diff --git a/CLAUDE.md b/CLAUDE.md index 33ff0d2..e7b8ff3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -384,6 +384,58 @@ Historiquement on a oublié `kind` dans `duplicateEntry` (bug "Site required" sur duplique-note), et `custom_fields` dans la rotation + duplicate. Cette liste évite de répéter ces erreurs. +## Encrypted attachments + +Per-entry file storage (PDFs, images of backup codes, etc.) encrypted +client-side with the vault key. + +- **Table** : `entry_attachments` (id, user_id, entry_id, filename, mime, + size_bytes, encrypted_blob TEXT base64, iv, created_at). FK cascade on + user + entry delete. +- **Endpoints** ([PM.Handler.Attachments.pas](delphi-backend/Handlers/PM.Handler.Attachments.pas)) : + - `GET /entries/{id}/attachments` → metadata array (no blob) + - `POST /entries/{id}/attachments` → full upload, ciphertext capped at + ~10 MB base64 (~7.5 MB raw) + - `GET /attachments/{id}` → metadata + blob (fetched on Download click) + - `DELETE /attachments/{id}` → permanent (no trash) +- **Crypto** : `encryptBlobBytes(uint8)` / `decryptBlobBytes(b64, iv)` + use the same AES-GCM 256 + `state.cryptoKey` as passwords. Filename, + mime, size are stored in cleartext (leaked metadata) so the listing + doesn't have to decrypt all rows on slideover-open. +- **Cap** : 5 MB raw client-side check, ~10 MB base64 server-side. +- **Master pw rotation** : attachments are NOT re-encrypted on rotation + → they become inaccessible. Known limitation, document for users who + rotate master pw (rotate before adding heavy attachments). +- **UI** : `soAttachmentsField(entryId)` rendered in slideover (existing + entries only, never on new). Upload via hidden file input + paperclip + button. Download reuses `Bridge.saveFile` (native Save As dialog). + +## Auto-backup (encrypted JSON, silent) + +Silent periodic encrypted export. Triggered on unlock (5s defer) if +`autoBackupInterval` days écoulés depuis `autoBackupLast`. Bouton +"Backup now" dans Settings pour trigger manuel. + +- **State** (DPAPI prefs via `Bridge.getPref/setPref`) — survivent au + port-change : `autoBackupEnabled`, `autoBackupDir`, `autoBackupInterval`, + `autoBackupKeep`, `autoBackupLast` (ISO), `autoBackupPwd` (prompté une + fois à l'enable). +- **Pwd** : indépendant du master pw, choisi par l'user au premier toggle. + Stocké DPAPI, utilisé silencieusement à chaque run. User le retape pour + restaurer via l'import standard. **Pourquoi pas dérivé du cryptoKey** : + master pw rotation re-génère cryptoKey → backups antérieurs deviennent + inaccessibles. Pwd séparée découple du cycle de vie de la vault key. +- **Filename** : `vault-autobackup-yyyymmdd-HHmmss.json` — sort lexical + = chronologique pour la rétention. Container = même format que + `doExport` user-driven → restore via "Import vault" classique. +- **Bridge cmds** ([UMainForm.pas](delphi-backend/UMainForm.pas)) : + - `folder/pick` → `SelectDirectory` FMX, callback `Bridge.onFolderPickResult(reqId, path)` + - `file/write?path=&data=` → silent write (no dialog) + - `file/listMatch?dir=&prefix=` → JSON `[{name,size,mtime}]` + - `file/delete?path=` → single delete +- **Retention** : après chaque write OK, list dir + sort name desc, + delete au-delà de `keep`. Best-effort. + ## Settings sync Per-user blob JSON dans `users.settings_json`, exposé via `GET/PUT diff --git a/css/style.css b/css/style.css index 3ce49f9..d47bc55 100644 --- a/css/style.css +++ b/css/style.css @@ -537,6 +537,12 @@ input[type="range"]::-webkit-slider-thumb { background: var(--accent-soft); box-shadow: inset 0 0 0 2px var(--accent); } +/* Folder reorder: insertion line above or below the hovered item. */ +.nav-item.drop-before { box-shadow: inset 0 2px 0 0 var(--accent); } +.nav-item.drop-after { box-shadow: inset 0 -2px 0 0 var(--accent); } +.nav-item.is-dragging { opacity: 0.4; } +.nav-item[draggable="true"] { cursor: grab; } +.nav-item[draggable="true"]:active { cursor: grabbing; } /* ---- 8. MAIN --------------------------------------------- */ @@ -1055,6 +1061,12 @@ input[type="range"]::-webkit-slider-thumb { background: var(--accent-soft); font-weight: 600; } +.entry-chip.is-aged { + color: #b45309; + background: rgba(245, 158, 11, 0.15); + font-weight: 600; +} +.entry-chip.is-aged svg { stroke: #b45309; } /* TOTP live-code panel (inside slide-over Two-factor field) */ .totp-panel { @@ -1464,6 +1476,183 @@ input[type="range"]::-webkit-slider-thumb { } .so-custom-add .btn { padding: 4px 10px; font-size: 12px; } +/* ---- Audit log viewer -------------------------------- */ +.entry-grid.is-audit { + display: flex; + flex-direction: column; + gap: 10px; +} +.audit-loading, .audit-empty { + color: var(--text-dim); + padding: 24px; + text-align: center; + font-size: 13px; +} +.audit-header { + display: flex; gap: 8px; + align-items: center; +} +.audit-search { + flex: 1; + padding: 6px 12px; + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text); + font-size: 13px; +} +.audit-search:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-soft); +} +.audit-note { + margin: 0; + font-size: 11px; + color: var(--text-faint); + font-style: italic; +} +.audit-list { + display: flex; + flex-direction: column; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg-elev); + max-height: 70vh; + overflow-y: auto; +} +.audit-row { + display: grid; + grid-template-columns: 160px 1fr 120px; + gap: 12px; + padding: 8px 14px; + font-size: 12px; + border-bottom: 1px solid var(--border-soft); + align-items: center; +} +.audit-row:last-child { border-bottom: none; } +.audit-row:hover { background: var(--bg); } +.audit-date { + color: var(--text-dim); + font-family: 'JetBrains Mono', ui-monospace, monospace; + white-space: nowrap; +} +.audit-action { + color: var(--text); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.audit-ip { + color: var(--text-faint); + font-family: 'JetBrains Mono', ui-monospace, monospace; + text-align: right; +} + +/* ---- Filters dropdown + chips ------------------------ */ +.filters-wrap { position: relative; } +.filters-count { + position: absolute; + top: -2px; right: -4px; + background: var(--accent); + color: white; + font-size: 9px; + font-weight: 700; + padding: 1px 5px; + border-radius: 8px; + line-height: 1.3; + pointer-events: none; +} +.filters-menu { + position: absolute; + top: calc(100% + 4px); right: 0; + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + box-shadow: var(--shadow-lg); + z-index: 60; + min-width: 240px; + padding: 6px; +} +.filters-menu-title { + padding: 4px 8px 6px; + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-faint); +} +.filters-menu-row { + display: flex; align-items: center; gap: 8px; + padding: 6px 8px; + cursor: pointer; + border-radius: 4px; + font-size: 13px; + color: var(--text); +} +.filters-menu-row:hover { background: var(--accent-soft); } +.filters-menu-row svg { width: 14px; height: 14px; color: var(--text-dim); } +.filters-menu-cb { + margin: 0; + accent-color: var(--accent); + cursor: pointer; +} +.filters-menu-clear { + width: 100%; + margin-top: 6px; + padding: 6px 8px; + background: none; + border: 1px solid var(--border); + border-radius: 4px; + color: var(--text-dim); + font-size: 12px; + cursor: pointer; +} +.filters-menu-clear:hover { + color: var(--text); + border-color: var(--accent); +} + +.filter-chips { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + padding: 8px 0; + margin-bottom: 4px; +} +.filter-chip { + display: inline-flex; align-items: center; gap: 6px; + background: var(--accent-soft); + color: var(--accent); + border: 1px solid var(--accent); + border-radius: 14px; + padding: 3px 4px 3px 10px; + font-size: 11px; + font-weight: 600; +} +.filter-chip svg { width: 12px; height: 12px; } +.filter-chip-x { + display: inline-flex; align-items: center; justify-content: center; + width: 16px; height: 16px; + background: none; + border: none; + cursor: pointer; + color: var(--accent); + border-radius: 50%; + padding: 0; +} +.filter-chip-x:hover { background: var(--accent); color: white; } +.filter-chip-x svg { width: 10px; height: 10px; } +.filter-chips-clear { + background: none; + border: none; + color: var(--text-faint); + font-size: 11px; + cursor: pointer; + padding: 3px 8px; + text-decoration: underline; +} +.filter-chips-clear:hover { color: var(--text-dim); } + /* ---- Note cards (kind=note) -------------------------- */ .entry-note-row { display: flex; align-items: center; @@ -2168,6 +2357,140 @@ input[type="password"]::-ms-clear { } .nav-item:hover .nav-count { display: none; } +/* Folder edit (pencil) button — same hover-reveal as the delete X. */ +.nav-item .folder-edit { + width: 20px; height: 20px; + padding: 0; + line-height: 0; + display: inline-flex; align-items: center; justify-content: center; + background: transparent; border: none; + color: var(--text-faint); + border-radius: 4px; + opacity: 0; + transition: opacity var(--t-fast), color var(--t-fast), background var(--t-fast); + cursor: pointer; +} +.nav-item .folder-edit svg { width: 13px; height: 13px; display: block; } +.nav-item:hover .folder-edit { opacity: 0.7; } +.nav-item .folder-edit:hover { + opacity: 1; + color: var(--accent); + background: var(--accent-soft); +} +/* When folder-edit is rendered before folder-delete in the DOM, push delete + to the far right so the layout stays {icon} {label} … {edit} {delete}. */ +.nav-item .folder-edit + .folder-delete { margin-left: 2px; } + +/* ============================================================ + FOLDER CUSTOMIZATION MODAL + ============================================================ */ +#folderModal .form-field { margin-bottom: 14px; } +#folderModal .form-field label { + display: block; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-faint); + margin-bottom: 4px; +} +#folderModal .so-input { width: 100%; } + +/* ============================================================ + ATTACHMENTS list inside the slideover + ============================================================ */ +.so-attach-list { + margin-top: 8px; + display: flex; + flex-direction: column; + gap: 6px; +} +.so-attach-empty { + font-size: 12px; + color: var(--text-faint); + padding: 6px 2px; + font-style: italic; +} +.so-attach-row { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 10px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 6px; +} +.so-attach-info { + flex: 1; + min-width: 0; +} +.so-attach-name { + font-size: 13px; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.so-attach-meta { + font-size: 11px; + color: var(--text-faint); + margin-top: 1px; +} +.so-attach-row .icon-btn { flex-shrink: 0; } +.folder-swatch-row, +.folder-icon-row { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-top: 6px; +} +.folder-swatch { + width: 26px; height: 26px; + border-radius: 50%; + border: 2px solid transparent; + background: var(--bg-elev); + cursor: pointer; + padding: 0; + transition: transform var(--t-fast), border-color var(--t-fast); +} +.folder-swatch:hover { transform: scale(1.1); } +.folder-swatch.is-active { + border-color: var(--text); + box-shadow: 0 0 0 2px var(--bg); +} +.folder-swatch.is-default { + background: + linear-gradient(135deg, transparent 47%, var(--danger) 47% 53%, transparent 53%), + var(--bg-elev); +} +.folder-icon-btn { + width: 36px; height: 36px; + display: inline-flex; align-items: center; justify-content: center; + border: 1px solid var(--border); + background: var(--bg-elev); + border-radius: 6px; + cursor: pointer; + color: var(--text-dim); + transition: background var(--t-fast), border-color var(--t-fast), color var(--t-fast); +} +.folder-icon-btn svg { width: 18px; height: 18px; } +.folder-icon-btn:hover { background: var(--bg-elev-2); color: var(--text); } +.folder-icon-btn.is-active { + border-color: var(--accent); + color: var(--accent); + background: var(--accent-soft); +} +.folder-preview { + display: flex; align-items: center; gap: 10px; + margin: 16px 0 4px; + padding: 12px 14px; + background: var(--bg-elev); + border-radius: 6px; + color: var(--text); + font-weight: 500; +} +.folder-preview svg { width: 18px; height: 18px; } + /* Remember username checkbox row on auth screen. */ .remember-row { display: flex; align-items: center; gap: 8px; @@ -2241,3 +2564,53 @@ input[type="password"]::-ms-clear { font-size: 12px; cursor: pointer; } + +/* ============================================================ + PRINTABLE RECOVERY CODE — only renders on the printed page + ============================================================ */ +#printRecoveryArea { display: none; } + +@media print { + /* Hide the running app entirely; show only the printable sheet. */ + body > *:not(#printRecoveryArea) { display: none !important; } + #printRecoveryArea { + display: block !important; + position: static; + background: #fff !important; + color: #000 !important; + } + .prc-sheet { + font-family: Georgia, 'Times New Roman', serif; + padding: 40px; + max-width: 700px; + margin: 0 auto; + } + .prc-sheet h1 { + font-size: 22px; + margin: 0 0 18px; + border-bottom: 2px solid #000; + padding-bottom: 8px; + } + .prc-meta { + display: flex; + justify-content: space-between; + font-size: 12px; + margin-bottom: 28px; + } + .prc-code { + font-family: 'Courier New', monospace; + font-size: 32px; + letter-spacing: 6px; + text-align: center; + padding: 24px; + border: 2px dashed #000; + margin: 28px 0; + font-weight: bold; + } + .prc-instructions { + font-size: 13px; + line-height: 1.6; + } + .prc-instructions ol { padding-left: 22px; } + @page { margin: 1.5cm; } +} diff --git a/delphi-backend/Handlers/PM.Handler.Attachments.pas b/delphi-backend/Handlers/PM.Handler.Attachments.pas new file mode 100644 index 0000000..c6f4ee5 --- /dev/null +++ b/delphi-backend/Handlers/PM.Handler.Attachments.pas @@ -0,0 +1,317 @@ +unit PM.Handler.Attachments; + +(* + Encrypted file attachments per entry. + + GET /entries/{id}/attachments -> [{id, filename, mime, size_bytes, created_at}, ...] + POST /entries/{id}/attachments body {filename, mime, encrypted_blob, iv, size_bytes} + -> {id, filename, mime, size_bytes, created_at} + GET /attachments/{id} -> {id, filename, mime, size_bytes, encrypted_blob, iv} + DELETE /attachments/{id} -> {message} + + encrypted_blob is the base64-encoded AES-GCM ciphertext of the raw file + bytes, produced by the JS client with the per-user vault key. Server + never sees plaintext. + + Per-attachment cap: ~10 MB of ciphertext-as-base64 (≈ 7.5 MB raw file). + Heavier attachments aren't appropriate for SQLite TEXT storage anyway. +*) + +interface + +implementation + +uses + System.SysUtils, System.JSON, + FireDAC.Comp.Client, FireDAC.Stan.Param, + IdCustomHTTPServer, + PM.Router, PM.JSON, PM.Database, PM.Session, PM.Audit; + +const + MAX_ATTACHMENT_B64 = 10 * 1024 * 1024; // 10 MB of base64 text + +function GetClientIP(ARequest: TIdHTTPRequestInfo): string; +begin + Result := ARequest.RemoteIP; + if Result = '' then Result := '127.0.0.1'; +end; + +// Ownership check: returns True iff the entry exists and belongs to LUserId. +function EntryBelongsToUser(LEntryId, LUserId: Integer): Boolean; +var + LQ: TFDQuery; +begin + LQ := TFDQuery.Create(nil); + try + LQ.Connection := DB.Connection; + LQ.SQL.Text := + 'SELECT 1 FROM vault_entries WHERE id = :id AND user_id = :uid'; + LQ.ParamByName('id').AsInteger := LEntryId; + LQ.ParamByName('uid').AsInteger := LUserId; + LQ.Open; + Result := not LQ.Eof; + finally + LQ.Free; + end; +end; + +// ===== GET /entries/{id}/attachments ========================================= + +procedure HandleListAttachments(ARequest: TIdHTTPRequestInfo; + AResponse: TIdHTTPResponseInfo; const AParams: TArray); +var + LUserId, LEntryId: Integer; + LQ: TFDQuery; + LArr: TJSONArray; + LObj: TJSONObject; +begin + try + LUserId := Authenticate(ARequest, AResponse); + except + on ESessionRejected do Exit; + end; + + LEntryId := StrToIntDef(AParams[0], 0); + if LEntryId = 0 then + begin + TJSONHelper.SendError(AResponse, 400, 'Invalid entry id'); + Exit; + end; + + LArr := TJSONArray.Create; + DB.Lock; + try + if not EntryBelongsToUser(LEntryId, LUserId) then + begin + TJSONHelper.SendError(AResponse, 404, 'Entry not found'); + LArr.Free; + Exit; + end; + LQ := TFDQuery.Create(nil); + try + LQ.Connection := DB.Connection; + LQ.SQL.Text := + 'SELECT id, filename, mime, size_bytes, created_at ' + + 'FROM entry_attachments WHERE entry_id = :eid AND user_id = :uid ' + + 'ORDER BY created_at DESC'; + LQ.ParamByName('eid').AsInteger := LEntryId; + LQ.ParamByName('uid').AsInteger := LUserId; + LQ.Open; + while not LQ.Eof do + begin + LObj := TJSONObject.Create; + LObj.AddPair('id', TJSONNumber.Create(LQ.FieldByName('id').AsInteger)); + LObj.AddPair('filename', LQ.FieldByName('filename').AsString); + LObj.AddPair('mime', LQ.FieldByName('mime').AsString); + LObj.AddPair('size_bytes', TJSONNumber.Create(LQ.FieldByName('size_bytes').AsInteger)); + LObj.AddPair('created_at', + FormatDateTime('yyyy-mm-dd"T"hh:nn:ss', LQ.FieldByName('created_at').AsDateTime)); + LArr.Add(LObj); + LQ.Next; + end; + finally + LQ.Free; + end; + finally + DB.Unlock; + end; + TJSONHelper.SendJSON(AResponse, LArr); +end; + +// ===== POST /entries/{id}/attachments ======================================== + +procedure HandleCreateAttachment(ARequest: TIdHTTPRequestInfo; + AResponse: TIdHTTPResponseInfo; const AParams: TArray); +var + LUserId, LEntryId, LNewId: Integer; + LBody: TJSONObject; + LFilename, LMime, LBlob, LIv: string; + LSize: Integer; + LQ: TFDQuery; + LObj: TJSONObject; +begin + try + LUserId := Authenticate(ARequest, AResponse); + RequireCSRF(ARequest, AResponse, LUserId); + except + on ESessionRejected do Exit; + end; + + LEntryId := StrToIntDef(AParams[0], 0); + if LEntryId = 0 then + begin + TJSONHelper.SendError(AResponse, 400, 'Invalid entry id'); + Exit; + end; + + LBody := TJSONHelper.ReadBody(ARequest); + try + LFilename := Trim(LBody.GetValue('filename', '')); + LMime := LBody.GetValue('mime', ''); + LBlob := LBody.GetValue('encrypted_blob', ''); + LIv := LBody.GetValue('iv', ''); + LSize := LBody.GetValue('size_bytes', 0); + finally + LBody.Free; + end; + + if (LFilename = '') or (LBlob = '') or (LIv = '') then + begin + TJSONHelper.SendError(AResponse, 400, 'Missing required fields'); + Exit; + end; + if Length(LBlob) > MAX_ATTACHMENT_B64 then + begin + TJSONHelper.SendError(AResponse, 413, 'Attachment too large (max ~7.5 MB raw)'); + Exit; + end; + + DB.Lock; + try + if not EntryBelongsToUser(LEntryId, LUserId) then + begin + TJSONHelper.SendError(AResponse, 404, 'Entry not found'); + Exit; + end; + LQ := TFDQuery.Create(nil); + try + LQ.Connection := DB.Connection; + LQ.SQL.Text := + 'INSERT INTO entry_attachments ' + + '(user_id, entry_id, filename, mime, size_bytes, encrypted_blob, iv) ' + + 'VALUES (:uid, :eid, :name, :mime, :sz, :blob, :iv)'; + LQ.ParamByName('uid').AsInteger := LUserId; + LQ.ParamByName('eid').AsInteger := LEntryId; + LQ.ParamByName('name').AsString := LFilename; + LQ.ParamByName('mime').AsString := LMime; + LQ.ParamByName('sz').AsInteger := LSize; + LQ.ParamByName('blob').AsString := LBlob; + LQ.ParamByName('iv').AsString := LIv; + LQ.ExecSQL; + LNewId := DB.Connection.GetLastAutoGenValue('entry_attachments'); + finally + LQ.Free; + end; + finally + DB.Unlock; + end; + + LogAudit(LUserId, 'add_attachment', GetClientIP(ARequest)); + LObj := TJSONObject.Create; + LObj.AddPair('id', TJSONNumber.Create(LNewId)); + LObj.AddPair('filename', LFilename); + LObj.AddPair('mime', LMime); + LObj.AddPair('size_bytes', TJSONNumber.Create(LSize)); + LObj.AddPair('created_at', FormatDateTime('yyyy-mm-dd"T"hh:nn:ss', Now)); + TJSONHelper.SendJSON(AResponse, LObj); +end; + +// ===== GET /attachments/{id} ================================================= + +procedure HandleGetAttachment(ARequest: TIdHTTPRequestInfo; + AResponse: TIdHTTPResponseInfo; const AParams: TArray); +var + LUserId, LId: Integer; + LQ: TFDQuery; + LObj: TJSONObject; +begin + try + LUserId := Authenticate(ARequest, AResponse); + 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 := + 'SELECT id, filename, mime, size_bytes, encrypted_blob, iv ' + + 'FROM entry_attachments WHERE id = :id AND user_id = :uid'; + LQ.ParamByName('id').AsInteger := LId; + LQ.ParamByName('uid').AsInteger := LUserId; + LQ.Open; + if LQ.Eof then + begin + TJSONHelper.SendError(AResponse, 404, 'Not found'); + Exit; + end; + LObj := TJSONObject.Create; + LObj.AddPair('id', TJSONNumber.Create(LQ.FieldByName('id').AsInteger)); + LObj.AddPair('filename', LQ.FieldByName('filename').AsString); + LObj.AddPair('mime', LQ.FieldByName('mime').AsString); + LObj.AddPair('size_bytes', TJSONNumber.Create(LQ.FieldByName('size_bytes').AsInteger)); + LObj.AddPair('encrypted_blob', LQ.FieldByName('encrypted_blob').AsString); + LObj.AddPair('iv', LQ.FieldByName('iv').AsString); + TJSONHelper.SendJSON(AResponse, LObj); + finally + LQ.Free; + end; + finally + DB.Unlock; + end; +end; + +// ===== DELETE /attachments/{id} ============================================== + +procedure HandleDeleteAttachment(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 := + 'DELETE FROM entry_attachments WHERE id = :id AND user_id = :uid'; + LQ.ParamByName('id').AsInteger := LId; + LQ.ParamByName('uid').AsInteger := LUserId; + LQ.ExecSQL; + if LQ.RowsAffected = 0 then + begin + TJSONHelper.SendError(AResponse, 404, 'Not found'); + Exit; + end; + finally + LQ.Free; + end; + finally + DB.Unlock; + end; + + LogAudit(LUserId, 'delete_attachment', GetClientIP(ARequest)); + TJSONHelper.SendOK(AResponse, 'Deleted'); +end; + +initialization + Router.Register('GET', '/entries/(\d+)/attachments', HandleListAttachments); + Router.Register('POST', '/entries/(\d+)/attachments', HandleCreateAttachment); + Router.Register('GET', '/attachments/(\d+)', HandleGetAttachment); + Router.Register('DELETE', '/attachments/(\d+)', HandleDeleteAttachment); + +end. diff --git a/delphi-backend/Handlers/PM.Handler.Audit.pas b/delphi-backend/Handlers/PM.Handler.Audit.pas index 89d416d..1911ee5 100644 --- a/delphi-backend/Handlers/PM.Handler.Audit.pas +++ b/delphi-backend/Handlers/PM.Handler.Audit.pas @@ -1,13 +1,11 @@ unit PM.Handler.Audit; (* - POST /audit body {action, site} -> {ok:true} + POST /audit body {action, site} -> {ok:true} + GET /audit?limit=N&before= -> [{id, action, ip, created_at}, ...] - Light-weight endpoint that lets the JS layer append an entry to audit_log - without going through the full entries pipeline. Used by the autofill - feature to record which site was filled (action = "autofill:"). - The bearer token identifies the user — no data beyond the action string - is stored. + audit_log is auto-purged after 30 days by Database init. The viewer + reads page-by-page via the `before` cursor (id < before). *) interface @@ -17,7 +15,8 @@ implementation uses System.SysUtils, System.JSON, IdCustomHTTPServer, - PM.Router, PM.JSON, PM.Session, PM.Audit; + Data.DB, FireDAC.Comp.Client, FireDAC.Stan.Param, + PM.Router, PM.JSON, PM.Session, PM.Audit, PM.Database; function GetClientIP(ARequest: TIdHTTPRequestInfo): string; begin @@ -59,7 +58,67 @@ begin TJSONHelper.SendOK(AResponse); end; +// GET /audit — return up to `limit` log entries for the current user, +// optionally newer-than-cursor (`before` = id). Most-recent first. +procedure HandleGetAudit(ARequest: TIdHTTPRequestInfo; + AResponse: TIdHTTPResponseInfo; const AParams: TArray); +var + LUserId, LLimit, LBefore: Integer; + LQ: TFDQuery; + LArr: TJSONArray; + LObj: TJSONObject; +begin + LUserId := Authenticate(ARequest, AResponse); + + LLimit := StrToIntDef(ARequest.Params.Values['limit'], 100); + if LLimit <= 0 then LLimit := 100; + if LLimit > 500 then LLimit := 500; + LBefore := StrToIntDef(ARequest.Params.Values['before'], 0); + + LArr := TJSONArray.Create; + DB.Lock; + try + LQ := TFDQuery.Create(nil); + try + LQ.Connection := DB.Connection; + if LBefore > 0 then + LQ.SQL.Text := + 'SELECT id, action, ip, created_at FROM audit_log ' + + 'WHERE user_id = :uid AND id < :b ' + + 'ORDER BY id DESC LIMIT :l' + else + LQ.SQL.Text := + 'SELECT id, action, ip, created_at FROM audit_log ' + + 'WHERE user_id = :uid ' + + 'ORDER BY id DESC LIMIT :l'; + LQ.ParamByName('uid').AsInteger := LUserId; + LQ.ParamByName('l').AsInteger := LLimit; + if LBefore > 0 then + LQ.ParamByName('b').AsInteger := LBefore; + LQ.Open; + while not LQ.Eof do + begin + LObj := TJSONObject.Create; + LObj.AddPair('id', TJSONNumber.Create(LQ.FieldByName('id').AsInteger)); + LObj.AddPair('action', LQ.FieldByName('action').AsString); + LObj.AddPair('ip', LQ.FieldByName('ip').AsString); + LObj.AddPair('created_at', + FormatDateTime('yyyy-mm-dd hh:nn:ss', + LQ.FieldByName('created_at').AsDateTime)); + LArr.Add(LObj); + LQ.Next; + end; + finally + LQ.Free; + end; + finally + DB.Unlock; + end; + TJSONHelper.SendJSON(AResponse, LArr); +end; + initialization Router.Register('POST', '/audit', HandlePostAudit); + Router.Register('GET', '/audit', HandleGetAudit); end. diff --git a/delphi-backend/Handlers/PM.Handler.Entries.pas b/delphi-backend/Handlers/PM.Handler.Entries.pas index 4e5d144..c653aa4 100644 --- a/delphi-backend/Handlers/PM.Handler.Entries.pas +++ b/delphi-backend/Handlers/PM.Handler.Entries.pas @@ -139,6 +139,14 @@ begin LObj.AddPair('custom_fields_iv', LQ.FieldByName('custom_fields_iv').AsString); LObj.AddPair('created_at', ISODateTimeField(LQ.FieldByName('created_at'))); LObj.AddPair('updated_at', ISODateTimeField(LQ.FieldByName('updated_at'))); + if LQ.FieldByName('accessed_at').IsNull then + LObj.AddPair('accessed_at', TJSONNull.Create) + else + LObj.AddPair('accessed_at', ISODateTimeField(LQ.FieldByName('accessed_at'))); + if LQ.FieldByName('password_changed_at').IsNull then + LObj.AddPair('password_changed_at', TJSONNull.Create) + else + LObj.AddPair('password_changed_at', ISODateTimeField(LQ.FieldByName('password_changed_at'))); LArr.Add(LObj); LQ.Next; end; @@ -213,9 +221,9 @@ begin 'INSERT INTO vault_entries ' + '(user_id, site, title, username, encrypted_password, iv, encryption_method, ' + ' folder, tags, totp_secret, totp_iv, kind, custom_fields, custom_fields_iv,' + - ' created_at, updated_at) ' + + ' created_at, updated_at, password_changed_at) ' + 'VALUES (:uid, :s, :tt, :u, :e, :i, ''client'', :f, :t, :ts, :tiv, :k, ' + - ' :cf, :cfiv, :c, :c2)'; + ' :cf, :cfiv, :c, :c2, :c)'; LQ.ParamByName('uid').AsInteger := LUserId; LQ.ParamByName('s').AsString := LSite; LQ.ParamByName('tt').AsString := LTitle; @@ -355,12 +363,16 @@ begin LQ.ParamByName('id').AsInteger := LId; LQ.ExecSQL; + // password_changed_at fires only when the ciphertext actually + // changes — same conditional used above for history insertion. LQ.SQL.Text := 'UPDATE vault_entries ' + 'SET site=:s, title=:tt, username=:u, encrypted_password=:e, iv=:i, ' + ' folder=:f, tags=:t, totp_secret=:ts, totp_iv=:tiv, kind=:k, ' + ' custom_fields=:cf, custom_fields_iv=:cfiv, ' + - ' updated_at=:c ' + + ' updated_at=:c, ' + + ' password_changed_at = CASE WHEN encrypted_password <> :e ' + + ' THEN :c ELSE password_changed_at END ' + 'WHERE id=:id AND user_id=:uid'; LQ.ParamByName('s').AsString := LSite; LQ.ParamByName('tt').AsString := LTitle; @@ -546,6 +558,51 @@ begin TJSONHelper.SendOK(AResponse, 'Toggled'); end; +// ===== POST /entries/{id}/touch ============================================== +// Bumps accessed_at. Called from JS on copy / slideover-open so the sidebar +// "Recent" view can show what the user actually uses. Auth-only (no CSRF +// requirement — this is a write but harmless to forge across sessions, and +// the call is fire-and-forget from clipboard handlers where blocking on +// CSRF would be visibly laggy). +procedure HandleTouchEntry(ARequest: TIdHTTPRequestInfo; + AResponse: TIdHTTPResponseInfo; const AParams: TArray); +var + LUserId, LId: Integer; + LQ: TFDQuery; +begin + try + LUserId := Authenticate(ARequest, AResponse); + 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 accessed_at = CURRENT_TIMESTAMP ' + + 'WHERE id = :id AND user_id = :uid AND deleted = 0'; + LQ.ParamByName('id').AsInteger := LId; + LQ.ParamByName('uid').AsInteger := LUserId; + LQ.ExecSQL; + finally + LQ.Free; + end; + finally + DB.Unlock; + end; + TJSONHelper.SendOK(AResponse); +end; + // ===== POST /entries/{id}/icon =============================================== // Stores (or clears) a cached favicon for one entry. Separate endpoint so the // client can save the icon without re-PUT-ing the full entry (which would @@ -968,6 +1025,7 @@ initialization Router.Register('POST', '/entries/bulk-import', HandleBulkImport); Router.Register('POST', '/entries/(\d+)/restore', HandleRestoreEntry); Router.Register('POST', '/entries/(\d+)/favorite', HandleToggleFavorite); + Router.Register('POST', '/entries/(\d+)/touch', HandleTouchEntry); Router.Register('POST', '/entries/(\d+)/icon', HandleSetEntryIcon); Router.Register('GET', '/entries/(\d+)/history', HandleGetEntryHistory); Router.Register('GET', '/entries/count', HandleEntriesCount); diff --git a/delphi-backend/Handlers/PM.Handler.Folders.pas b/delphi-backend/Handlers/PM.Handler.Folders.pas index 72cf2b9..4873ca4 100644 --- a/delphi-backend/Handlers/PM.Handler.Folders.pas +++ b/delphi-backend/Handlers/PM.Handler.Folders.pas @@ -1,9 +1,10 @@ unit PM.Handler.Folders; (* - GET /folders -> JSON array of folder names - POST /folders body {name} -> {message,name} - DELETE /folders/{name} -> {message} + GET /folders -> [{name, color, icon}, ...] + POST /folders body {name, color?, icon?} -> {message, name} + PUT /folders/{name} body {color?, icon?} -> {message} + DELETE /folders/{name} -> {message} *) interface @@ -12,6 +13,7 @@ implementation uses System.SysUtils, System.JSON, System.NetEncoding, + System.Generics.Collections, FireDAC.Comp.Client, FireDAC.Stan.Param, IdCustomHTTPServer, PM.Router, PM.JSON, PM.Database, PM.Session, PM.Audit, PM.RateLimit; @@ -24,6 +26,7 @@ var LUserId: Integer; LQ: TFDQuery; LArr: TJSONArray; + LObj: TJSONObject; begin try LUserId := Authenticate(ARequest, AResponse); @@ -37,12 +40,18 @@ begin LQ := TFDQuery.Create(nil); try LQ.Connection := DB.Connection; - LQ.SQL.Text := 'SELECT name FROM folders WHERE user_id = :uid ORDER BY name'; + LQ.SQL.Text := + 'SELECT name, color, icon FROM folders ' + + 'WHERE user_id = :uid ORDER BY sort_order, name'; LQ.ParamByName('uid').AsInteger := LUserId; LQ.Open; while not LQ.Eof do begin - LArr.Add(LQ.FieldByName('name').AsString); + LObj := TJSONObject.Create; + LObj.AddPair('name', LQ.FieldByName('name').AsString); + LObj.AddPair('color', LQ.FieldByName('color').AsString); + LObj.AddPair('icon', LQ.FieldByName('icon').AsString); + LArr.Add(LObj); LQ.Next; end; finally @@ -61,7 +70,7 @@ procedure HandleCreateFolder(ARequest: TIdHTTPRequestInfo; var LUserId: Integer; LBody: TJSONObject; - LName: string; + LName, LColor, LIcon: string; LQ: TFDQuery; LObj: TJSONObject; begin @@ -74,7 +83,9 @@ begin LBody := TJSONHelper.ReadBody(ARequest); try - LName := Trim(LBody.GetValue('name', '')); + LName := Trim(LBody.GetValue('name', '')); + LColor := Trim(LBody.GetValue('color', '')); + LIcon := Trim(LBody.GetValue('icon', '')); finally LBody.Free; end; @@ -95,9 +106,15 @@ begin 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; + LQ.SQL.Text := + 'INSERT INTO folders (user_id, name, color, icon) ' + + 'VALUES (:uid, :name, :color, :icon)'; + LQ.ParamByName('uid').AsInteger := LUserId; + LQ.ParamByName('name').AsString := LName; + if LColor = '' then LQ.ParamByName('color').Clear + else LQ.ParamByName('color').AsString := LColor; + if LIcon = '' then LQ.ParamByName('icon').Clear + else LQ.ParamByName('icon').AsString := LIcon; try LQ.ExecSQL; except @@ -121,6 +138,156 @@ begin TJSONHelper.SendJSON(AResponse, LObj); end; +// ===== PUT /folders/{name} =================================================== +// Body: {color?, icon?} — pass empty string to clear. + +procedure HandleUpdateFolder(ARequest: TIdHTTPRequestInfo; + AResponse: TIdHTTPResponseInfo; const AParams: TArray); +var + LUserId: Integer; + LBody: TJSONObject; + LName, LColor, LIcon: string; + LHasColor, LHasIcon: Boolean; + LQ: TFDQuery; +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 customise All'); + Exit; + end; + + LBody := TJSONHelper.ReadBody(ARequest); + try + LHasColor := LBody.GetValue('color') <> nil; + LHasIcon := LBody.GetValue('icon') <> nil; + LColor := LBody.GetValue('color', ''); + LIcon := LBody.GetValue('icon', ''); + finally + LBody.Free; + end; + + if not (LHasColor or LHasIcon) then + begin + TJSONHelper.SendOK(AResponse, 'No change'); + Exit; + end; + + DB.Lock; + try + LQ := TFDQuery.Create(nil); + try + LQ.Connection := DB.Connection; + // Build SET clause dynamically based on which fields the caller sent. + var LSet := ''; + if LHasColor then LSet := 'color = :color'; + if LHasIcon then + begin + if LSet <> '' then LSet := LSet + ', '; + LSet := LSet + 'icon = :icon'; + end; + LQ.SQL.Text := + 'UPDATE folders SET ' + LSet + + ' WHERE user_id = :uid AND name = :name'; + LQ.ParamByName('uid').AsInteger := LUserId; + LQ.ParamByName('name').AsString := LName; + if LHasColor then + begin + if LColor = '' then LQ.ParamByName('color').Clear + else LQ.ParamByName('color').AsString := LColor; + end; + if LHasIcon then + begin + if LIcon = '' then LQ.ParamByName('icon').Clear + else LQ.ParamByName('icon').AsString := LIcon; + end; + LQ.ExecSQL; + if LQ.RowsAffected = 0 then + begin + TJSONHelper.SendError(AResponse, 404, 'Not found'); + Exit; + end; + finally + LQ.Free; + end; + finally + DB.Unlock; + end; + + LogAudit(LUserId, 'update_folder', GetClientIP(ARequest)); + TJSONHelper.SendOK(AResponse, 'Updated'); +end; + +// ===== POST /folders/reorder ================================================= +// Body: {names: ["Work", "Personal", "Misc"]} — write sort_order = index+1 +// for each. Names not in the list keep their previous sort_order (so a +// partial reorder still works after another tab created a folder). + +procedure HandleReorderFolders(ARequest: TIdHTTPRequestInfo; + AResponse: TIdHTTPResponseInfo; const AParams: TArray); +var + LUserId: Integer; + LBody: TJSONObject; + LArr: TJSONArray; + LQ: TFDQuery; + I: Integer; +begin + try + LUserId := Authenticate(ARequest, AResponse); + RequireCSRF(ARequest, AResponse, LUserId); + except + on ESessionRejected do Exit; + end; + + LBody := TJSONHelper.ReadBody(ARequest); + try + LArr := LBody.GetValue('names'); + if (LArr = nil) or (LArr.Count = 0) then + begin + TJSONHelper.SendError(AResponse, 400, 'names array required'); + Exit; + end; + DB.Lock; + try + LQ := TFDQuery.Create(nil); + try + LQ.Connection := DB.Connection; + LQ.SQL.Text := + 'UPDATE folders SET sort_order = :ord ' + + 'WHERE user_id = :uid AND name = :name'; + for I := 0 to LArr.Count - 1 do + begin + LQ.ParamByName('uid').AsInteger := LUserId; + LQ.ParamByName('ord').AsInteger := I + 1; + LQ.ParamByName('name').AsString := LArr.Items[I].Value; + LQ.ExecSQL; + end; + finally + LQ.Free; + end; + finally + DB.Unlock; + end; + finally + LBody.Free; + end; + + LogAudit(LUserId, 'reorder_folders', GetClientIP(ARequest)); + TJSONHelper.SendOK(AResponse, 'Reordered'); +end; + // ===== DELETE /folders/{name} ================================================ procedure HandleDeleteFolder(ARequest: TIdHTTPRequestInfo; @@ -194,7 +361,9 @@ end; initialization Router.Register('GET', '/folders', HandleGetFolders); - Router.Register('POST', '/folders', HandleCreateFolder); + Router.Register('POST', '/folders', HandleCreateFolder); + Router.Register('POST', '/folders/reorder', HandleReorderFolders); + Router.Register('PUT', '/folders/(.+)', HandleUpdateFolder); Router.Register('DELETE', '/folders/(.+)', HandleDeleteFolder); end. diff --git a/delphi-backend/PMServer.dpr b/delphi-backend/PMServer.dpr index cd49234..e68b182 100644 --- a/delphi-backend/PMServer.dpr +++ b/delphi-backend/PMServer.dpr @@ -28,7 +28,8 @@ uses PM.Handler.Passkey in 'Handlers\PM.Handler.Passkey.pas', PM.Handler.Recovery in 'Handlers\PM.Handler.Recovery.pas', PM.Handler.Audit in 'Handlers\PM.Handler.Audit.pas', - PM.Handler.Settings in 'Handlers\PM.Handler.Settings.pas'; + PM.Handler.Settings in 'Handlers\PM.Handler.Settings.pas', + PM.Handler.Attachments in 'Handlers\PM.Handler.Attachments.pas'; {$R *.res} {$R assets\assets.res} diff --git a/delphi-backend/PMServer.dproj b/delphi-backend/PMServer.dproj index 6da4216..91a46cf 100644 --- a/delphi-backend/PMServer.dproj +++ b/delphi-backend/PMServer.dproj @@ -1,1259 +1,1260 @@ - - - {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 - 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) - app.ico - app.png - app.png - - - DEBUG;$(DCC_Define) - false - true - true - true - - - Debug - - - Debug - - - Debug - - - Debug - - - PerMonitorV2 - true - 1033 - CompanyName=;FileDescription=$(MSBuildProjectName);FileVersion=1.0.0.0;InternalName=;LegalCopyright=;LegalTrademarks=;OriginalFilename=;ProductName=$(MSBuildProjectName);ProductVersion=1.0.0.0;Comments=;ProgramID=com.embarcadero.$(MSBuildProjectName) - - PMServer_Icon1.ico - app.png - app.png - - - - MainSource - - - -
MainForm
-
- - - - - - - - - - - - - - - - - - - - - - - - - - Base - - - Cfg_1 - Base - - - Cfg_2 - Base - -
- - Delphi.Personality.12 - - - - - PMServer.dpr - - - Microsoft Office 2000 Sample Automation Server Wrapper Components - Microsoft Office XP Sample Automation Server Wrapper Components - - - - False - True - True - True - True - True - True - False - - - - - true - - - - - true - - - - - true - - - - - Assets\ - Logo44x44.png - true - - - - - 1 - - - Contents\MacOS - 1 - - - 0 - - - - - classes - 64 - - - classes - 64 - - - - - res\xml - 1 - - - res\xml - 1 - - - - - library\lib\armeabi - 1 - - - library\lib\armeabi - 1 - - - - - library\lib\armeabi-v7a - 1 - - - - - library\lib\mips - 1 - - - library\lib\mips - 1 - - - - - library\lib\armeabi-v7a - 1 - - - library\lib\arm64-v8a - 1 - - - - - library\lib\armeabi-v7a - 1 - - - - - res\drawable - 1 - - - res\drawable - 1 - - - - - res\drawable-anydpi-v21 - 1 - - - res\drawable-anydpi-v21 - 1 - - - - - res\values - 1 - - - res\values - 1 - - - - - res\values-v21 - 1 - - - res\values-v21 - 1 - - - - - res\values-v31 - 1 - - - res\values-v31 - 1 - - - - - res\drawable-anydpi-v26 - 1 - - - res\drawable-anydpi-v26 - 1 - - - - - res\drawable - 1 - - - res\drawable - 1 - - - - - res\drawable - 1 - - - res\drawable - 1 - - - - - res\drawable - 1 - - - res\drawable - 1 - - - - - res\drawable-anydpi-v33 - 1 - - - res\drawable-anydpi-v33 - 1 - - - - - res\values - 1 - - - res\values - 1 - - - - - res\values-night-v21 - 1 - - - res\values-night-v21 - 1 - - - - - res\drawable - 1 - - - res\drawable - 1 - - - - - res\drawable-xxhdpi - 1 - - - res\drawable-xxhdpi - 1 - - - - - res\drawable-xxxhdpi - 1 - - - res\drawable-xxxhdpi - 1 - - - - - res\drawable-ldpi - 1 - - - res\drawable-ldpi - 1 - - - - - res\drawable-mdpi - 1 - - - res\drawable-mdpi - 1 - - - - - res\drawable-hdpi - 1 - - - res\drawable-hdpi - 1 - - - - - res\drawable-xhdpi - 1 - - - res\drawable-xhdpi - 1 - - - - - res\drawable-mdpi - 1 - - - res\drawable-mdpi - 1 - - - - - res\drawable-hdpi - 1 - - - res\drawable-hdpi - 1 - - - - - res\drawable-xhdpi - 1 - - - res\drawable-xhdpi - 1 - - - - - res\drawable-xxhdpi - 1 - - - res\drawable-xxhdpi - 1 - - - - - res\drawable-xxxhdpi - 1 - - - res\drawable-xxxhdpi - 1 - - - - - res\drawable-small - 1 - - - res\drawable-small - 1 - - - - - res\drawable-normal - 1 - - - res\drawable-normal - 1 - - - - - res\drawable-large - 1 - - - res\drawable-large - 1 - - - - - res\drawable-xlarge - 1 - - - res\drawable-xlarge - 1 - - - - - res\values - 1 - - - res\values - 1 - - - - - res\drawable-anydpi-v24 - 1 - - - res\drawable-anydpi-v24 - 1 - - - - - res\drawable - 1 - - - res\drawable - 1 - - - - - res\drawable-night-anydpi-v21 - 1 - - - res\drawable-night-anydpi-v21 - 1 - - - - - res\drawable-anydpi-v31 - 1 - - - res\drawable-anydpi-v31 - 1 - - - - - res\drawable-night-anydpi-v31 - 1 - - - res\drawable-night-anydpi-v31 - 1 - - - - - 1 - - - Contents\MacOS - 1 - - - 0 - - - - - Contents\MacOS - 1 - .framework - - - Contents\MacOS - 1 - .framework - - - Contents\MacOS - 1 - .framework - - - 0 - - - - - 1 - .dylib - - - 1 - .dylib - - - 1 - .dylib - - - Contents\MacOS - 1 - .dylib - - - Contents\MacOS - 1 - .dylib - - - Contents\MacOS - 1 - .dylib - - - 0 - .dll;.bpl - - - - - 1 - .dylib - - - 1 - .dylib - - - 1 - .dylib - - - Contents\MacOS - 1 - .dylib - - - Contents\MacOS - 1 - .dylib - - - Contents\MacOS - 1 - .dylib - - - 0 - .bpl - - - - - 0 - - - 0 - - - 0 - - - 0 - - - 0 - - - Contents\Resources\StartUp\ - 0 - - - Contents\Resources\StartUp\ - 0 - - - Contents\Resources\StartUp\ - 0 - - - 0 - - - - - 1 - - - 1 - - - - - ..\$(PROJECTNAME).app.dSYM\Contents\Resources\DWARF - 1 - - - ..\$(PROJECTNAME).app.dSYM\Contents\Resources\DWARF - 1 - - - - - ..\ - 1 - - - ..\ - 1 - - - ..\ - 1 - - - - - Contents - 1 - - - Contents - 1 - - - Contents - 1 - - - - - Contents\Resources - 1 - - - Contents\Resources - 1 - - - Contents\Resources - 1 - - - - - library\lib\armeabi-v7a - 1 - - - library\lib\arm64-v8a - 1 - - - 1 - - - 1 - - - 1 - - - 1 - - - Contents\MacOS - 1 - - - Contents\MacOS - 1 - - - Contents\MacOS - 1 - - - 0 - - - - - library\lib\armeabi-v7a - 1 - - - - - 1 - - - 1 - - - - - ..\$(PROJECTNAME).app.dSYM\Contents\Resources\DWARF - 1 - - - ..\$(PROJECTNAME).app.dSYM\Contents\Resources\DWARF - 1 - - - ..\$(PROJECTNAME).app.dSYM\Contents\Resources\DWARF - 1 - - - - - ..\ - 1 - - - ..\ - 1 - - - ..\ - 1 - - - - - 1 - - - 1 - - - 1 - - - - - ..\$(PROJECTNAME).launchscreen - 64 - - - ..\$(PROJECTNAME).launchscreen - 64 - - - - - 1 - - - 1 - - - 1 - - - - - Assets - 1 - - - Assets - 1 - - - - - Assets - 1 - - - Assets - 1 - - - - - ..\$(PROJECTNAME).launchscreen\Assets\AppIcon.appiconset - 1 - - - ..\$(PROJECTNAME).launchscreen\Assets\AppIcon.appiconset - 1 - - - - - ..\$(PROJECTNAME).launchscreen\Assets\AppIcon.appiconset - 1 - - - ..\$(PROJECTNAME).launchscreen\Assets\AppIcon.appiconset - 1 - - - - - ..\$(PROJECTNAME).launchscreen\Assets\AppIcon.appiconset - 1 - - - ..\$(PROJECTNAME).launchscreen\Assets\AppIcon.appiconset - 1 - - - - - ..\$(PROJECTNAME).launchscreen\Assets\LaunchScreenImage.imageset - 1 - - - ..\$(PROJECTNAME).launchscreen\Assets\LaunchScreenImage.imageset - 1 - - - - - ..\$(PROJECTNAME).launchscreen\Assets\LaunchScreenImage.imageset - 1 - - - ..\$(PROJECTNAME).launchscreen\Assets\LaunchScreenImage.imageset - 1 - - - - - ..\$(PROJECTNAME).launchscreen\Assets\AppIcon.appiconset - 1 - - - ..\$(PROJECTNAME).launchscreen\Assets\AppIcon.appiconset - 1 - - - - - ..\$(PROJECTNAME).launchscreen\Assets\AppIcon.appiconset - 1 - - - ..\$(PROJECTNAME).launchscreen\Assets\AppIcon.appiconset - 1 - - - - - ..\$(PROJECTNAME).launchscreen\Assets\AppIcon.appiconset - 1 - - - ..\$(PROJECTNAME).launchscreen\Assets\AppIcon.appiconset - 1 - - - - - ..\$(PROJECTNAME).launchscreen\Assets\AppIcon.appiconset - 1 - - - ..\$(PROJECTNAME).launchscreen\Assets\AppIcon.appiconset - 1 - - - - - ..\$(PROJECTNAME).launchscreen\Assets\AppIcon.appiconset - 1 - - - ..\$(PROJECTNAME).launchscreen\Assets\AppIcon.appiconset - 1 - - - - - ..\$(PROJECTNAME).launchscreen\Assets\LaunchScreenImage.imageset - 1 - - - ..\$(PROJECTNAME).launchscreen\Assets\LaunchScreenImage.imageset - 1 - - - - - ..\$(PROJECTNAME).launchscreen\Assets\LaunchScreenImage.imageset - 1 - - - ..\$(PROJECTNAME).launchscreen\Assets\LaunchScreenImage.imageset - 1 - - - - - ..\$(PROJECTNAME).launchscreen\Assets\LaunchScreenImage.imageset - 1 - - - ..\$(PROJECTNAME).launchscreen\Assets\LaunchScreenImage.imageset - 1 - - - - - ..\$(PROJECTNAME).launchscreen\Assets\LaunchScreenImage.imageset - 1 - - - ..\$(PROJECTNAME).launchscreen\Assets\LaunchScreenImage.imageset - 1 - - - - - ..\$(PROJECTNAME).launchscreen\Assets\AppIcon.appiconset - 1 - - - ..\$(PROJECTNAME).launchscreen\Assets\AppIcon.appiconset - 1 - - - - - ..\$(PROJECTNAME).launchscreen\Assets\AppIcon.appiconset - 1 - - - ..\$(PROJECTNAME).launchscreen\Assets\AppIcon.appiconset - 1 - - - - - ..\$(PROJECTNAME).launchscreen\Assets\AppIcon.appiconset - 1 - - - ..\$(PROJECTNAME).launchscreen\Assets\AppIcon.appiconset - 1 - - - - - ..\$(PROJECTNAME).launchscreen\Assets\AppIcon.appiconset - 1 - - - ..\$(PROJECTNAME).launchscreen\Assets\AppIcon.appiconset - 1 - - - - - ..\$(PROJECTNAME).launchscreen\Assets\AppIcon.appiconset - 1 - - - ..\$(PROJECTNAME).launchscreen\Assets\AppIcon.appiconset - 1 - - - - - ..\$(PROJECTNAME).launchscreen\Assets\AppIcon.appiconset - 1 - - - ..\$(PROJECTNAME).launchscreen\Assets\AppIcon.appiconset - 1 - - - - - - - - - - - - - - - - 12 - - - - - - "Z:\password-manager\delphi-backend\assets\BuildAssets.cmd" - False - - False - - False - -
+ + + {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 + 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) + app.ico + app.png + app.png + + + DEBUG;$(DCC_Define) + false + true + true + true + + + Debug + + + Debug + + + Debug + + + Debug + + + PerMonitorV2 + true + 1033 + CompanyName=;FileDescription=$(MSBuildProjectName);FileVersion=1.0.0.0;InternalName=;LegalCopyright=;LegalTrademarks=;OriginalFilename=;ProductName=$(MSBuildProjectName);ProductVersion=1.0.0.0;Comments=;ProgramID=com.embarcadero.$(MSBuildProjectName) + + PMServer_Icon1.ico + app.png + app.png + + + + MainSource + + + +
MainForm
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + Base + + + Cfg_1 + Base + + + Cfg_2 + Base + +
+ + Delphi.Personality.12 + + + + + PMServer.dpr + + + Microsoft Office 2000 Sample Automation Server Wrapper Components + Microsoft Office XP Sample Automation Server Wrapper Components + + + + False + True + True + True + True + True + True + False + + + + + true + + + + + true + + + + + true + + + + + Assets\ + Logo44x44.png + true + + + + + 1 + + + Contents\MacOS + 1 + + + 0 + + + + + classes + 64 + + + classes + 64 + + + + + res\xml + 1 + + + res\xml + 1 + + + + + library\lib\armeabi + 1 + + + library\lib\armeabi + 1 + + + + + library\lib\armeabi-v7a + 1 + + + + + library\lib\mips + 1 + + + library\lib\mips + 1 + + + + + library\lib\armeabi-v7a + 1 + + + library\lib\arm64-v8a + 1 + + + + + library\lib\armeabi-v7a + 1 + + + + + res\drawable + 1 + + + res\drawable + 1 + + + + + res\drawable-anydpi-v21 + 1 + + + res\drawable-anydpi-v21 + 1 + + + + + res\values + 1 + + + res\values + 1 + + + + + res\values-v21 + 1 + + + res\values-v21 + 1 + + + + + res\values-v31 + 1 + + + res\values-v31 + 1 + + + + + res\drawable-anydpi-v26 + 1 + + + res\drawable-anydpi-v26 + 1 + + + + + res\drawable + 1 + + + res\drawable + 1 + + + + + res\drawable + 1 + + + res\drawable + 1 + + + + + res\drawable + 1 + + + res\drawable + 1 + + + + + res\drawable-anydpi-v33 + 1 + + + res\drawable-anydpi-v33 + 1 + + + + + res\values + 1 + + + res\values + 1 + + + + + res\values-night-v21 + 1 + + + res\values-night-v21 + 1 + + + + + res\drawable + 1 + + + res\drawable + 1 + + + + + res\drawable-xxhdpi + 1 + + + res\drawable-xxhdpi + 1 + + + + + res\drawable-xxxhdpi + 1 + + + res\drawable-xxxhdpi + 1 + + + + + res\drawable-ldpi + 1 + + + res\drawable-ldpi + 1 + + + + + res\drawable-mdpi + 1 + + + res\drawable-mdpi + 1 + + + + + res\drawable-hdpi + 1 + + + res\drawable-hdpi + 1 + + + + + res\drawable-xhdpi + 1 + + + res\drawable-xhdpi + 1 + + + + + res\drawable-mdpi + 1 + + + res\drawable-mdpi + 1 + + + + + res\drawable-hdpi + 1 + + + res\drawable-hdpi + 1 + + + + + res\drawable-xhdpi + 1 + + + res\drawable-xhdpi + 1 + + + + + res\drawable-xxhdpi + 1 + + + res\drawable-xxhdpi + 1 + + + + + res\drawable-xxxhdpi + 1 + + + res\drawable-xxxhdpi + 1 + + + + + res\drawable-small + 1 + + + res\drawable-small + 1 + + + + + res\drawable-normal + 1 + + + res\drawable-normal + 1 + + + + + res\drawable-large + 1 + + + res\drawable-large + 1 + + + + + res\drawable-xlarge + 1 + + + res\drawable-xlarge + 1 + + + + + res\values + 1 + + + res\values + 1 + + + + + res\drawable-anydpi-v24 + 1 + + + res\drawable-anydpi-v24 + 1 + + + + + res\drawable + 1 + + + res\drawable + 1 + + + + + res\drawable-night-anydpi-v21 + 1 + + + res\drawable-night-anydpi-v21 + 1 + + + + + res\drawable-anydpi-v31 + 1 + + + res\drawable-anydpi-v31 + 1 + + + + + res\drawable-night-anydpi-v31 + 1 + + + res\drawable-night-anydpi-v31 + 1 + + + + + 1 + + + Contents\MacOS + 1 + + + 0 + + + + + Contents\MacOS + 1 + .framework + + + Contents\MacOS + 1 + .framework + + + Contents\MacOS + 1 + .framework + + + 0 + + + + + 1 + .dylib + + + 1 + .dylib + + + 1 + .dylib + + + Contents\MacOS + 1 + .dylib + + + Contents\MacOS + 1 + .dylib + + + Contents\MacOS + 1 + .dylib + + + 0 + .dll;.bpl + + + + + 1 + .dylib + + + 1 + .dylib + + + 1 + .dylib + + + Contents\MacOS + 1 + .dylib + + + Contents\MacOS + 1 + .dylib + + + Contents\MacOS + 1 + .dylib + + + 0 + .bpl + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + Contents\Resources\StartUp\ + 0 + + + Contents\Resources\StartUp\ + 0 + + + Contents\Resources\StartUp\ + 0 + + + 0 + + + + + 1 + + + 1 + + + + + ..\$(PROJECTNAME).app.dSYM\Contents\Resources\DWARF + 1 + + + ..\$(PROJECTNAME).app.dSYM\Contents\Resources\DWARF + 1 + + + + + ..\ + 1 + + + ..\ + 1 + + + ..\ + 1 + + + + + Contents + 1 + + + Contents + 1 + + + Contents + 1 + + + + + Contents\Resources + 1 + + + Contents\Resources + 1 + + + Contents\Resources + 1 + + + + + library\lib\armeabi-v7a + 1 + + + library\lib\arm64-v8a + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + Contents\MacOS + 1 + + + Contents\MacOS + 1 + + + Contents\MacOS + 1 + + + 0 + + + + + library\lib\armeabi-v7a + 1 + + + + + 1 + + + 1 + + + + + ..\$(PROJECTNAME).app.dSYM\Contents\Resources\DWARF + 1 + + + ..\$(PROJECTNAME).app.dSYM\Contents\Resources\DWARF + 1 + + + ..\$(PROJECTNAME).app.dSYM\Contents\Resources\DWARF + 1 + + + + + ..\ + 1 + + + ..\ + 1 + + + ..\ + 1 + + + + + 1 + + + 1 + + + 1 + + + + + ..\$(PROJECTNAME).launchscreen + 64 + + + ..\$(PROJECTNAME).launchscreen + 64 + + + + + 1 + + + 1 + + + 1 + + + + + Assets + 1 + + + Assets + 1 + + + + + Assets + 1 + + + Assets + 1 + + + + + ..\$(PROJECTNAME).launchscreen\Assets\AppIcon.appiconset + 1 + + + ..\$(PROJECTNAME).launchscreen\Assets\AppIcon.appiconset + 1 + + + + + ..\$(PROJECTNAME).launchscreen\Assets\AppIcon.appiconset + 1 + + + ..\$(PROJECTNAME).launchscreen\Assets\AppIcon.appiconset + 1 + + + + + ..\$(PROJECTNAME).launchscreen\Assets\AppIcon.appiconset + 1 + + + ..\$(PROJECTNAME).launchscreen\Assets\AppIcon.appiconset + 1 + + + + + ..\$(PROJECTNAME).launchscreen\Assets\LaunchScreenImage.imageset + 1 + + + ..\$(PROJECTNAME).launchscreen\Assets\LaunchScreenImage.imageset + 1 + + + + + ..\$(PROJECTNAME).launchscreen\Assets\LaunchScreenImage.imageset + 1 + + + ..\$(PROJECTNAME).launchscreen\Assets\LaunchScreenImage.imageset + 1 + + + + + ..\$(PROJECTNAME).launchscreen\Assets\AppIcon.appiconset + 1 + + + ..\$(PROJECTNAME).launchscreen\Assets\AppIcon.appiconset + 1 + + + + + ..\$(PROJECTNAME).launchscreen\Assets\AppIcon.appiconset + 1 + + + ..\$(PROJECTNAME).launchscreen\Assets\AppIcon.appiconset + 1 + + + + + ..\$(PROJECTNAME).launchscreen\Assets\AppIcon.appiconset + 1 + + + ..\$(PROJECTNAME).launchscreen\Assets\AppIcon.appiconset + 1 + + + + + ..\$(PROJECTNAME).launchscreen\Assets\AppIcon.appiconset + 1 + + + ..\$(PROJECTNAME).launchscreen\Assets\AppIcon.appiconset + 1 + + + + + ..\$(PROJECTNAME).launchscreen\Assets\AppIcon.appiconset + 1 + + + ..\$(PROJECTNAME).launchscreen\Assets\AppIcon.appiconset + 1 + + + + + ..\$(PROJECTNAME).launchscreen\Assets\LaunchScreenImage.imageset + 1 + + + ..\$(PROJECTNAME).launchscreen\Assets\LaunchScreenImage.imageset + 1 + + + + + ..\$(PROJECTNAME).launchscreen\Assets\LaunchScreenImage.imageset + 1 + + + ..\$(PROJECTNAME).launchscreen\Assets\LaunchScreenImage.imageset + 1 + + + + + ..\$(PROJECTNAME).launchscreen\Assets\LaunchScreenImage.imageset + 1 + + + ..\$(PROJECTNAME).launchscreen\Assets\LaunchScreenImage.imageset + 1 + + + + + ..\$(PROJECTNAME).launchscreen\Assets\LaunchScreenImage.imageset + 1 + + + ..\$(PROJECTNAME).launchscreen\Assets\LaunchScreenImage.imageset + 1 + + + + + ..\$(PROJECTNAME).launchscreen\Assets\AppIcon.appiconset + 1 + + + ..\$(PROJECTNAME).launchscreen\Assets\AppIcon.appiconset + 1 + + + + + ..\$(PROJECTNAME).launchscreen\Assets\AppIcon.appiconset + 1 + + + ..\$(PROJECTNAME).launchscreen\Assets\AppIcon.appiconset + 1 + + + + + ..\$(PROJECTNAME).launchscreen\Assets\AppIcon.appiconset + 1 + + + ..\$(PROJECTNAME).launchscreen\Assets\AppIcon.appiconset + 1 + + + + + ..\$(PROJECTNAME).launchscreen\Assets\AppIcon.appiconset + 1 + + + ..\$(PROJECTNAME).launchscreen\Assets\AppIcon.appiconset + 1 + + + + + ..\$(PROJECTNAME).launchscreen\Assets\AppIcon.appiconset + 1 + + + ..\$(PROJECTNAME).launchscreen\Assets\AppIcon.appiconset + 1 + + + + + ..\$(PROJECTNAME).launchscreen\Assets\AppIcon.appiconset + 1 + + + ..\$(PROJECTNAME).launchscreen\Assets\AppIcon.appiconset + 1 + + + + + + + + + + + + + + + + 12 + + + + + + "Z:\password-manager\delphi-backend\assets\BuildAssets.cmd" + False + + False + + False + +
diff --git a/delphi-backend/Source/PM.Database.pas b/delphi-backend/Source/PM.Database.pas index 3ade9d7..7fef05b 100644 --- a/delphi-backend/Source/PM.Database.pas +++ b/delphi-backend/Source/PM.Database.pas @@ -208,6 +208,28 @@ begin FConn.ExecSQL( 'CREATE INDEX IF NOT EXISTS idx_history_entry ' + ' ON entries_password_history(entry_id, changed_at DESC)'); + // Per-entry encrypted file attachments (PDFs, images of backup codes, + // etc.). encrypted_blob is the AES-GCM ciphertext of the raw file bytes, + // base64-encoded. Filename + mime + size_bytes are stored in cleartext + // for the listing UI — knowingly leaked metadata in exchange for not + // having to decrypt every entry on list render. + FConn.ExecSQL( + 'CREATE TABLE IF NOT EXISTS entry_attachments (' + + ' id INTEGER PRIMARY KEY AUTOINCREMENT,' + + ' user_id INTEGER NOT NULL,' + + ' entry_id INTEGER NOT NULL,' + + ' filename TEXT NOT NULL,' + + ' mime TEXT,' + + ' size_bytes INTEGER NOT NULL,' + + ' encrypted_blob TEXT NOT NULL,' + + ' iv TEXT NOT NULL,' + + ' created_at DATETIME DEFAULT CURRENT_TIMESTAMP,' + + ' FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,' + + ' FOREIGN KEY (entry_id) REFERENCES vault_entries(id) ON DELETE CASCADE' + + ')'); + FConn.ExecSQL( + 'CREATE INDEX IF NOT EXISTS idx_attachments_entry ' + + ' ON entry_attachments(entry_id)'); end; function TPMDatabase.ColumnExists(const ATable, AColumn: string): Boolean; @@ -275,6 +297,22 @@ begin // Fetched on demand by the Delphi favicon proxy when the user opts in. // NULL = no icon cached → JS falls back to the first-letter avatar. AddColumnIfMissing('vault_entries', 'icon_b64', 'TEXT'); + // Tracked client-side via POST /entries/:id/touch on copy/open. Powers + // the sidebar "Recent" view. NULL = never accessed since the column + // landed (legacy rows). + AddColumnIfMissing('vault_entries', 'accessed_at', 'DATETIME'); + // Bumped to CURRENT_TIMESTAMP only when encrypted_password actually + // changes (distinct from updated_at which fires on any edit). Powers + // the "aged password" badge. Legacy rows: NULL → JS falls back to + // updated_at, then created_at. + AddColumnIfMissing('vault_entries', 'password_changed_at', 'DATETIME'); + // Per-folder customisation. NULL = no override → JS uses the default + // accent + i-folder symbol. + AddColumnIfMissing('folders', 'color', 'TEXT'); + AddColumnIfMissing('folders', 'icon', 'TEXT'); + // Manual sort order from drag-reorder. 0 = legacy/never reordered → + // falls back to alphabetical secondary sort in GET /folders. + AddColumnIfMissing('folders', 'sort_order', 'INTEGER DEFAULT 0'); AddColumnIfMissing('users', 'hash_algo', 'TEXT DEFAULT ''pbkdf2'''); // PBKDF2 iteration count per user. Legacy rows (predating this column) // default to 100000 — the value used by api.php / the early Delphi build. diff --git a/delphi-backend/UMainForm.pas b/delphi-backend/UMainForm.pas index 84f4f19..c9d6565 100644 --- a/delphi-backend/UMainForm.pas +++ b/delphi-backend/UMainForm.pas @@ -15,7 +15,7 @@ interface uses System.SysUtils, System.Classes, System.UITypes, System.NetEncoding, - System.StrUtils, System.Generics.Collections, + System.StrUtils, System.Generics.Collections, System.IOUtils, System.JSON, Winapi.Windows, Winapi.ShellAPI, FMX.Forms, FMX.Controls, FMX.Controls.Presentation, FMX.StdCtrls, FMX.Memo, FMX.Memo.Types, FMX.ScrollBox, FMX.Edit, FMX.Layouts, FMX.Types, @@ -860,6 +860,182 @@ begin BoolToStr(PM.AutoStart.IsAutoStartEnabled, True).ToLower + ')'); end + // ---- Native file save (bypasses WebView2's browser download UI) ------ + // JS sends: cmd://file/save?name=&data=&reqId= + // Delphi opens GetSaveFileName, writes the decoded bytes, then calls + // Bridge.onFileSaveResult(reqId, ok, path). All synchronous on the UI + // thread — payloads are small (a vault JSON export is well under 1 MB). + else if ACmd = 'file/save' then + begin + var LName := GetParam('name'); + var LData := GetParam('data'); + var LReqId := GetParam('reqId'); + var LOk := False; + var LPath := ''; + var LErr := ''; + try + var LBytes := TNetEncoding.Base64.DecodeStringToBytes(LData); + var LDlg := TSaveDialog.Create(nil); + try + LDlg.FileName := LName; + var LExt := ExtractFileExt(LName); + if LExt = '.json' then LDlg.Filter := 'JSON file (*.json)|*.json|All files (*.*)|*.*' + else if LExt = '.csv' then LDlg.Filter := 'CSV file (*.csv)|*.csv|All files (*.*)|*.*' + else LDlg.Filter := 'All files (*.*)|*.*'; + LDlg.DefaultExt := LExt.TrimLeft(['.']); + LDlg.Options := LDlg.Options + [TOpenOption.ofOverwritePrompt]; + if LDlg.Execute then + begin + LPath := LDlg.FileName; + var LStream := TFileStream.Create(LPath, fmCreate); + try + if Length(LBytes) > 0 then + LStream.WriteBuffer(LBytes[0], Length(LBytes)); + finally + LStream.Free; + end; + LOk := True; + LogLine(Format('File saved: %s (%d bytes)', [LPath, Length(LBytes)])); + end + else + LogLine('File save cancelled by user'); + finally + LDlg.Free; + end; + except + on E: Exception do + begin + LErr := E.Message; + LogLine('File save FAILED: ' + LErr); + end; + end; + var LEscReq := StringReplace(LReqId, '"', '\"', [rfReplaceAll]); + var LEscPath := StringReplace(LPath, '\', '\\', [rfReplaceAll]); + LEscPath := StringReplace(LEscPath, '"', '\"', [rfReplaceAll]); + var LEscErr := StringReplace(LErr, '\', '\\', [rfReplaceAll]); + LEscErr := StringReplace(LEscErr, '"', '\"', [rfReplaceAll]); + WebBrowser.ExecuteJavaScript( + 'if(window.Bridge&&Bridge.onFileSaveResult)' + + 'Bridge.onFileSaveResult("' + LEscReq + '",' + + BoolToStr(LOk, True).ToLower + ',"' + LEscPath + '","' + LEscErr + '")'); + end + + // ---- Auto-backup: folder picker (modal Win32 dialog) ----------------- + // cmd://folder/pick?reqId= + // Callback: Bridge.onFolderPickResult(reqId, path) (path = '' on cancel) + else if ACmd = 'folder/pick' then + begin + var LReqId := GetParam('reqId'); + var LDir := ''; + SelectDirectory('Choose backup folder', '', LDir); + var LEscReq := StringReplace(LReqId, '"', '\"', [rfReplaceAll]); + var LEscDir := StringReplace(LDir, '\', '\\', [rfReplaceAll]); + LEscDir := StringReplace(LEscDir, '"', '\"', [rfReplaceAll]); + WebBrowser.ExecuteJavaScript( + 'if(window.Bridge&&Bridge.onFolderPickResult)' + + 'Bridge.onFolderPickResult("' + LEscReq + '","' + LEscDir + '")'); + if LDir <> '' then LogLine('Folder picked: ' + LDir); + end + + // ---- Auto-backup: silent file write (no dialog) ---------------------- + // cmd://file/write?path=&data=&reqId= + // Callback: Bridge.onFileWriteResult(reqId, ok, error) + else if ACmd = 'file/write' then + begin + var LPath := GetParam('path'); + var LData := GetParam('data'); + var LReqId := GetParam('reqId'); + var LOk := False; + var LErr := ''; + try + var LBytes := TNetEncoding.Base64.DecodeStringToBytes(LData); + var LStream := TFileStream.Create(LPath, fmCreate); + try + if Length(LBytes) > 0 then + LStream.WriteBuffer(LBytes[0], Length(LBytes)); + finally + LStream.Free; + end; + LOk := True; + LogLine(Format('File written: %s (%d bytes)', [LPath, Length(LBytes)])); + except + on E: Exception do + begin + LErr := E.Message; + LogLine('File write FAILED for "' + LPath + '": ' + LErr); + end; + end; + var LEscReq := StringReplace(LReqId, '"', '\"', [rfReplaceAll]); + var LEscErr := StringReplace(LErr, '\', '\\', [rfReplaceAll]); + LEscErr := StringReplace(LEscErr, '"', '\"', [rfReplaceAll]); + WebBrowser.ExecuteJavaScript( + 'if(window.Bridge&&Bridge.onFileWriteResult)' + + 'Bridge.onFileWriteResult("' + LEscReq + '",' + + BoolToStr(LOk, True).ToLower + ',"' + LEscErr + '")'); + end + + // ---- Auto-backup: list files in dir matching name prefix -------------- + // cmd://file/listMatch?dir=&prefix=&reqId= + // Callback: Bridge.onFileListResult(reqId, jsonArr) + // Each item: {name, size, mtime} (mtime = ISO). + else if ACmd = 'file/listMatch' then + begin + var LDir := GetParam('dir'); + var LPrefix := GetParam('prefix'); + var LReqId := GetParam('reqId'); + var LArr := TJSONArray.Create; + try + if (LDir <> '') and TDirectory.Exists(LDir) then + begin + var LFiles := TDirectory.GetFiles(LDir, LPrefix + '*'); + for var F in LFiles do + begin + var LObj := TJSONObject.Create; + LObj.AddPair('name', ExtractFileName(F)); + LObj.AddPair('size', TJSONNumber.Create(TFile.GetSize(F))); + LObj.AddPair('mtime', + FormatDateTime('yyyy-mm-dd"T"hh:nn:ss', TFile.GetLastWriteTime(F))); + LArr.Add(LObj); + end; + end; + var LJSON := LArr.ToString; + var LEscReq := StringReplace(LReqId, '"', '\"', [rfReplaceAll]); + var LEscJson := StringReplace(LJSON, '\', '\\', [rfReplaceAll]); + LEscJson := StringReplace(LEscJson, '"', '\"', [rfReplaceAll]); + WebBrowser.ExecuteJavaScript( + 'if(window.Bridge&&Bridge.onFileListResult)' + + 'Bridge.onFileListResult("' + LEscReq + '","' + LEscJson + '")'); + finally + LArr.Free; + end; + end + + // ---- Auto-backup: delete a single file (for retention pruning) ------- + // cmd://file/delete?path=&reqId= + // Callback: Bridge.onFileDeleteResult(reqId, ok) + else if ACmd = 'file/delete' then + begin + var LPath := GetParam('path'); + var LReqId := GetParam('reqId'); + var LOk := False; + try + if TFile.Exists(LPath) then + begin + TFile.Delete(LPath); + LOk := True; + LogLine('File deleted: ' + LPath); + end; + except + on E: Exception do + LogLine('File delete FAILED for "' + LPath + '": ' + E.Message); + end; + var LEscReq := StringReplace(LReqId, '"', '\"', [rfReplaceAll]); + WebBrowser.ExecuteJavaScript( + 'if(window.Bridge&&Bridge.onFileDeleteResult)' + + 'Bridge.onFileDeleteResult("' + LEscReq + '",' + + BoolToStr(LOk, True).ToLower + ')'); + end + else LogLine('Bridge: unknown command "' + ACmd + '"'); end; diff --git a/delphi-backend/assets/assets.res b/delphi-backend/assets/assets.res index 472f7e4..8d365b3 100644 Binary files a/delphi-backend/assets/assets.res and b/delphi-backend/assets/assets.res differ diff --git a/index.html b/index.html index 524429b..679eb43 100644 --- a/index.html +++ b/index.html @@ -44,6 +44,9 @@ + + + @@ -220,10 +223,18 @@ TOTP generator + + +
+ + +
@@ -320,6 +338,9 @@ + + +
@@ -456,6 +477,19 @@ +
+ + Flag aged passwords + Show an "Aged" badge on entries whose password hasn't changed in this many days. + + +
Check passwords against breach database (HIBP) @@ -545,9 +579,58 @@ choose a password independent of your master password — save it carefully, you need it to restore.

- +
+ + +
+
+ +
diff --git a/js/app.js b/js/app.js index 831a262..fe8a879 100644 --- a/js/app.js +++ b/js/app.js @@ -14,6 +14,11 @@ const API = (location.pathname.indexOf('/password-manager/') === 0) // Active only when running inside the Delphi-hosted WebView2 (API === ''). // Falls back to navigator.clipboard for the standalone PHP frontend. const prefResolvers = {}; +const fileSaveResolvers = {}; +const folderPickResolvers = {}; +const fileWriteResolvers = {}; +const fileListResolvers = {}; +const fileDeleteResolvers = {}; let autoStartResolver = null; const faviconResolvers = {}; let _faviconReqSeq = 0; @@ -189,6 +194,127 @@ const Bridge = (() => { } }, + // Native Save As dialog — bypasses WebView2's browser download UI + // (which Edge wraps with "Téléchargements" popup + Open File prompt). + // The caller passes raw bytes as Uint8Array OR a string; we base64 + // it and ship to Delphi which writes the file after the user picks. + saveFile(name, content) { + if (!active) return Promise.resolve({ ok: false, error: 'bridge offline' }); + let bytes; + if (typeof content === 'string') bytes = new TextEncoder().encode(content); + else bytes = content; + const b64 = bytesToBase64(bytes); + const reqId = 'fs_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8); + return new Promise(resolve => { + fileSaveResolvers[reqId] = resolve; + cmd('cmd://file/save?name=' + encodeURIComponent(name) + + '&data=' + encodeURIComponent(b64) + + '&reqId=' + encodeURIComponent(reqId)); + setTimeout(() => { + if (fileSaveResolvers[reqId]) { + delete fileSaveResolvers[reqId]; + resolve({ ok: false, error: 'timeout' }); + } + }, 120000); + }); + }, + onFileSaveResult(reqId, ok, path, err) { + const r = fileSaveResolvers[reqId]; + if (r) { + delete fileSaveResolvers[reqId]; + r({ ok: !!ok, path: path || '', error: err || '' }); + } + }, + + // ---- Auto-backup helpers (folder pick + silent write + list + delete) + pickFolder() { + if (!active) return Promise.resolve(''); + const reqId = 'fp_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6); + return new Promise(resolve => { + folderPickResolvers[reqId] = resolve; + cmd('cmd://folder/pick?reqId=' + encodeURIComponent(reqId)); + setTimeout(() => { + if (folderPickResolvers[reqId]) { + delete folderPickResolvers[reqId]; + resolve(''); + } + }, 120000); + }); + }, + onFolderPickResult(reqId, path) { + const r = folderPickResolvers[reqId]; + if (r) { delete folderPickResolvers[reqId]; r(path || ''); } + }, + + writeFile(path, content) { + if (!active) return Promise.resolve({ ok: false, error: 'bridge offline' }); + let bytes; + if (typeof content === 'string') bytes = new TextEncoder().encode(content); + else bytes = content; + const b64 = bytesToBase64(bytes); + const reqId = 'fw_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6); + return new Promise(resolve => { + fileWriteResolvers[reqId] = resolve; + cmd('cmd://file/write?path=' + encodeURIComponent(path) + + '&data=' + encodeURIComponent(b64) + + '&reqId=' + encodeURIComponent(reqId)); + setTimeout(() => { + if (fileWriteResolvers[reqId]) { + delete fileWriteResolvers[reqId]; + resolve({ ok: false, error: 'timeout' }); + } + }, 30000); + }); + }, + onFileWriteResult(reqId, ok, err) { + const r = fileWriteResolvers[reqId]; + if (r) { delete fileWriteResolvers[reqId]; r({ ok: !!ok, error: err || '' }); } + }, + + listFiles(dir, prefix) { + if (!active) return Promise.resolve([]); + const reqId = 'fl_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6); + return new Promise(resolve => { + fileListResolvers[reqId] = resolve; + cmd('cmd://file/listMatch?dir=' + encodeURIComponent(dir) + + '&prefix=' + encodeURIComponent(prefix || '') + + '&reqId=' + encodeURIComponent(reqId)); + setTimeout(() => { + if (fileListResolvers[reqId]) { + delete fileListResolvers[reqId]; + resolve([]); + } + }, 10000); + }); + }, + onFileListResult(reqId, json) { + const r = fileListResolvers[reqId]; + if (!r) return; + delete fileListResolvers[reqId]; + try { r(JSON.parse(json || '[]')); } + catch { r([]); } + }, + + deleteFile(path) { + if (!active) return Promise.resolve(false); + const reqId = 'fd_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6); + return new Promise(resolve => { + fileDeleteResolvers[reqId] = resolve; + cmd('cmd://file/delete?path=' + encodeURIComponent(path) + + '&reqId=' + encodeURIComponent(reqId)); + setTimeout(() => { + if (fileDeleteResolvers[reqId]) { + delete fileDeleteResolvers[reqId]; + resolve(false); + } + }, 10000); + }); + }, + onFileDeleteResult(reqId, ok) { + const r = fileDeleteResolvers[reqId]; + if (r) { delete fileDeleteResolvers[reqId]; r(!!ok); } + }, + // ---- Start with Windows (HKCU Run registry) ----------------------- getAutoStart() { if (!active) return Promise.resolve(false); @@ -309,7 +435,7 @@ const state = { entries: [], trashed: [], trashedCount: 0, // server-side count, updated separately from state.trashed - folders: ['All'], + folders: [{ name: 'All', color: '', icon: '' }], view: 'all', // 'all' | 'favorites' | 'folder:' | 'tag:' | 'trash' search: '', selectedId: null, @@ -333,6 +459,9 @@ const state = { pageSize: parseInt(localStorage.getItem('pageSize') || '25') || 25, currentPage: 1, checked: new Set(), // entry IDs checked for batch operations + // Advanced filters — runtime only (cleared on lockVault). Set of string + // keys; see FILTER_DEFS for the available predicates. + activeFilters: new Set(), hibpEnabled: localStorage.getItem('hibpEnabled') === '1', // default OFF // entry.id → count from HIBP (0 = clean, >0 = pwned, undefined = unchecked) hibpResults: new Map(), @@ -361,6 +490,7 @@ const state = { // Days after which trashed entries are permanently purged. 0 = never. // Synced across devices because it's a user-level preference. trashAutoPurgeDays: parseInt(localStorage.getItem('trashAutoPurgeDays') || '0') || 0, + passwordExpiryDays: parseInt(localStorage.getItem('passwordExpiryDays') || '0') || 0, }; // ============================================================ @@ -1589,7 +1719,8 @@ async function doLogout() { sessionStorage.clear(); state.token = ''; state.csrf = ''; state.salt = ''; state.username = ''; state.kdfIterations = 0; - state.cryptoKey = null; state.entries = []; state.trashed = []; state.folders = ['All']; + state.cryptoKey = null; state.entries = []; state.trashed = []; + state.folders = [{ name: 'All', color: '', icon: '' }]; state.locked = false; showAuth(); $('#loginUsername').value = ''; @@ -1614,6 +1745,9 @@ function lockVault() { clearInterval(totpToolTimer); totpToolTimer = null; } if (typeof healthCache !== 'undefined') healthCache = null; + if (typeof auditCache !== 'undefined') auditCache = null; + auditFilter = ''; + state.activeFilters.clear(); showAuth(); // Two UI variants for the auth screen: @@ -1692,11 +1826,26 @@ async function doUnlock(p) { async function loadFolders() { try { const r = await api('/folders', { headers: authHeaders() }); - // 'All' is always implicit first - state.folders = ['All'].concat(r.filter(n => n !== 'All')); + // 'All' is always implicit first. The server returns object rows; + // tolerate legacy string-only responses too (older deployments). + const seen = (r || []).map(x => typeof x === 'string' + ? { name: x, color: '', icon: '' } + : { name: x.name, color: x.color || '', icon: x.icon || '' }); + state.folders = [{ name: 'All', color: '', icon: '' }] + .concat(seen.filter(f => f.name !== 'All')); } catch (e) { /* ignore */ } } +// Lookup helpers — UI code refers to folders by name everywhere, so we +// keep the name-keyed map for O(1) meta access without changing call sites. +function folderByName(name) { + return state.folders.find(f => f.name === name); +} +function folderMetaFor(name) { + const f = folderByName(name); + return { color: (f && f.color) || '', icon: (f && f.icon) || '' }; +} + async function loadEntries() { try { const r = await api('/entries', { headers: authHeaders() }); @@ -1728,6 +1877,153 @@ async function loadEntryCounts() { // FILTERS / DERIVED // ============================================================ +// Advanced filter definitions. Each filter has a label (shown in dropdown +// and chip), an icon, and a predicate. Predicates only inspect metadata +// the server already returned — no decryption — so they run cheaply on +// every render. "weak" / "pwned" / "old" read from already-computed caches +// (HIBP / vault-health) so they're consistent with those views. +const FILTER_DEFS = { + 'kind-note': { + label: 'Notes only', + icon: 'i-edit', + predicate: e => e.kind === 'note', + }, + 'kind-login': { + label: 'Logins only', + icon: 'i-key', + predicate: e => (e.kind || 'login') === 'login', + }, + 'has-totp': { + label: 'Has 2FA / TOTP', + icon: 'i-shield', + predicate: e => !!(e.totp_secret && e.totp_iv), + }, + 'no-totp-login': { + label: 'Logins without 2FA', + icon: 'i-alert', + predicate: e => (e.kind || 'login') === 'login' && !(e.totp_secret && e.totp_iv), + }, + 'has-icon': { + label: 'Has favicon', + icon: 'i-globe', + predicate: e => !!e.icon_b64, + }, + 'has-custom-fields': { + label: 'Has custom fields', + icon: 'i-list', + predicate: e => !!(e.custom_fields && e.custom_fields_iv), + }, + 'pwned': { + label: 'Pwned (HIBP)', + icon: 'i-alert', + predicate: e => { + const n = state.hibpResults.get(e.id); + return typeof n === 'number' && n > 0; + }, + }, + 'favorite': { + label: 'Favorites', + icon: 'i-star', + predicate: e => !!e.favorite, + }, + 'aged': { + label: 'Aged password', + icon: 'i-alert', + predicate: e => isPasswordAged(e), + }, +}; + +function renderFiltersMenu() { + const menu = document.getElementById('filtersMenu'); + if (!menu) return; + menu.innerHTML = ''; + menu.appendChild(el('div', { class: 'filters-menu-title' }, 'Filters')); + Object.entries(FILTER_DEFS).forEach(([key, def]) => { + const row = el('label', { class: 'filters-menu-row' }); + const cb = el('input', { type: 'checkbox', class: 'filters-menu-cb' }); + cb.checked = state.activeFilters.has(key); + cb.addEventListener('change', ev => { + ev.stopPropagation(); + if (cb.checked) state.activeFilters.add(key); + else state.activeFilters.delete(key); + state.currentPage = 1; + renderFiltersBadge(); + renderFilterChips(); + render(); + }); + row.appendChild(cb); + row.appendChild(icon(def.icon)); + row.appendChild(el('span', null, def.label)); + menu.appendChild(row); + }); + if (state.activeFilters.size > 0) { + const clear = el('button', { + class: 'filters-menu-clear', type: 'button', + }, 'Clear all filters'); + clear.addEventListener('click', ev => { + ev.stopPropagation(); + state.activeFilters.clear(); + state.currentPage = 1; + renderFiltersBadge(); + renderFilterChips(); + renderFiltersMenu(); + render(); + }); + menu.appendChild(clear); + } +} + +function renderFiltersBadge() { + const badge = document.getElementById('filtersCount'); + if (!badge) return; + const n = state.activeFilters.size; + badge.textContent = String(n); + badge.style.display = n > 0 ? '' : 'none'; +} + +function renderFilterChips() { + const bar = document.getElementById('filterChips'); + if (!bar) return; + bar.innerHTML = ''; + if (state.activeFilters.size === 0) { + bar.classList.add('is-hidden'); + return; + } + bar.classList.remove('is-hidden'); + state.activeFilters.forEach(key => { + const def = FILTER_DEFS[key]; + if (!def) return; + const chip = el('span', { class: 'filter-chip' }); + chip.appendChild(icon(def.icon)); + chip.appendChild(el('span', null, def.label)); + const x = el('button', { type: 'button', class: 'filter-chip-x', title: 'Remove' }); + x.appendChild(icon('i-x')); + x.addEventListener('click', ev => { + ev.stopPropagation(); + state.activeFilters.delete(key); + state.currentPage = 1; + renderFiltersBadge(); + renderFilterChips(); + renderFiltersMenu(); + render(); + }); + chip.appendChild(x); + bar.appendChild(chip); + }); + const clear = el('button', { type: 'button', class: 'filter-chips-clear' }, + 'Clear all'); + clear.addEventListener('click', ev => { + ev.stopPropagation(); + state.activeFilters.clear(); + state.currentPage = 1; + renderFiltersBadge(); + renderFilterChips(); + renderFiltersMenu(); + render(); + }); + bar.appendChild(clear); +} + function filteredEntries() { // Trash view shows its own list (loaded separately) let list; @@ -1737,6 +2033,14 @@ function filteredEntries() { list = state.entries; if (state.view === 'favorites') list = list.filter(e => e.favorite); else if (state.view === 'notes') list = list.filter(e => e.kind === 'note'); + else if (state.view === 'recent') { + // Top 10 actually-used entries, freshest first. accessed_at NULL + // (never touched) sinks to the bottom and is filtered out so the + // view only ever shows things the user reached for. + list = list.filter(e => !!e.accessed_at) + .sort((a, b) => (b.accessed_at || '').localeCompare(a.accessed_at || '')) + .slice(0, 10); + } else if (state.view.startsWith('folder:')) { const f = state.view.slice(7); // 'folder:All' is now the "(no folder)" pseudo-entry → filter @@ -1757,9 +2061,19 @@ function filteredEntries() { (e.tags || '').toLowerCase().includes(q) ); } + // Advanced filters (AND). Each active filter key maps to a predicate; + // the entry must satisfy ALL of them. + if (state.activeFilters && state.activeFilters.size > 0) { + for (const key of state.activeFilters) { + const def = FILTER_DEFS[key]; + if (def && typeof def.predicate === 'function') + list = list.filter(def.predicate); + } + } // Trash view keeps the deletion order (newest first) — re-sorting feels - // wrong for a recoverable archive. All other views honour user choice. - if (state.view !== 'trash') { + // wrong for a recoverable archive. Recent view already sorted by + // accessed_at; re-applying the user sort would defeat the purpose. + if (state.view !== 'trash' && state.view !== 'recent') { list = sortEntries(list, state.sortBy, state.sortDir); } return list; @@ -1780,9 +2094,11 @@ function viewTitle() { if (state.view === 'all') return 'All items'; if (state.view === 'favorites') return 'Favorites'; if (state.view === 'notes') return 'Notes'; + if (state.view === 'recent') return 'Recently used'; if (state.view === 'trash') return 'Trash'; if (state.view === 'authenticator') return 'Authenticator'; if (state.view === 'health') return 'Vault health'; + if (state.view === 'audit') return 'Audit log'; if (state.view.startsWith('folder:')) return state.view.slice(7); if (state.view.startsWith('tag:')) return '# ' + state.view.slice(4); return 'Items'; @@ -1794,6 +2110,8 @@ function viewTitle() { function render() { renderSidebar(); + renderFilterChips(); + renderFiltersBadge(); renderGrid(); } @@ -1811,7 +2129,7 @@ function renderSidebar() { // Section totals — shown next to the section header so the user still // sees the count when the section is collapsed. - const folderCount = state.folders.filter(n => n !== 'All').length; + const folderCount = state.folders.filter(f => f.name !== 'All').length; const tagCount = allTags().length; const fc = document.getElementById('countFolders'); const tc = document.getElementById('countTags'); @@ -1829,24 +2147,70 @@ function renderSidebar() { // still reachable via "All items". const fList = $('#foldersList'); fList.innerHTML = ''; - state.folders.filter(n => n !== 'All').forEach(name => { + state.folders.filter(f => f.name !== 'All').forEach(folder => { + const name = folder.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, + draggable: 'true', on: { click: () => setView(key) }, }); - item.appendChild(icon('i-folder')); + // Custom icon if set, else default. Custom color tints the icon. + const iconEl = icon(folder.icon || 'i-folder'); + if (folder.color) iconEl.style.color = folder.color; + item.appendChild(iconEl); 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')); + const editBtn = el('button', { + class: 'folder-edit', + type: 'button', + title: 'Customize folder', + on: { click: ev => { ev.stopPropagation(); openFolderModal(folder); } }, + }); + editBtn.appendChild(icon('i-edit')); + item.appendChild(editBtn); + + // Outgoing drag: reorder. Carries a custom MIME so the drop + // handler can distinguish folder-on-folder (reorder) from + // entry-on-folder (move entry). + item.addEventListener('dragstart', e => { + e.dataTransfer.setData('application/x-pm-folder', name); + e.dataTransfer.effectAllowed = 'move'; + item.classList.add('is-dragging'); + }); + item.addEventListener('dragend', () => item.classList.remove('is-dragging')); + + // Incoming drag: either an entry being moved here, OR another + // folder being reordered above/below this one. + item.addEventListener('dragover', e => { + e.preventDefault(); + const types = e.dataTransfer.types; + if (types.includes('application/x-pm-folder')) { + // Compute insert position based on mouse Y vs item midline. + const r = item.getBoundingClientRect(); + const before = (e.clientY - r.top) < r.height / 2; + item.classList.toggle('drop-before', before); + item.classList.toggle('drop-after', !before); + item.classList.remove('drag-over'); + } else { + item.classList.add('drag-over'); + item.classList.remove('drop-before', 'drop-after'); + } + }); + item.addEventListener('dragleave', () => + item.classList.remove('drag-over', 'drop-before', 'drop-after')); item.addEventListener('drop', async e => { e.preventDefault(); - item.classList.remove('drag-over'); + const draggedFolder = e.dataTransfer.getData('application/x-pm-folder'); + const before = item.classList.contains('drop-before'); + item.classList.remove('drag-over', 'drop-before', 'drop-after'); + if (draggedFolder && draggedFolder !== name) { + await reorderFolderTo(draggedFolder, name, before); + return; + } const id = e.dataTransfer.getData('text/plain'); if (id) await moveEntryToFolder(parseInt(id), name); }); @@ -1960,6 +2324,19 @@ async function addTagToEntry(id, tag) { function renderGrid() { $('#contentTitle').textContent = viewTitle(); + // Audit log: read-only chronological feed, custom render. + if (state.view === 'audit') { + if (authTickTimer) { clearInterval(authTickTimer); authTickTimer = null; } + const oldBtn = $('#emptyTrashBtn'); if (oldBtn) oldBtn.remove(); + renderBatchBar(); + const grid = $('#entryGrid'); + grid.className = 'entry-grid is-audit'; + grid.innerHTML = ''; + $('#emptyState').classList.add('is-hidden'); + renderAuditLog(grid); + return; + } + // Vault health dashboard: bypass the standard list rendering entirely. if (state.view === 'health') { if (authTickTimer) { clearInterval(authTickTimer); authTickTimer = null; } @@ -2144,6 +2521,10 @@ function renderAuthenticatorGrid(grid, entries) { // edit / view re-entry (Tools → Vault health). let healthCache = null; +// Audit log: array of {id, action, ip, created_at}. Refreshed on demand +// from the server. Cleared on lockVault. +let auditCache = null; +let auditFilter = ''; // Per-category expand state. Survives full re-renders of the dashboard // (renderGrid runs from openSlideOver → would otherwise reset every // "Show all" toggle back to collapsed). @@ -2381,6 +2762,80 @@ function renderHealthSection(opts) { return card; } +// ============================================================ +// AUDIT LOG VIEWER +// ============================================================ +// +// audit_log is filled by every sensitive action server-side +// (add/edit/delete entry, autofill, lock, master-pw change, etc.). +// 30-day auto-purge runs at server start. Frontend is read-only. + +async function renderAuditLog(grid) { + if (!auditCache) { + grid.appendChild(el('div', { class: 'audit-loading' }, 'Loading…')); + try { + const r = await fetch(API + '/audit?limit=500', { headers: authHeaders() }); + auditCache = r.ok ? await r.json() : []; + } catch (e) { auditCache = []; } + grid.innerHTML = ''; + } + const total = auditCache.length; + $('#contentMeta').textContent = total + (total === 1 ? ' event' : ' events'); + + // Header: search + refresh + clear notice + const head = el('div', { class: 'audit-header' }); + const search = el('input', { + type: 'text', class: 'audit-search', + placeholder: 'Filter actions (e.g. autofill, edit, delete)…', + }); + search.value = auditFilter; + search.addEventListener('input', () => { + auditFilter = search.value; + renderAuditRows(); + }); + head.appendChild(search); + const refresh = el('button', { class: 'btn btn-ghost btn-sm', type: 'button' }); + refresh.appendChild(icon('i-rotate-ccw')); + refresh.appendChild(document.createTextNode(' Refresh')); + refresh.addEventListener('click', () => { + auditCache = null; + render(); + }); + head.appendChild(refresh); + grid.appendChild(head); + + const note = el('p', { class: 'audit-note' }, + 'Entries older than 30 days are auto-purged. Server-side.'); + grid.appendChild(note); + + const list = el('div', { class: 'audit-list' }); + grid.appendChild(list); + + function renderAuditRows() { + list.innerHTML = ''; + const q = auditFilter.trim().toLowerCase(); + const filtered = q + ? auditCache.filter(r => + (r.action || '').toLowerCase().includes(q)) + : auditCache; + if (filtered.length === 0) { + list.appendChild(el('p', { class: 'audit-empty' }, + q ? 'No events match "' + q + '"' : 'No events recorded yet.')); + return; + } + filtered.forEach(row => { + const li = el('div', { class: 'audit-row' }); + li.appendChild(el('span', { class: 'audit-date' }, + row.created_at || '')); + li.appendChild(el('span', { class: 'audit-action' }, + row.action || '')); + li.appendChild(el('span', { class: 'audit-ip' }, row.ip || '')); + list.appendChild(li); + }); + } + renderAuditRows(); +} + function showEmptyState() { const illustration = $('#emptyIllustration use'); const title = $('#emptyTitle'); @@ -2480,6 +2935,20 @@ function entryOpenInBrowser(url) { // Display label for a folder value. "All" is the default "uncategorized" // bucket; we relabel it so users don't see two "All" entries in folder // pickers (the top nav "All items" also says "All"). +// Returns the age (in days) of an entry's password, falling back to +// updated_at then created_at for legacy rows with no password_changed_at. +function passwordAgeDays(e) { + const stamp = e.password_changed_at || e.updated_at || e.created_at; + if (!stamp) return 0; + const ms = Date.now() - Date.parse(stamp); + return Math.floor(ms / 86400000); +} +function isPasswordAged(e) { + const limit = parseInt(state.passwordExpiryDays, 10) || 0; + if (limit <= 0) return false; + return passwordAgeDays(e) >= limit; +} + function folderLabel(f) { if (!f || f === 'All') return '(no folder)'; return f; @@ -2720,8 +3189,11 @@ function renderCard(e) { // bucket and shouldn't be shown as a chip (visually duplicates "All items"). const meta = el('div', { class: 'entry-meta' }); if (e.folder && e.folder !== 'All') { + const fm = folderMetaFor(e.folder); const chip = el('span', { class: 'entry-chip is-folder' }); - chip.appendChild(icon('i-folder')); + const ic = icon(fm.icon || 'i-folder'); + if (fm.color) ic.style.color = fm.color; + chip.appendChild(ic); chip.appendChild(el('span', null, e.folder)); meta.appendChild(chip); } @@ -2744,6 +3216,19 @@ function renderCard(e) { chip.appendChild(el('span', null, 'Pwned')); meta.appendChild(chip); } + // Aged password — flagged when the user-configured expiry window + // has elapsed since the password was last changed. Falls back to + // updated_at / created_at for legacy rows. + if (isPasswordAged(e)) { + const days = passwordAgeDays(e); + const chip = el('span', { + class: 'entry-chip is-aged', + title: 'Password unchanged for ' + days + ' days — time to rotate.', + }); + chip.appendChild(icon('i-alert')); + chip.appendChild(el('span', null, 'Aged')); + meta.appendChild(chip); + } // 2FA indicator — entry has a TOTP secret configured. Server returns // null for both fields when none; truthy = configured (the actual // secret stays encrypted until the user opens the slide-over). @@ -3239,7 +3724,7 @@ function renderBatchBar() { // Normal view: Move to folder | Add tag | Delete (soft) const moveSel = el('select'); moveSel.appendChild(el('option', { value: '' }, 'Move to folder…')); - state.folders.forEach(f => moveSel.appendChild(el('option', { value: f }, folderLabel(f)))); + state.folders.forEach(f => moveSel.appendChild(el('option', { value: f.name }, folderLabel(f.name)))); moveSel.addEventListener('change', () => { if (moveSel.value) batchMoveToFolder(moveSel.value); }); @@ -3292,6 +3777,7 @@ async function openSlideOver(id, opts) { if (!isNew && !e) return; state.selectedId = isNew ? null : id; + if (!isNew) touchEntry(id); // Resolve kind early: opts.kind for new entries (login default), entry's // own kind for existing. Drives the field layout below. const kind = isNew ? (opts.kind || 'login') : (e.kind || 'login'); @@ -3366,6 +3852,7 @@ async function openSlideOver(id, opts) { const hist = soHistoryButton(); if (hist) body.appendChild(hist); body.appendChild(soCustomFieldsField()); + if (!isNew) body.appendChild(soAttachmentsField(id)); body.appendChild(soFolderField(soState.original.folder)); body.appendChild(soTagsField()); } else { @@ -3383,6 +3870,7 @@ async function openSlideOver(id, opts) { if (histLogin) body.appendChild(histLogin); body.appendChild(soTotpField(plainTotp)); body.appendChild(soCustomFieldsField()); + if (!isNew) body.appendChild(soAttachmentsField(id)); body.appendChild(soFolderField(soState.original.folder)); body.appendChild(soTagsField()); } @@ -3844,8 +4332,8 @@ function soFolderField(current) { wrap.appendChild(el('div', { class: 'slideover-field-label' }, 'Folder')); const sel = el('select', { id: 'soFolder', class: 'so-input' }); state.folders.forEach(f => { - const opt = el('option', { value: f }, folderLabel(f)); - if (f === current) opt.selected = true; + const opt = el('option', { value: f.name }, folderLabel(f.name)); + if (f.name === current) opt.selected = true; sel.appendChild(opt); }); wrap.appendChild(sel); @@ -4285,7 +4773,7 @@ function closeEntryModal() { function populateFolderSelect() { const sel = $('#entryFolder'); sel.innerHTML = ''; - state.folders.forEach(f => sel.appendChild(el('option', { value: f }, folderLabel(f)))); + state.folders.forEach(f => sel.appendChild(el('option', { value: f.name }, folderLabel(f.name)))); } async function saveEntry(e) { @@ -4498,6 +4986,7 @@ async function copyPassword(entry) { navigator.clipboard.writeText(p).then(() => toast('Password copied · clears in 30s')); setTimeout(() => navigator.clipboard.writeText('').catch(()=>{}), 30000); } + touchEntry(entry.id); } function copyUsername(entry) { @@ -4508,6 +4997,208 @@ function copyUsername(entry) { } else { navigator.clipboard.writeText(u).then(() => toast('Username copied')); } + touchEntry(entry.id); +} + +// Bump accessed_at on the server (fire-and-forget) AND in local state so +// the "Recent" view updates without a full reload. Coalesces rapid calls +// for the same id within 2s to avoid hammering the server on bulk copies. +const _touchTimers = {}; +function touchEntry(id) { + if (!id) return; + const now = new Date().toISOString(); + const e = state.entries.find(x => x.id === id); + if (e) e.accessed_at = now; + if (_touchTimers[id]) return; + _touchTimers[id] = setTimeout(() => { + delete _touchTimers[id]; + fetch(API + '/entries/' + id + '/touch', { + method: 'POST', + headers: authHeaders(), + }).catch(() => {}); // silent — local state already reflects it + }, 2000); +} + +// ============================================================ +// ENCRYPTED ATTACHMENTS +// ============================================================ +// Per-entry files (PDFs, backup-code images, recovery sheets, …) +// encrypted client-side with the vault key, base64-shipped to the server +// for opaque storage. Listing returns metadata only — the ciphertext is +// fetched on demand when the user clicks Download. + +const ATTACHMENT_MAX_BYTES = 5 * 1024 * 1024; // raw file size cap (5 MB) + +async function encryptBlobBytes(bytes) { + const iv = crypto.getRandomValues(new Uint8Array(12)); + const ct = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv }, state.cryptoKey, bytes); + return { encrypted: bytesToBase64(ct), iv: bytesToBase64(iv) }; +} +async function decryptBlobBytes(encB64, ivB64) { + const ct = base64ToBytes(encB64); + const iv = base64ToBytes(ivB64); + const dec = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv }, state.cryptoKey, ct); + return new Uint8Array(dec); +} + +function humanFileSize(n) { + if (n < 1024) return n + ' B'; + if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB'; + return (n / 1024 / 1024).toFixed(2) + ' MB'; +} + +async function uploadAttachment(entryId, file) { + if (!file) return null; + if (file.size > ATTACHMENT_MAX_BYTES) { + toast('File too large (max 5 MB raw)', 'error'); + return null; + } + const bytes = new Uint8Array(await file.arrayBuffer()); + const { encrypted, iv } = await encryptBlobBytes(bytes); + const meta = await api('/entries/' + entryId + '/attachments', { + method: 'POST', + headers: authHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ + filename: file.name, + mime: file.type || 'application/octet-stream', + encrypted_blob: encrypted, + iv, + size_bytes: file.size, + }), + }); + return meta; +} + +async function downloadAttachment(att) { + try { + const full = await api('/attachments/' + att.id, { headers: authHeaders() }); + const bytes = await decryptBlobBytes(full.encrypted_blob, full.iv); + if (Bridge.active && typeof Bridge.saveFile === 'function') { + const res = await Bridge.saveFile(att.filename, bytes); + if (res.ok) toast('Saved to ' + res.path); + else if (res.error) toast('Save failed: ' + res.error, 'error'); + } else { + // Web fallback: trigger a browser download. + const blob = new Blob([bytes], { type: att.mime || 'application/octet-stream' }); + const url = URL.createObjectURL(blob); + const a = el('a', { href: url, download: att.filename }); + document.body.appendChild(a); + a.click(); + setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 100); + } + } catch (e) { + toast('Download failed: ' + (e && e.message ? e.message : e), 'error'); + } +} + +// Slideover field: header + upload button + list of attachments. +// Re-renders the list in place when the contents change (upload/delete). +function soAttachmentsField(entryId) { + const wrap = el('div', { class: 'slideover-field' }); + wrap.appendChild(el('div', { class: 'slideover-field-label' }, 'Attachments')); + + const addBtn = el('button', { + class: 'btn btn-ghost btn-sm', type: 'button', + title: 'Attach a file (encrypted before upload, max 5 MB)', + }); + addBtn.appendChild(icon('i-paperclip')); + addBtn.appendChild(document.createTextNode(' Attach file')); + wrap.appendChild(addBtn); + + const fileInput = el('input', { type: 'file', style: 'display:none' }); + wrap.appendChild(fileInput); + + const list = el('div', { class: 'so-attach-list', id: 'soAttachList' }); + wrap.appendChild(list); + + addBtn.addEventListener('click', ev => { + ev.stopPropagation(); + fileInput.click(); + }); + fileInput.addEventListener('change', async ev => { + ev.stopPropagation(); + const file = fileInput.files && fileInput.files[0]; + if (!file) return; + fileInput.value = ''; // allow re-uploading the same name later + addBtn.disabled = true; + try { + await uploadAttachment(entryId, file); + toast('Attachment uploaded'); + await refreshAttachments(entryId, list); + } catch (e) { + toast('Upload failed: ' + e.message, 'error'); + } finally { + addBtn.disabled = false; + } + }); + + // Initial async load (silent — empty state shows by default). + refreshAttachments(entryId, list); + return wrap; +} + +async function refreshAttachments(entryId, container) { + container.innerHTML = ''; + let items = []; + try { + items = await api('/entries/' + entryId + '/attachments', + { headers: authHeaders() }); + } catch (e) { + container.appendChild(el('div', { class: 'so-attach-empty' }, + 'Failed to load attachments')); + return; + } + if (!items || items.length === 0) { + container.appendChild(el('div', { class: 'so-attach-empty' }, + 'No attachments yet.')); + return; + } + items.forEach(att => container.appendChild(buildAttachmentRow(att, entryId, container))); +} + +function buildAttachmentRow(att, entryId, listContainer) { + const row = el('div', { class: 'so-attach-row' }); + const name = el('div', { class: 'so-attach-name', title: att.filename }, att.filename); + const meta = el('div', { class: 'so-attach-meta' }, + humanFileSize(att.size_bytes) + ' · ' + (att.mime || '?')); + const info = el('div', { class: 'so-attach-info' }); + info.appendChild(name); + info.appendChild(meta); + row.appendChild(info); + + const dl = el('button', { class: 'icon-btn icon-btn-sm', type: 'button', title: 'Download' }); + dl.appendChild(icon('i-download')); + dl.addEventListener('click', ev => { + ev.stopPropagation(); + downloadAttachment(att); + }); + row.appendChild(dl); + + const del = el('button', { class: 'icon-btn icon-btn-sm', type: 'button', title: 'Delete' }); + del.appendChild(icon('i-trash')); + del.addEventListener('click', async ev => { + ev.stopPropagation(); + const ok = await confirmDialog({ + title: 'Delete attachment?', + message: 'This will permanently remove ' + att.filename + '. There is no trash for attachments.', + okText: 'Delete', + danger: true, + }); + if (!ok) return; + try { + await api('/attachments/' + att.id, { + method: 'DELETE', headers: authHeaders(), + }); + toast('Attachment deleted'); + await refreshAttachments(entryId, listContainer); + } catch (e) { + toast('Delete failed: ' + e.message, 'error'); + } + }); + row.appendChild(del); + return row; } // Display helper: when `maskUsernames` setting is on, show only the first 2 @@ -4547,32 +5238,195 @@ async function deleteFolder(name, entryCount) { } catch (e) { toast(e.message || 'Delete failed', 'error'); } } -async function addFolder() { - const name = await promptDialog({ - title: 'New folder', - message: 'Folder name', - placeholder: 'e.g. Work', - okText: 'Create', - }); - if (!name || !name.trim()) return; - const clean = name.trim(); - // "All" is reserved as the internal default for "uncategorized" entries - // (and would visually duplicate the "All items" top nav). Reject here - // rather than letting the user create a confusing duplicate. - if (clean.toLowerCase() === 'all') { - toast('"All" is reserved — pick another name', 'warning'); - return; - } +// Curated swatches + icon choices. Kept small so the picker stays a +// single visual row each — paradox of choice tames itself. +const FOLDER_PALETTE = [ + '', // 'no color' = default accent + '#ef4444', '#f97316', '#eab308', '#22c55e', + '#06b6d4', '#3b82f6', '#a855f7', '#ec4899', +]; +const FOLDER_ICONS = [ + 'i-folder', 'i-shield', 'i-key', 'i-star', + 'i-user', 'i-tag', 'i-globe', 'i-lock', +]; + +function addFolder() { openFolderModal(null); } + +// Reorder: move `draggedName` directly before or after `targetName`. +// Builds the new names list and persists via POST /folders/reorder. +async function reorderFolderTo(draggedName, targetName, insertBefore) { + // Work on the user-visible (non-"All") slice — "All" is synthetic and + // never reordered, so it must stay at index 0 in state.folders. + const real = state.folders.filter(f => f.name !== 'All'); + const dragged = real.find(f => f.name === draggedName); + if (!dragged) return; + const filtered = real.filter(f => f.name !== draggedName); + const targetIdx = filtered.findIndex(f => f.name === targetName); + if (targetIdx === -1) return; + const insertIdx = insertBefore ? targetIdx : targetIdx + 1; + filtered.splice(insertIdx, 0, dragged); + + state.folders = [{ name: 'All', color: '', icon: '' }].concat(filtered); + render(); // optimistic — feels instant + try { - await api('/folders', { + await api('/folders/reorder', { method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }), - body: JSON.stringify({ name: clean }), + body: JSON.stringify({ names: filtered.map(f => f.name) }), }); + } catch (e) { + // Roll back by reloading from the server on failure. + toast('Reorder failed — reverting', 'error'); await loadFolders(); render(); - toast('Folder created'); - } catch (e) { toast(e.message, 'error'); } + } +} + +function openFolderModal(existing) { + const isEdit = !!existing; + const initName = isEdit ? existing.name : ''; + const initColor = isEdit ? (existing.color || '') : ''; + const initIcon = isEdit ? (existing.icon || 'i-folder') : 'i-folder'; + + // Build modal node fresh each call so swatches/icons don't leak listeners. + const old = document.getElementById('folderModal'); + if (old) old.remove(); + + const modal = el('div', { id: 'folderModal', class: 'modal' }); + const backdrop = el('div', { class: 'modal-backdrop' }); + backdrop.addEventListener('click', () => modal.remove()); + modal.appendChild(backdrop); + const panel = el('div', { class: 'modal-panel modal-panel-sm' }); + + const header = el('div', { class: 'modal-header' }); + header.appendChild(el('h3', null, isEdit ? 'Customize folder' : 'New folder')); + panel.appendChild(header); + + const body = el('div', { class: 'modal-body' }); + + // Name input (read-only when editing — rename out of scope for v1) + const nameField = el('div', { class: 'form-field' }); + nameField.appendChild(el('label', null, 'Name')); + const nameInput = el('input', { + type: 'text', class: 'so-input', id: 'folderModalName', + placeholder: 'e.g. Work', value: initName, + }); + if (isEdit) nameInput.disabled = true; + nameField.appendChild(nameInput); + body.appendChild(nameField); + + // Selected state held by closure. + let pickedColor = initColor; + let pickedIcon = initIcon; + + // Color row + const colorField = el('div', { class: 'form-field' }); + colorField.appendChild(el('label', null, 'Color')); + const colorRow = el('div', { class: 'folder-swatch-row' }); + FOLDER_PALETTE.forEach(c => { + const sw = el('button', { + type: 'button', + class: 'folder-swatch' + (c === pickedColor ? ' is-active' : ''), + 'data-color': c, + title: c || 'Default', + }); + if (c) sw.style.background = c; + else sw.classList.add('is-default'); + sw.addEventListener('click', () => { + pickedColor = c; + colorRow.querySelectorAll('.folder-swatch').forEach(x => + x.classList.toggle('is-active', x.getAttribute('data-color') === c)); + refreshPreview(); + }); + colorRow.appendChild(sw); + }); + colorField.appendChild(colorRow); + body.appendChild(colorField); + + // Icon row + const iconField = el('div', { class: 'form-field' }); + iconField.appendChild(el('label', null, 'Icon')); + const iconRow = el('div', { class: 'folder-icon-row' }); + FOLDER_ICONS.forEach(name => { + const btn = el('button', { + type: 'button', + class: 'folder-icon-btn' + (name === pickedIcon ? ' is-active' : ''), + 'data-icon': name, + }); + btn.appendChild(icon(name)); + btn.addEventListener('click', () => { + pickedIcon = name; + iconRow.querySelectorAll('.folder-icon-btn').forEach(x => + x.classList.toggle('is-active', x.getAttribute('data-icon') === name)); + refreshPreview(); + }); + iconRow.appendChild(btn); + }); + iconField.appendChild(iconRow); + body.appendChild(iconField); + + // Live preview + const previewWrap = el('div', { class: 'folder-preview' }); + const previewIcon = icon(pickedIcon); + previewWrap.appendChild(previewIcon); + const previewLabel = el('span', null, initName || 'Folder'); + previewWrap.appendChild(previewLabel); + body.appendChild(previewWrap); + panel.appendChild(body); + + function refreshPreview() { + previewIcon.innerHTML = ''; + const u = document.createElementNS('http://www.w3.org/2000/svg', 'use'); + u.setAttribute('href', '#' + pickedIcon); + previewIcon.appendChild(u); + previewIcon.style.color = pickedColor || ''; + previewLabel.textContent = (nameInput.value || 'Folder'); + } + nameInput.addEventListener('input', refreshPreview); + + // Footer buttons + const footer = el('div', { class: 'modal-footer' }); + const cancelBtn = el('button', { class: 'btn btn-ghost', type: 'button' }, 'Cancel'); + cancelBtn.addEventListener('click', () => modal.remove()); + footer.appendChild(cancelBtn); + + const saveBtn = el('button', + { class: 'btn btn-primary', type: 'button' }, + isEdit ? 'Save' : 'Create'); + saveBtn.addEventListener('click', () => submitFolder()); + footer.appendChild(saveBtn); + panel.appendChild(footer); + + modal.appendChild(panel); + document.body.appendChild(modal); + setTimeout(() => nameInput.focus(), 30); + + async function submitFolder() { + const clean = (nameInput.value || '').trim(); + if (!isEdit && !clean) return toast('Name required', 'warning'); + if (clean.toLowerCase() === 'all') return toast('"All" is reserved', 'warning'); + try { + if (isEdit) { + await api('/folders/' + encodeURIComponent(initName), { + method: 'PUT', + headers: authHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ color: pickedColor, icon: pickedIcon }), + }); + toast('Folder updated'); + } else { + await api('/folders', { + method: 'POST', + headers: authHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ name: clean, color: pickedColor, icon: pickedIcon }), + }); + toast('Folder created'); + } + modal.remove(); + await loadFolders(); + render(); + } catch (e) { toast(e.message, 'error'); } + } } // ============================================================ @@ -5010,6 +5864,50 @@ async function tryQuickUnlock() { // account isn't left with the recovery code's KEK as the only // escape hatch. +// Renders a printable A4 sheet (CSS-only, no external libs) with the +// recovery code in large monospace + a fold-and-stash instruction. +// Removes the print container after the print dialog closes. +function printRecoveryCode(code, username) { + const existing = document.getElementById('printRecoveryArea'); + if (existing) existing.remove(); + + const wrap = document.createElement('div'); + wrap.id = 'printRecoveryArea'; + const today = new Date().toISOString().slice(0, 10); + wrap.innerHTML = + '
' + + '

PMServer · Recovery Code

' + + '
' + + '
Account: ' + (username || '') + '
' + + '
Generated: ' + today + '
' + + '
' + + '
' + code + '
' + + '
' + + '

Keep this sheet offline and physically secure.

' + + '

Use this code if you forget your master password:

' + + '
    ' + + '
  1. On the unlock screen, click "Use recovery code".
  2. ' + + '
  3. Enter your username and the 16-character code above.
  4. ' + + '
  5. You will be asked to set a new master password — the code is then consumed.
  6. ' + + '
' + + '

The code can be used up to 5 times. It is permanently erased the moment you successfully change the master password.

' + + '

Anyone with this code can reset your master password — store it like a paper key, not a sticky note.

' + + '
' + + '
'; + document.body.appendChild(wrap); + + // Restore on print-end and on focus (Edge fires focus when preview closes). + const cleanup = () => { + const n = document.getElementById('printRecoveryArea'); + if (n) n.remove(); + window.removeEventListener('afterprint', cleanup); + window.removeEventListener('focus', cleanup); + }; + window.addEventListener('afterprint', cleanup); + window.addEventListener('focus', cleanup); + setTimeout(() => window.print(), 50); +} + // Random recovery code: 16 chars in 4 groups of 4. ~96 bits entropy // from a 36-char alphabet (no ambiguous chars: no 0/O/I/l/1) so the // printed form is misreading-resistant. @@ -5120,21 +6018,26 @@ async function doGenerateRecoveryKey() { // onclick handlers, hence the addEventListener route. setTimeout(() => { const btn = document.getElementById('copyRecoveryCodeBtn'); - if (!btn) return; - btn.addEventListener('click', () => { - // Same path as password copy: secure-clipboard via Delphi - // (excluded from Win+V history, auto-cleared after 30s) when - // running embedded, navigator.clipboard with manual scrub - // otherwise. - if (Bridge.copySecure(code, 30000)) { - toast('Recovery code copied · clears in 30s'); - } else { - navigator.clipboard.writeText(code).then(() => { + if (btn) { + btn.addEventListener('click', () => { + // Same path as password copy: secure-clipboard via Delphi + // (excluded from Win+V history, auto-cleared after 30s) when + // running embedded, navigator.clipboard with manual scrub + // otherwise. + if (Bridge.copySecure(code, 30000)) { toast('Recovery code copied · clears in 30s'); - setTimeout(() => navigator.clipboard.writeText('').catch(()=>{}), 30000); - }); - } - }); + } else { + navigator.clipboard.writeText(code).then(() => { + toast('Recovery code copied · clears in 30s'); + setTimeout(() => navigator.clipboard.writeText('').catch(()=>{}), 30000); + }); + } + }); + } + const printBtn = document.getElementById('printRecoveryCodeBtn'); + if (printBtn) { + printBtn.addEventListener('click', () => printRecoveryCode(code, state.username)); + } }, 0); await confirmDialog({ @@ -5150,6 +6053,9 @@ async function doGenerateRecoveryKey() { '' + + '' + '
' + '

' + 'Using it lets you recover access if you forget your master ' + @@ -5904,50 +6810,113 @@ async function doExport() { return toast('Use at least 6 characters', 'warning'); } - // Step 3: assemble the plaintext payload (same shape as the legacy - // plaintext exporter — round-trips with the existing JSON importer - // after decryption). - const payload = { - 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); - let plainTotp = ''; - if (e.totp_secret && e.totp_iv) { - plainTotp = await decryptTotpSecret(e.totp_secret, e.totp_iv); - if (plainTotp === '[ERROR]') plainTotp = ''; + toast('Encrypting backup… (PBKDF2 600k iterations)'); + try { + // Step 3: assemble the plaintext payload (same shape as the legacy + // plaintext exporter — round-trips with the existing JSON importer + // after decryption). + const payload = { + 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); + let plainTotp = ''; + if (e.totp_secret && e.totp_iv) { + plainTotp = await decryptTotpSecret(e.totp_secret, e.totp_iv); + if (plainTotp === '[ERROR]') plainTotp = ''; + } + payload.entries.push({ + site: e.site, + title: e.title || '', + username: e.username, + password: plain, + folder: e.folder, + tags: parseTags(e.tags), + favorite: !!e.favorite, + totp_secret: plainTotp, + kind: e.kind || 'login', + created_at: e.created_at, + updated_at: e.updated_at, + }); } - payload.entries.push({ - site: e.site, - title: e.title || '', - username: e.username, - password: plain, - folder: e.folder, - tags: parseTags(e.tags), - favorite: !!e.favorite, - totp_secret: plainTotp, - created_at: e.created_at, - updated_at: e.updated_at, - }); - } - // Step 4: encrypt + download - const container = await encryptExportPayload(payload, exportPwd); - const blob = new Blob([JSON.stringify(container, null, 2)], { - type: 'application/json', + // Step 4: encrypt + save via native dialog + const container = await encryptExportPayload(payload, exportPwd); + const json = JSON.stringify(container, null, 2); + const fname = 'vault-export-' + new Date().toISOString().slice(0, 10) + '.json'; + const res = await Bridge.saveFile(fname, json); + if (res.ok) toast(payload.entries.length + ' entries exported to ' + res.path); + else if (res.error) toast('Export failed: ' + res.error, 'error'); + } catch (err) { + toast('Export failed: ' + (err && err.message ? err.message : err), 'error'); + } +} + +// CSV escape: wrap in quotes if the value contains comma / quote / newline. +// Inner quotes doubled per RFC 4180. +function csvEscape(s) { + if (s == null) return ''; + s = String(s); + if (/[",\n\r]/.test(s)) return '"' + s.replace(/"/g, '""') + '"'; + return s; +} + +async function doExportCSV() { + const ok = await confirmDialog({ + title: 'Export to CSV?', + message: + 'The CSV file is NOT encrypted. Passwords, note bodies and ' + + 'custom field values will be written in plaintext, readable by ' + + 'anyone who opens the file.

' + + 'Use only for migration to another password manager — delete ' + + 'the file as soon as the import is done.', + okText: 'Export plaintext', + danger: true, }); - 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(payload.entries.length + ' entries exported (encrypted)'); + if (!ok) return; + + // Decrypt everything client-side (server never sees plaintext). + const rows = [[ + 'kind', 'title', 'site', 'username', 'password', + 'totp_secret', 'folder', 'tags', + 'custom_fields', 'created_at', 'updated_at', + ]]; + for (const e of state.entries) { + const pwd = await decryptPwd(e.encrypted_password, e.iv); + let totp = ''; + if (e.totp_secret && e.totp_iv) { + totp = await decryptTotpSecret(e.totp_secret, e.totp_iv); + if (totp === '[ERROR]') totp = ''; + } + let cf = ''; + if (e.custom_fields && e.custom_fields_iv) { + const arr = await decryptCustomFields(e.custom_fields, e.custom_fields_iv); + if (arr.length) cf = JSON.stringify(arr); + } + rows.push([ + e.kind || 'login', + e.title || '', + e.site || '', + e.username || '', + pwd === '[ERROR]' ? '' : pwd, + totp, + e.folder || '', + e.tags || '', + cf, + e.created_at || '', + e.updated_at || '', + ]); + } + const csv = rows.map(r => r.map(csvEscape).join(',')).join('\r\n'); + // Prepend UTF-8 BOM so Excel reads accented chars correctly. + const body = '' + csv; + const fname = 'vault-export-' + new Date().toISOString().slice(0, 10) + '.csv'; + const res = await Bridge.saveFile(fname, body); + if (res.ok) toast(state.entries.length + ' entries exported to ' + res.path, 'warning'); + else if (res.error) toast('Export failed: ' + res.error, 'error'); } // ============================================================ @@ -6273,6 +7242,7 @@ function openSettings() { $('#settingTrayNotif').checked = state.trayNotificationsEnabled !== false; $('#settingTrayNotifRow').style.display = Bridge.active ? '' : 'none'; $('#settingTrashPurge').value = String(state.trashAutoPurgeDays || 0); + $('#settingPasswordExpiry').value = String(state.passwordExpiryDays || 0); $('#settingUser').textContent = state.username; // Async: query server for recovery key state and update the label refreshRecoveryStatus(); @@ -6293,6 +7263,13 @@ function openSettings() { updateQuickUnlockUI(); } + // Auto-backup: only meaningful with the Delphi bridge (filesystem). + if (Bridge.active) { + loadAutoBackupConfig().then(refreshAutoBackupUI); + } else { + refreshAutoBackupUI(null); + } + $('#settingsPanel').classList.add('is-open'); } function closeSettings() { @@ -6406,6 +7383,9 @@ async function enterApp() { // Trash auto-purge (configured via Settings → Security). Fire-and- // forget: failures are silent — the user can run "Empty trash" manually. autoPurgeTrashIfNeeded(); + // Fire-and-forget periodic backup. Defer a few seconds so the unlock + // path isn't blocked by file I/O + AES-GCM over the full vault. + setTimeout(() => { runAutoBackupIfDue(); }, 5000); } async function autoPurgeTrashIfNeeded() { @@ -6431,6 +7411,187 @@ async function autoPurgeTrashIfNeeded() { } } +// ============================================================ +// AUTO-BACKUP (silent encrypted exports on a schedule) +// ============================================================ +// All config is device-local (folder paths and passwords don't sync +// meaningfully across machines) and persisted via Delphi DPAPI prefs +// so it survives the port-rotation localStorage wipe. + +const ABK = { + enabled: 'autoBackupEnabled', // '1' | '' + dir: 'autoBackupDir', // absolute Windows path + interval: 'autoBackupInterval', // days, int as string + keep: 'autoBackupKeep', // count, int as string + last: 'autoBackupLast', // ISO timestamp of last successful run + pwd: 'autoBackupPwd', // user-chosen pwd, used silently +}; +const AUTO_BACKUP_PREFIX = 'vault-autobackup-'; + +async function loadAutoBackupConfig() { + if (!Bridge.active) return null; + const [enabled, dir, interval, keep, last, pwd] = await Promise.all([ + Bridge.getPref(ABK.enabled), + Bridge.getPref(ABK.dir), + Bridge.getPref(ABK.interval), + Bridge.getPref(ABK.keep), + Bridge.getPref(ABK.last), + Bridge.getPref(ABK.pwd), + ]); + return { + enabled: enabled === '1', + dir: dir || '', + interval: Math.max(1, parseInt(interval, 10) || 7), + keep: Math.max(1, parseInt(keep, 10) || 10), + last: last || '', + hasPwd: !!pwd, + pwd, + }; +} + +function refreshAutoBackupUI(cfg) { + if (!cfg) { + $('#autoBackupField').style.display = 'none'; + return; + } + $('#autoBackupField').style.display = ''; + $('#settingAutoBackupEnabled').checked = cfg.enabled; + $('#autoBackupConfig').style.display = cfg.enabled ? '' : 'none'; + $('#autoBackupDir').textContent = cfg.dir || '(not set)'; + $('#settingAutoBackupInterval').value = cfg.interval; + $('#settingAutoBackupKeep').value = cfg.keep; + $('#autoBackupLast').textContent = cfg.last + ? 'Last run: ' + cfg.last.replace('T', ' ').slice(0, 16) + : 'Never run yet'; +} + +async function pickAutoBackupFolder() { + const path = await Bridge.pickFolder(); + if (!path) return; + Bridge.setPref(ABK.dir, path); + $('#autoBackupDir').textContent = path; + toast('Backup folder set'); +} + +async function promptAndStoreBackupPwd() { + const pwd = await promptDialog({ + title: 'Choose a backup password', + message: 'You will need this to restore the auto-backups. Save it somewhere safe — it is independent of your master password.', + placeholder: 'At least 6 characters', + password: true, + okText: 'Save', + }); + if (!pwd || pwd.length < 6) { + toast('Auto-backup cancelled — password too short', 'warning'); + return false; + } + Bridge.setPref(ABK.pwd, pwd); + return true; +} + +async function onToggleAutoBackup(ev) { + const enabled = ev.target.checked; + if (enabled) { + const cfg = await loadAutoBackupConfig(); + if (!cfg.hasPwd && !(await promptAndStoreBackupPwd())) { + ev.target.checked = false; + return; + } + Bridge.setPref(ABK.enabled, '1'); + $('#autoBackupConfig').style.display = ''; + toast('Auto-backup enabled'); + // Refresh the dir/last display in case we came from cold state. + const fresh = await loadAutoBackupConfig(); + refreshAutoBackupUI(fresh); + } else { + Bridge.setPref(ABK.enabled, ''); + // Forget the stored backup pwd so re-enabling prompts fresh — + // gives the user a way to change it without extra UI. + Bridge.setPref(ABK.pwd, ''); + $('#autoBackupConfig').style.display = 'none'; + toast('Auto-backup disabled'); + } +} + +async function runAutoBackupNow() { + const cfg = await loadAutoBackupConfig(); + if (!cfg) return toast('Bridge not available', 'error'); + if (!cfg.dir) return toast('Choose a backup folder first', 'warning'); + if (!cfg.hasPwd) return toast('Backup password not set', 'warning'); + if (!state.cryptoKey) return toast('Vault is locked', 'warning'); + + toast('Encrypting backup…'); + try { + const payload = { + 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); + let plainTotp = ''; + if (e.totp_secret && e.totp_iv) { + plainTotp = await decryptTotpSecret(e.totp_secret, e.totp_iv); + if (plainTotp === '[ERROR]') plainTotp = ''; + } + payload.entries.push({ + site: e.site, title: e.title || '', username: e.username, + password: plain, folder: e.folder, tags: parseTags(e.tags), + favorite: !!e.favorite, totp_secret: plainTotp, + kind: e.kind || 'login', + created_at: e.created_at, updated_at: e.updated_at, + }); + } + const container = await encryptExportPayload(payload, cfg.pwd); + const json = JSON.stringify(container, null, 2); + // Filename: yyyymmdd-HHMMSS for filesystem-sort-friendliness. + const ts = new Date().toISOString() + .replace(/[-:]/g, '').replace('T', '-').slice(0, 15); + const fname = AUTO_BACKUP_PREFIX + ts + '.json'; + const path = cfg.dir.replace(/[\\/]+$/, '') + '\\' + fname; + const res = await Bridge.writeFile(path, json); + if (!res.ok) { + toast('Backup failed: ' + (res.error || 'unknown'), 'error'); + return; + } + const now = new Date().toISOString(); + Bridge.setPref(ABK.last, now); + $('#autoBackupLast').textContent = 'Last run: ' + now.replace('T', ' ').slice(0, 16); + toast(payload.entries.length + ' entries backed up'); + applyAutoBackupRetention(cfg.dir, cfg.keep); + } catch (err) { + toast('Backup failed: ' + (err && err.message ? err.message : err), 'error'); + } +} + +async function applyAutoBackupRetention(dir, keep) { + try { + const files = await Bridge.listFiles(dir, AUTO_BACKUP_PREFIX); + if (files.length <= keep) return; + // Sort by name desc (timestamps in filename → lexical = chronological) + files.sort((a, b) => (a.name < b.name ? 1 : -1)); + const toDelete = files.slice(keep); + for (const f of toDelete) { + const path = dir.replace(/[\\/]+$/, '') + '\\' + f.name; + await Bridge.deleteFile(path); + } + } catch (e) { + // Retention is best-effort; user can clean up manually. + } +} + +async function runAutoBackupIfDue() { + if (!Bridge.active) return; + const cfg = await loadAutoBackupConfig(); + if (!cfg || !cfg.enabled || !cfg.dir || !cfg.hasPwd) return; + if (!state.cryptoKey) return; + const intervalMs = cfg.interval * 24 * 3600 * 1000; + const last = cfg.last ? Date.parse(cfg.last) : 0; + if (last && (Date.now() - last) < intervalMs) return; + await runAutoBackupNow(); +} + // ============================================================ // SERVER-SIDE SETTINGS SYNC // ============================================================ @@ -6451,6 +7612,9 @@ const SYNCED_SETTING_KEYS = [ 'faviconsEnabled', 'trayNotificationsEnabled', 'trashAutoPurgeDays', + // Password expiry reminder window (days, 0 = disabled). Cards show + // an "Aged" badge for entries whose password_changed_at exceeds it. + 'passwordExpiryDays', ]; function applySidebarCollapsed() { @@ -6504,6 +7668,9 @@ async function loadServerSettings() { case 'trashAutoPurgeDays': localStorage.setItem('trashAutoPurgeDays', String(v)); break; + case 'passwordExpiryDays': + localStorage.setItem('passwordExpiryDays', String(v)); + break; } }); // Apply visual settings immediately. @@ -6848,6 +8015,12 @@ async function init() { render(); }); $('#sidebarTotpToolBtn').addEventListener('click', openTotpTool); + $('#sidebarRecentBtn').addEventListener('click', () => { + state.view = 'recent'; + state.currentPage = 1; + $$('.nav-item').forEach(b => b.classList.remove('is-active')); + render(); + }); $('#sidebarHealthBtn').addEventListener('click', () => { state.view = 'health'; state.currentPage = 1; @@ -6856,6 +8029,12 @@ async function init() { healthCache = null; render(); }); + $('#sidebarAuditBtn').addEventListener('click', () => { + state.view = 'audit'; + $$('.nav-item').forEach(b => b.classList.remove('is-active')); + auditCache = null; + render(); + }); // Sidebar section collapse toggles document.querySelectorAll('[data-section-toggle]').forEach(btn => { @@ -6960,6 +8139,29 @@ async function init() { if (n === 0) toast('Trash auto-purge disabled'); else toast('Trash will auto-purge after ' + n + ' days (next unlock)'); }); + $('#settingPasswordExpiry').addEventListener('change', e => { + const n = parseInt(e.target.value, 10) || 0; + state.passwordExpiryDays = n; + localStorage.setItem('passwordExpiryDays', String(n)); + saveServerSettings(); + render(); + if (n === 0) toast('Password aging reminders disabled'); + else toast('Passwords older than ' + n + ' days will be flagged'); + }); + $('#settingAutoBackupEnabled').addEventListener('change', onToggleAutoBackup); + $('#autoBackupPickDirBtn').addEventListener('click', pickAutoBackupFolder); + $('#settingAutoBackupInterval').addEventListener('change', e => { + const n = Math.max(1, parseInt(e.target.value, 10) || 7); + e.target.value = n; + Bridge.setPref(ABK.interval, String(n)); + }); + $('#settingAutoBackupKeep').addEventListener('change', e => { + const n = Math.max(1, parseInt(e.target.value, 10) || 10); + e.target.value = n; + Bridge.setPref(ABK.keep, String(n)); + }); + $('#autoBackupNowBtn').addEventListener('click', runAutoBackupNow); + $('#settingFavicons').addEventListener('change', e => { state.faviconsEnabled = e.target.checked; localStorage.setItem('faviconsEnabled', state.faviconsEnabled ? '1' : '0'); @@ -7070,6 +8272,7 @@ async function init() { toast('Open Windows Settings → System → Clipboard → turn off "Clipboard history"', 'warning'); }); $('#exportBtn').addEventListener('click', doExport); + $('#exportCsvBtn').addEventListener('click', doExportCSV); $('#importBtn').addEventListener('click', doImport); $('#changeMasterBtn').addEventListener('click', openChangeMasterModal); $('#changeMasterForm').addEventListener('submit', e => { @@ -7183,6 +8386,25 @@ async function init() { const cheatBtn = document.getElementById('cheatsheetBtn'); if (cheatBtn) cheatBtn.addEventListener('click', openCheatsheet); + // Filters dropdown + const filtersBtn = document.getElementById('filtersBtn'); + const filtersMenu = document.getElementById('filtersMenu'); + if (filtersBtn && filtersMenu) { + filtersBtn.addEventListener('click', ev => { + ev.stopPropagation(); + renderFiltersMenu(); + filtersMenu.classList.toggle('is-hidden'); + }); + // Click outside the menu closes it (not on a checkbox / button — those + // bubbleStop above so they don't reach this listener). + document.addEventListener('click', e => { + if (!e.target.closest('.filters-wrap')) + filtersMenu.classList.add('is-hidden'); + }); + } + renderFiltersBadge(); + renderFilterChips(); + // Quick-search modal (tray menu) — keyboard nav + close const qsInput = document.getElementById('quickSearchInput'); if (qsInput) {