From 2ef636ce30d760e1c017433c69ed7cbca9bf1a55 Mon Sep 17 00:00:00 2001 From: Zaki <18zaki18@gmail.com> Date: Wed, 10 Jun 2026 00:15:20 +0100 Subject: [PATCH] feat: unified entry slideover + custom icons + UX fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unified create/edit slideover - openSlideOver(id) now accepts null for new entries. Same UI (icon, name, site, user, password, TOTP, folder, tags) for both create and edit. Drops the separate entry modal — no more "save first, then add TOTP" two-step. - "+ New" button, Ctrl+K → New entry, and Ctrl+Shift+A all route through the slideover. Ctrl+Shift+A pre-fills the title with the foreground window's name. - Save button visible from the start in new mode (no dirty wait). - Title shows mode unambiguously: cyan "+ New entry" vs "Edit · ". Custom icon upload (soIconField) - 56×56 preview at the top of every slideover + Upload icon / Remove buttons. Same POST /entries/{id}/icon endpoint as the auto-fetch path. Validates type / size (64 KB cap matching server). - Solves the case where DDG doesn't index a domain (self-hosted apps, private sites): the user pastes any image and it sticks. Favicon: privacy-first, DDG only - Removed the direct-fetch fallback steps (3-5). Privacy stance: zero DNS leak outside icons.duckduckgo.com. Domains DDG doesn't cover stay icon-less until the user uploads a custom one. - PM.Favicon.FetchFaviconDataUri takes an optional TFaviconLog callback so UMainForm can stream per-step trace into LogLine for diagnostics. Fixes - Slideover z-index 30 → 50. The topbar's backdrop-filter creates a stacking context at z-index 40 which was clipping the slideover header (title + close button hidden behind topbar). - RestoreFromTray no longer un-maximises a maximised window when called outside a tray-restore context (Ctrl+Shift+A, Ctrl+Shift+L picker, app/focus cmd). SW_RESTORE on a maximised window reverts to normal — now we only SW_RESTORE if IsIconic. - "Show all"/"Show less" per-category state survives renderGrid re-renders (healthExpanded map). - "+ New" and dashboard "Fix" buttons stopPropagation so the document-level click-outside handler doesn't close the slideover they just opened. - soDirtyCheck keeps Save visible while in new mode regardless of diff. - openSlideover → openSlideOver typo fix across all call sites. Co-Authored-By: Claude Opus 4.7 --- CLAUDE.md | 8 +- css/style.css | 41 +++- delphi-backend/PMServer.dproj | 9 +- delphi-backend/Source/PM.Bridge.pas | 12 +- delphi-backend/Source/PM.Favicon.pas | 153 ++++++++++--- delphi-backend/UMainForm.pas | 10 +- delphi-backend/assets/assets.res | Bin 351612 -> 360036 bytes js/app.js | 324 +++++++++++++++++++++------ 8 files changed, 450 insertions(+), 107 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 00a3209..e6cfd31 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -166,9 +166,11 @@ Opt-in (`state.faviconsEnabled`, default OFF, synced via `settings_json`). La SEULE feature qui sort sur le réseau côté Delphi (HIBP est côté JS). - Source : `https://icons.duckduckgo.com/ip3/.ico` — proxy DDG, pas - de tracking, retourne PNG 16-32 px. Centralisé → seul DDG voit la - liste des domaines stockés, vs hit chaque /favicon.ico (qui leakerait - TOUT le vault à chaque site) + de tracking, retourne PNG 16-32 px. **DDG-only par design** : pas de + fallback vers `/favicon.ico` direct (qui leakerait DNS à chaque + domaine stocké). 2 essais : host complet puis SLD (`chat.qwen.ai` → + `qwen.ai`). Pour les sites privés/self-hosted que DDG ne couvre pas, + l'user upload une icône custom via `soIconField` dans le slideover - `THTTPClient` (`System.Net.HttpClient`) qui wrappe **WinHTTP** sur Windows → TLS natif via le store de certificats Windows. Pas de DLLs OpenSSL à shipper (Indy aurait silencieusement fail sans diff --git a/css/style.css b/css/style.css index d2ab425..488add5 100644 --- a/css/style.css +++ b/css/style.css @@ -1226,6 +1226,42 @@ input[type="range"]::-webkit-slider-thumb { font-style: italic; text-align: center; } +.health-more-btn { + display: block; + margin: 8px auto 0; +} + +/* ---- Slideover custom icon field ------------------------ */ +.so-icon-field { + display: flex; + align-items: center; + gap: 12px; +} +.so-icon-preview { + width: 56px; height: 56px; + display: grid; place-items: center; + background: var(--bg-elev-2); + border-radius: var(--radius-sm); + overflow: hidden; + font-weight: 600; + font-size: 20px; + color: var(--text); + flex-shrink: 0; +} +.so-icon-preview img { + width: 100%; height: 100%; + object-fit: contain; + padding: 6px; + box-sizing: border-box; +} +.so-icon-actions { + display: flex; flex-direction: column; gap: 6px; +} + +/* Slideover title — tint the "+ New entry" prefix in accent colour so + the mode is unambiguous without changing the header layout. */ +#slideoverTitle.is-new-mode { color: var(--accent); } +#slideoverTitle.is-edit-mode { color: var(--text); } .btn-xs { font-size: 11px; padding: 3px 10px; @@ -1252,7 +1288,10 @@ input[type="range"]::-webkit-slider-thumb { transform: translateX(100%); transition: transform var(--t-base); display: flex; flex-direction: column; - z-index: 30; + /* Above the topbar (z-index 40) — otherwise the topbar's + backdrop-filter stacking context clips the slideover header + (where the title / close button live) under it. */ + z-index: 50; } .slideover.is-open { transform: translateX(0); } .slideover-header { diff --git a/delphi-backend/PMServer.dproj b/delphi-backend/PMServer.dproj index 4799856..6da4216 100644 --- a/delphi-backend/PMServer.dproj +++ b/delphi-backend/PMServer.dproj @@ -168,6 +168,12 @@ 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) @@ -203,6 +209,7 @@ $(PreBuildEvent)]]> MainSource +
MainForm
@@ -219,9 +226,9 @@ $(PreBuildEvent)]]> - + diff --git a/delphi-backend/Source/PM.Bridge.pas b/delphi-backend/Source/PM.Bridge.pas index 5647385..659e45b 100644 --- a/delphi-backend/Source/PM.Bridge.pas +++ b/delphi-backend/Source/PM.Bridge.pas @@ -586,8 +586,11 @@ begin FMainForm.Show; // Restore to the exact pre-tray state (maximised/normal + size + pos). - // Falls back to SW_RESTORE if we never captured a placement (e.g. tray - // restore was triggered without a prior MinimizeToTray call). + // Falls back to SW_SHOW (+ conditional SW_RESTORE) if we never captured + // a placement (e.g. focus-app / new-entry hotkey on an already-visible + // window). Unconditional SW_RESTORE would un-maximise a maximised + // window — surprising for the user who pressed Ctrl+Shift+A / +L / + // clicked the tray. if FHasSavedPlacement then begin // showCmd governs whether the window comes back maximised or normal; @@ -599,7 +602,10 @@ begin else begin ShowWindow(LFormHwnd, SW_SHOW); - ShowWindow(LFormHwnd, SW_RESTORE); + // Only un-iconify if actually minimised. Win32 SW_RESTORE on a + // maximised window reverts it to normal — not what we want here. + if IsIconic(LFormHwnd) then + ShowWindow(LFormHwnd, SW_RESTORE); end; SetForegroundWindow(LFormHwnd); end; diff --git a/delphi-backend/Source/PM.Favicon.pas b/delphi-backend/Source/PM.Favicon.pas index 3fb7728..06614e4 100644 --- a/delphi-backend/Source/PM.Favicon.pas +++ b/delphi-backend/Source/PM.Favicon.pas @@ -21,9 +21,15 @@ unit PM.Favicon; interface +type + TFaviconLog = reference to procedure(const ALine: string); + // Fetches an icon for AHost (bare hostname, no scheme). Returns a // "data:image/...;base64,..." string on success, or '' on any failure. -function FetchFaviconDataUri(const AHost: string): string; +// ALog (optional): called for each fallback step so the host can trace +// exactly which URL hit / missed. +function FetchFaviconDataUri(const AHost: string; + ALog: TFaviconLog = nil): string; implementation @@ -32,9 +38,16 @@ uses System.Net.HttpClient, System.Net.URLClient; const - ICON_URL_TEMPLATE = 'https://icons.duckduckgo.com/ip3/%s.ico'; - MAX_ICON_BYTES = 65536; // 64 KB cap (matches handler's SetEntryIcon limit) - HTTP_TIMEOUT_MS = 5000; + ICON_URL_TEMPLATE = 'https://icons.duckduckgo.com/ip3/%s.ico'; + MAX_ICON_BYTES = 65536; // 64 KB cap (matches handler's SetEntryIcon limit) + HTTP_TIMEOUT_MS = 5000; + // DDG returns a generic placeholder for unknown domains. Bigger threshold + // than 100 to avoid treating its blank globe glyph as a real icon. + MIN_REAL_ICON_BYTES = 500; + // Privacy stance: DDG-only fetches. We don't fall back to the site's + // own /favicon.ico because that would leak DNS to every domain stored + // in the vault. For sites DDG doesn't index, the user can upload a + // custom icon via the slideover (soIconField). function NormalizeHost(const ARaw: string): string; var @@ -96,23 +109,120 @@ begin Exit('image/x-icon'); end; -function FetchFaviconDataUri(const AHost: string): string; +// "chat.deepseek.com" → "deepseek.com". Returns '' if S has no dot or +// is already a 2-label hostname (we'd fall back to the same input). +function ExtractSLD(const S: string): string; var - LHost, LUrl, LMime, LBase64: string; - LHttp: THTTPClient; - LResp: IHTTPResponse; - LStream: TMemoryStream; + DotCount, FirstDot: Integer; + I: Integer; +begin + Result := ''; + DotCount := 0; + FirstDot := 0; + for I := 1 to Length(S) do + if S[I] = '.' then + begin + Inc(DotCount); + if FirstDot = 0 then FirstDot := I; + end; + if DotCount < 2 then Exit; // already SLD or no dots + Result := Copy(S, FirstDot + 1, MaxInt); +end; + +function FetchOneIcon(const AUrl: string; + out ABytes: TBytes): Boolean; forward; + +function FetchFaviconDataUri(const AHost: string; + ALog: TFaviconLog = nil): string; + + procedure Trace(const ALine: string); + begin + if Assigned(ALog) then ALog(ALine); + end; + +var + LHost, LSld, LMime, LBase64, LUrl: string; LBytes: TBytes; + LOk: Boolean; begin Result := ''; LHost := NormalizeHost(AHost); - if LHost = '' then Exit; + if LHost = '' then + begin + Trace('reject: "' + AHost + '" not a valid hostname'); + Exit; + end; + // Strategy: prefer DDG (privacy-centralising) but fall back to the + // site's own /favicon.ico for domains DDG doesn't index (self-hosted + // tools, niche services, fresh subdomains, etc.). The user already + // opted into "fetch icons" so the DNS leak to one extra host they + // already visit is an acceptable trade-off for actually getting an icon. + + LSld := ExtractSLD(LHost); + LOk := False; + + // 1) DDG full host. LUrl := Format(ICON_URL_TEMPLATE, [LHost]); + if FetchOneIcon(LUrl, LBytes) then + begin + if Length(LBytes) >= MIN_REAL_ICON_BYTES then + begin + LOk := True; + Trace(Format('OK step1 DDG host: %s (%d bytes)', [LUrl, Length(LBytes)])); + end + else + Trace(Format('skip step1 DDG host: %s only %d bytes (< %d)', + [LUrl, Length(LBytes), MIN_REAL_ICON_BYTES])); + end + else + Trace('fail step1 DDG host: ' + LUrl); + + // 2) DDG SLD (e.g. "deepseek.com" when "chat.deepseek.com" 404s). + if (not LOk) and (LSld <> '') then + begin + var LTry: TBytes; + LUrl := Format(ICON_URL_TEMPLATE, [LSld]); + if FetchOneIcon(LUrl, LTry) then + begin + if Length(LTry) >= MIN_REAL_ICON_BYTES then + begin + LBytes := LTry; LOk := True; + Trace(Format('OK step2 DDG sld: %s (%d bytes)', [LUrl, Length(LTry)])); + end + else + Trace(Format('skip step2 DDG sld: %s only %d bytes', [LUrl, Length(LTry)])); + end + else + Trace('fail step2 DDG sld: ' + LUrl); + end; + + if (not LOk) or (Length(LBytes) = 0) then + begin + Trace('DDG has no icon for ' + LHost + ' — user can upload a custom one'); + Exit; + end; + + LMime := GuessMimeFromBytes(LBytes); + LBase64 := TNetEncoding.Base64.EncodeBytesToString(LBytes); + LBase64 := StringReplace(LBase64, #13, '', [rfReplaceAll]); + LBase64 := StringReplace(LBase64, #10, '', [rfReplaceAll]); + Result := 'data:' + LMime + ';base64,' + LBase64; +end; + +// Low-level HTTP GET. Returns False on any failure (DNS, TLS, non-200, +// oversize). On success ABytes contains the raw image bytes. +// THTTPClient wraps WinHTTP on Windows → native TLS, system cert store, +// zero extra DLLs to ship next to the exe. +function FetchOneIcon(const AUrl: string; out ABytes: TBytes): Boolean; +var + LHttp: THTTPClient; + LResp: IHTTPResponse; + LStream: TMemoryStream; +begin + Result := False; + SetLength(ABytes, 0); - // THTTPClient wraps WinHTTP on Windows → native TLS, system cert store, - // zero extra DLLs to ship next to the exe (unlike Indy + OpenSSL which - // fails silently when libcrypto/libssl are missing). LHttp := THTTPClient.Create; LStream := TMemoryStream.Create; try @@ -125,9 +235,8 @@ begin 'image/png,image/x-icon,image/*;q=0.8,*/*;q=0.1'); try - LResp := LHttp.Get(LUrl, LStream); + LResp := LHttp.Get(AUrl, LStream); except - // Any DNS / connect / TLS failure → silent ''. Exit; end; @@ -136,17 +245,9 @@ begin if LStream.Size > MAX_ICON_BYTES then Exit; LStream.Position := 0; - SetLength(LBytes, LStream.Size); - LStream.ReadBuffer(LBytes[0], LStream.Size); - - LMime := GuessMimeFromBytes(LBytes); - LBase64 := TNetEncoding.Base64.EncodeBytesToString(LBytes); - // Strip CR/LF that the encoder inserts every 76 chars — invalid inside - // an attribute and bloats the cached blob. - LBase64 := StringReplace(LBase64, #13, '', [rfReplaceAll]); - LBase64 := StringReplace(LBase64, #10, '', [rfReplaceAll]); - - Result := 'data:' + LMime + ';base64,' + LBase64; + SetLength(ABytes, LStream.Size); + LStream.ReadBuffer(ABytes[0], LStream.Size); + Result := True; finally LStream.Free; LHttp.Free; diff --git a/delphi-backend/UMainForm.pas b/delphi-backend/UMainForm.pas index 8a62c18..d49b143 100644 --- a/delphi-backend/UMainForm.pas +++ b/delphi-backend/UMainForm.pas @@ -590,7 +590,15 @@ begin var LDataUri: string; begin - LDataUri := PM.Favicon.FetchFaviconDataUri(LHost); + LDataUri := PM.Favicon.FetchFaviconDataUri(LHost, + procedure(const ALine: string) + begin + TThread.Queue(nil, + procedure + begin + LogLine('favicon[' + LHost + ']: ' + ALine); + end); + end); TThread.Queue(nil, procedure begin diff --git a/delphi-backend/assets/assets.res b/delphi-backend/assets/assets.res index 84e249828df873e00c2e552b93a52f8014d98b90..3fb97ff90ce8e7585d933e333ffefdce79e23131 100644 GIT binary patch delta 7047 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 NB6upL?xv>Q{{v%c4cq_# delta 1216 zcmY*ZT}&KR6z)AUyRa*E7g!3rEbRq>g|aL^n%J;5DU#M;`ll_ih7y?WUSxFGS$BpX zfh}u^)dyo6xAD|yj6C?D#-^s2Nwl>MX+$h2whu;QY_t`U)>0BR>=JCevsA6~Fgf=- zKleN5d^6Wq3syfZIQ`{Wc<0KQug*FhD7`0oP+>xDa?`auOzb9E9$8n|4w)%ox|Nn! z`5s^qJ@&$W6HQ7#xX^i85bgTEK{$R{C_HF|gvR%cKF9)5L*u%j9v~ zm?8&oC`Fw3T8cE{#T40%8`9hnPV=~1XP^wvr%5>;j6sf>R)R$<#6#k^cZF=i=nC;# zczrsI9aiw*>;lQg#Xm@HajzOEIO#Wrpa~zE7aqauv%JmrVQ9v8hoPmL{*pLB^&Xa?SXW2eTB#mm zlCIH1s@_9GlEySDX|yb&(#9RlWqzuuW<2fFSd6V`2=7aOp*;zMnfy#jf3fxnodl;tqWJ2)M0$tXx6&pdWM8$pU8E5vlio zS$B>=Pc92|t4f&9#H4HzG;_VioBTQfmpY*Ud)vWgbi4vx1QP}~mrg*U0_P@()#&*Q z2CZnDgx2EB=oCIh76}D22f3S8MF;qD@%TA#8Q)Gq2eF!caisopa2rougkym76~u+x zf`S$6p5+@?r@0iP{em6$NRW%p^+Fe3UFI~LQ&5L*rC=krP4T4+ra3k?#U-AV1}C1n z33fE5xHo!1$j0-)Kjf@MJ~I0Xlz6#7o@BLZltt)(%;nJ+4RZyCDSu1oxdcr%P2HCf zrHt*DK*sO?grM>344m6(-){agcBx@W!BUUld3o9`7}IXCbt}$2FIt^u(f>lKs*NoN o#NYPA`!h!6N8-X! { return; } const cleaned = autofillStripBrowserSuffix(windowTitle); - openEntryModal(); - setTimeout(() => { - const titleField = $('#entryTitle'); - if (titleField) { - titleField.value = cleaned; - $('#entrySite').focus(); - } - }, 0); + openSlideOver(null, { presetTitle: cleaned }); }, // Tell Delphi to simulate keystrokes. Empty username = password only @@ -1710,6 +1703,10 @@ function renderAuthenticatorGrid(grid, entries) { // edit / view re-entry (Tools → Vault health). let healthCache = null; +// 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). +const healthExpanded = { weak: false, reused: false, old: false, pwned: false }; const HEALTH_WEAK_THRESHOLD = 50; // computeStrength score < 50 → weak const HEALTH_OLD_DAYS = 365; // entries not updated in > 1 year @@ -1822,6 +1819,7 @@ async function renderHealthDashboard(grid) { // Four category cards grid.appendChild(renderHealthSection({ + key: 'weak', title: 'Weak passwords', hint: 'Strength score below ' + HEALTH_WEAK_THRESHOLD + '/100 (short / few character classes).', @@ -1831,6 +1829,7 @@ async function renderHealthDashboard(grid) { })); grid.appendChild(renderHealthSection({ + key: 'reused', title: 'Reused passwords', hint: 'Same password used on multiple entries — a single breach affects them all.', items: h.reused, @@ -1842,6 +1841,7 @@ async function renderHealthDashboard(grid) { })); grid.appendChild(renderHealthSection({ + key: 'old', title: 'Old passwords', hint: 'Not updated for more than ' + Math.round(HEALTH_OLD_DAYS / 30) + ' months. Consider rotating periodically for high-value accounts.', @@ -1852,6 +1852,7 @@ async function renderHealthDashboard(grid) { })); grid.appendChild(renderHealthSection({ + key: 'pwned', title: 'Breached passwords (HIBP)', hint: state.hibpEnabled ? 'Found in the Have I Been Pwned database. Change them now.' @@ -1886,25 +1887,51 @@ function renderHealthSection(opts) { const list = el('ul', { class: 'health-list' }); const getId = opts.idOfItem || (it => it.entry.id); - opts.items.slice(0, 20).forEach(it => { - const li = el('li', { class: 'health-item' }); - li.appendChild(el('span', { class: 'health-item-label' }, opts.formatItem(it))); - const fix = el('button', { class: 'btn btn-ghost btn-xs', type: 'button' }, - 'Fix'); - fix.addEventListener('click', ev => { - // Stop bubbling — the document-level "click outside slideover" - // handler would otherwise close the slideover we're about to - // open within the same click event. - ev.stopPropagation(); - openEntryForFix(getId(it)); + const INITIAL_LIMIT = 20; + // Read persisted expand state so a renderGrid() triggered by + // openSlideOver (after clicking "Fix") doesn't snap the list back + // to the collapsed view. + let expanded = !!(opts.key && healthExpanded[opts.key]); + let shown = expanded ? opts.items.length : + Math.min(INITIAL_LIMIT, opts.items.length); + + function renderRows() { + list.innerHTML = ''; + opts.items.slice(0, shown).forEach(it => { + const li = el('li', { class: 'health-item' }); + li.appendChild(el('span', { class: 'health-item-label' }, opts.formatItem(it))); + const fix = el('button', { class: 'btn btn-ghost btn-xs', type: 'button' }, + 'Fix'); + fix.addEventListener('click', ev => { + // Stop bubbling — the document-level "click outside slideover" + // handler would otherwise close the slideover we're about to + // open within the same click event. + ev.stopPropagation(); + openEntryForFix(getId(it)); + }); + li.appendChild(fix); + list.appendChild(li); }); - li.appendChild(fix); - list.appendChild(li); - }); + } + + renderRows(); card.appendChild(list); - if (opts.items.length > 20) { - card.appendChild(el('p', { class: 'health-more' }, - '+ ' + (opts.items.length - 20) + ' more not shown')); + + if (opts.items.length > INITIAL_LIMIT) { + const more = el('button', { + class: 'btn btn-ghost btn-xs health-more-btn', + type: 'button', + }, expanded ? 'Show less' : ('Show all ' + opts.items.length)); + more.addEventListener('click', ev => { + ev.stopPropagation(); + expanded = !expanded; + shown = expanded ? opts.items.length : INITIAL_LIMIT; + more.textContent = expanded ? 'Show less' + : 'Show all ' + opts.items.length; + if (opts.key) healthExpanded[opts.key] = expanded; + renderRows(); + }); + card.appendChild(more); } return card; } @@ -2704,49 +2731,74 @@ function renderBatchBar() { // Edit-in-place state for the slide-over let soState = null; -async function openSlideOver(id) { - const e = state.entries.find(x => x.id === id); - if (!e) return; - state.selectedId = id; +// Open the slideover for either an existing entry (id = number) or a +// brand-new one (id = null). opts: { presetTitle, presetSite } pre-fill +// the corresponding fields for the new-entry path (Ctrl+Shift+A hotkey). +async function openSlideOver(id, opts) { + opts = opts || {}; + const isNew = (id == null); + const e = isNew ? null : state.entries.find(x => x.id === id); + if (!isNew && !e) return; - $('#slideoverTitle').textContent = entryDisplayName(e); + state.selectedId = isNew ? null : id; + // Title with a clear "what mode am I in?" prefix. Plain text so the + // existing .slideover-header h3 ellipsis / overflow rules still work. + const titleEl = $('#slideoverTitle'); + titleEl.textContent = isNew + ? '+ New entry' + : 'Edit · ' + entryDisplayName(e); + titleEl.classList.toggle('is-new-mode', isNew); + titleEl.classList.toggle('is-edit-mode', !isNew); const body = $('#slideoverBody'); body.innerHTML = ''; - const plain = await decryptPwd(e.encrypted_password, e.iv); - - // Decrypt TOTP secret if present. Empty string when no TOTP configured - // OR when decryption fails (orphan ciphertext, key mismatch, etc.) — the - // UI treats both cases as "no 2FA", so the user can re-paste a secret to - // recover. + let plain = ''; let plainTotp = ''; - if (e.totp_secret && e.totp_iv) { - plainTotp = await decryptTotpSecret(e.totp_secret, e.totp_iv); - if (plainTotp === '[ERROR]') plainTotp = ''; + if (!isNew) { + plain = await decryptPwd(e.encrypted_password, e.iv); + // Decrypt TOTP secret if present. Empty string when no TOTP configured + // OR when decryption fails (orphan ciphertext, key mismatch, etc.). + if (e.totp_secret && e.totp_iv) { + plainTotp = await decryptTotpSecret(e.totp_secret, e.totp_iv); + if (plainTotp === '[ERROR]') plainTotp = ''; + } } - // Track original values so we can detect "dirty" + // Track original values so we can detect "dirty". For new entries the + // originals are empty strings — typing anything triggers the Save button. + const defaultFolder = state.view.startsWith('folder:') + ? state.view.slice(7) : 'All'; soState = { - id: e.id, + id: isNew ? null : e.id, original: { - site: e.site, title: e.title || '', - username: e.username || '', password: plain, - folder: e.folder || 'All', tags: parseTags(e.tags).join(','), - totp: plainTotp, + site: isNew ? (opts.presetSite || '') : e.site, + title: isNew ? (opts.presetTitle || '') : (e.title || ''), + username: isNew ? '' : (e.username || ''), + password: plain, + folder: isNew ? defaultFolder : (e.folder || 'All'), + tags: isNew ? '' : parseTags(e.tags).join(','), + totp: plainTotp, }, - tags: parseTags(e.tags), - originalEncrypted: e.encrypted_password, - originalIV: e.iv, - originalTotpEncrypted: e.totp_secret, - originalTotpIV: e.totp_iv, + tags: isNew ? [] : parseTags(e.tags), + originalEncrypted: isNew ? null : e.encrypted_password, + originalIV: isNew ? null : e.iv, + originalTotpEncrypted: isNew ? null : e.totp_secret, + originalTotpIV: isNew ? null : e.totp_iv, }; - body.appendChild(soEditableField('Display name', 'soTitle', e.title || '')); - body.appendChild(soEditableField('Site', 'soSite', e.site)); - body.appendChild(soEditableField('Username', 'soUsername', e.username || '')); + // Icon field needs SOMETHING to compute initials/fallback. For new + // entries we pass a synthetic placeholder. + const eForIcon = isNew + ? { id: null, icon_b64: null, site: soState.original.site, + title: soState.original.title } + : e; + body.appendChild(soIconField(eForIcon)); + body.appendChild(soEditableField('Display name', 'soTitle', soState.original.title)); + body.appendChild(soEditableField('Site', 'soSite', soState.original.site)); + body.appendChild(soEditableField('Username', 'soUsername', soState.original.username)); body.appendChild(soPasswordField(plain)); body.appendChild(soTotpField(plainTotp)); - body.appendChild(soFolderField(e.folder || 'All')); + body.appendChild(soFolderField(soState.original.folder)); body.appendChild(soTagsField()); // Action row — Save button is hidden until dirty. No Delete here: @@ -2768,6 +2820,18 @@ async function openSlideOver(id) { }); $('#slideover').classList.add('is-open'); + + // New entries: Save visible from the start so the action is obvious, + // and auto-focus the Site field (most important pivot field). + if (isNew) { + const save = document.getElementById('soSaveBtn'); + if (save) save.style.display = ''; + setTimeout(() => { + const f = document.getElementById('soSite'); + if (f) f.focus(); + }, 50); + } + renderGrid(); } @@ -2780,6 +2844,85 @@ function soEditableField(label, id, value) { return wrap; } +// Custom icon: lets the user upload an image when auto-fetch fails (private +// sites behind auth, self-hosted apps DDG doesn't index, etc.). Stored as +// base64 data URI via the same POST /entries/{id}/icon endpoint as the +// auto-fetched icons — the JS render path doesn't care which source it +// came from. +const ICON_MAX_BYTES = 64 * 1024; // matches server-side cap + +function soIconField(entry) { + const wrap = el('div', { class: 'slideover-field so-icon-field' }); + const preview = el('div', { class: 'so-icon-preview' }); + + function paintPreview(dataUriOrNull) { + preview.innerHTML = ''; + if (dataUriOrNull) { + const img = el('img', { src: dataUriOrNull, alt: '' }); + img.addEventListener('error', () => { + preview.innerHTML = ''; + preview.textContent = initials(entryDisplayName(entry)); + }); + preview.appendChild(img); + } else { + preview.textContent = initials(entryDisplayName(entry)); + } + } + paintPreview(entry.icon_b64 || null); + + const actions = el('div', { class: 'so-icon-actions' }); + + const uploadBtn = el('button', { class: 'btn btn-ghost btn-sm', type: 'button' }, + 'Upload icon'); + const fileInput = el('input', { + type: 'file', + accept: 'image/png,image/jpeg,image/gif,image/svg+xml,image/x-icon,image/webp', + style: 'display:none', + }); + uploadBtn.addEventListener('click', () => fileInput.click()); + fileInput.addEventListener('change', () => { + const f = fileInput.files && fileInput.files[0]; + if (!f) return; + if (!/^image\//.test(f.type)) { + toast('Not an image file', 'error'); + return; + } + if (f.size > ICON_MAX_BYTES) { + toast('Icon too large (max 64 KB)', 'error'); + return; + } + const reader = new FileReader(); + reader.onload = async ev => { + const dataUri = ev.target.result; // 'data:image/...;base64,...' + entry.icon_b64 = dataUri; + paintPreview(dataUri); + await saveEntryIcon(entry.id, dataUri); + // Refresh entry cards / health view so they show the new icon too. + render(); + toast('Custom icon set'); + }; + reader.readAsDataURL(f); + }); + + const resetBtn = el('button', { class: 'btn btn-ghost btn-sm', type: 'button' }, + 'Remove'); + resetBtn.addEventListener('click', async () => { + entry.icon_b64 = null; + paintPreview(null); + await saveEntryIcon(entry.id, ''); + render(); + toast('Icon removed'); + }); + + actions.appendChild(uploadBtn); + actions.appendChild(resetBtn); + actions.appendChild(fileInput); + + wrap.appendChild(preview); + wrap.appendChild(actions); + return wrap; +} + function soOnEnterSave(ev) { if (ev.key !== 'Enter') return; ev.preventDefault(); @@ -3035,6 +3178,13 @@ function renderSoChips() { function soDirtyCheck() { if (!soState) return; + // New entries: Save button stays visible regardless of dirty state so + // the action is always obvious. + if (soState.id == null) { + const btn = $('#soSaveBtn'); + if (btn) btn.style.display = ''; + return; + } const cur = { title: ($('#soTitle') || {}).value || '', site: ($('#soSite') || {}).value || '', @@ -3073,9 +3223,12 @@ async function soSave() { const totp = (($('#soTotpSecret') || {}).value || '').trim(); if (!site || !pwd) return toast('Site and password required', 'error'); - // Only re-encrypt if password changed; otherwise reuse stored ciphertext + const isNew = (soState.id == null); + + // Only re-encrypt if password changed; otherwise reuse stored ciphertext. + // For new entries there's nothing stored, always encrypt fresh. let enc; - if (pwd === soState.original.password) { + if (!isNew && pwd === soState.original.password) { enc = { encrypted: soState.originalEncrypted, iv: soState.originalIV }; } else { enc = await encryptPwd(pwd); @@ -3086,7 +3239,7 @@ async function soSave() { let totpEnc = ''; let totpIv = ''; if (totp !== '') { - if (totp === soState.original.totp && soState.originalTotpEncrypted) { + if (!isNew && totp === soState.original.totp && soState.originalTotpEncrypted) { totpEnc = soState.originalTotpEncrypted; totpIv = soState.originalTotpIV; } else { @@ -3101,24 +3254,45 @@ async function soSave() { } } + const body = JSON.stringify({ + site, title: title.trim(), username: user, + encrypted_password: enc.encrypted, iv: enc.iv, + totp_secret: totpEnc, totp_iv: totpIv, + folder: fold, tags: soState.tags.join(','), + }); + try { - await api('/entries/' + soState.id, { - method: 'PUT', - headers: authHeaders({ 'Content-Type': 'application/json' }), - body: JSON.stringify({ - site, title: title.trim(), username: user, - encrypted_password: enc.encrypted, iv: enc.iv, - totp_secret: totpEnc, totp_iv: totpIv, - folder: fold, tags: soState.tags.join(','), - }), - }); - toast('Saved'); + let targetId = soState.id; + if (isNew) { + const r = await api('/entries', { + method: 'POST', + headers: authHeaders({ 'Content-Type': 'application/json' }), + body, + }); + if (r && typeof r.id === 'number') targetId = r.id; + toast('Saved'); + } else { + await api('/entries/' + soState.id, { + method: 'PUT', + headers: authHeaders({ 'Content-Type': 'application/json' }), + body, + }); + toast('Saved'); + } await loadEntries(); - // Re-open with updated data - const updated = state.entries.find(x => x.id === soState.id); + // Invalidate the vault-health cache so the new/updated entry + // is reflected the next time the dashboard renders. + if (typeof healthCache !== 'undefined') healthCache = null; + + const updated = state.entries.find(x => x.id === targetId); if (updated) openSlideOver(updated.id); else closeSlideOver(); render(); + if (isNew && targetId) { + flashEntry(targetId); + // Auto-fetch favicon for the new entry if the user opted in. + if (state.faviconsEnabled && updated) ensureEntryFavicon(updated); + } } catch (err) { toast(err.message, 'error'); } } @@ -3675,7 +3849,7 @@ function closePalette() { $('#cmdPalette').classList.add('is-hidden'); } function paletteCommands() { return [ - { id: 'new', label: 'New entry', icon: 'i-plus', run: () => { closePalette(); openEntryModal(); } }, + { id: 'new', label: 'New entry', icon: 'i-plus', run: () => { closePalette(); openSlideOver(null); } }, { id: 'lock', label: 'Lock vault', icon: 'i-lock', run: () => { closePalette(); lockVault(); } }, { id: 'logout', label: 'Sign out', icon: 'i-log-out', run: () => { closePalette(); doLogout(); } }, { id: 'theme', label: 'Toggle theme', icon: 'i-sun', run: () => { closePalette(); toggleTheme(); } }, @@ -5633,7 +5807,13 @@ async function init() { toggleTheme(); saveServerSettings(); }); - $('#newEntryBtn').addEventListener('click', () => openEntryModal()); + $('#newEntryBtn').addEventListener('click', ev => { + // Stop bubbling — the document-level "click outside slideover" + // handler would otherwise close the panel we just opened in the + // same click event (same fix as the health dashboard Fix button). + ev.stopPropagation(); + openSlideOver(null); + }); $('#userChip').addEventListener('click', () => $('#userDropdown').classList.toggle('is-hidden')); $('#lockBtn').addEventListener('click', lockVault); $('#dropdownSettingsBtn').addEventListener('click', () => {