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 84e2498..3fb97ff 100644 Binary files a/delphi-backend/assets/assets.res and b/delphi-backend/assets/assets.res differ diff --git a/js/app.js b/js/app.js index ebfe93f..a2a1dcf 100644 --- a/js/app.js +++ b/js/app.js @@ -84,14 +84,7 @@ const Bridge = (() => { 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', () => {