feat: unified entry slideover + custom icons + UX fixes

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 · <name>".

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 00:15:20 +01:00
parent ad5fb21a18
commit 2ef636ce30
8 changed files with 450 additions and 107 deletions
+5 -3
View File
@@ -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/<host>.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
+40 -1
View File
@@ -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 {
+8 -1
View File
@@ -168,6 +168,12 @@
</PropertyGroup>
<PropertyGroup Condition="'$(Cfg_1_Win32)'!=''">
<AppDPIAwarenessMode>PerMonitorV2</AppDPIAwarenessMode>
<VerInfo_IncludeVerInfo>true</VerInfo_IncludeVerInfo>
<VerInfo_Locale>1033</VerInfo_Locale>
<VerInfo_Keys>CompanyName=;FileDescription=$(MSBuildProjectName);FileVersion=1.0.0.0;InternalName=;LegalCopyright=;LegalTrademarks=;OriginalFilename=;ProductName=$(MSBuildProjectName);ProductVersion=1.0.0.0;Comments=;ProgramID=com.embarcadero.$(MSBuildProjectName)</VerInfo_Keys>
<Icon_MainIcon>app.ico</Icon_MainIcon>
<UWP_DelphiLogo44>app.png</UWP_DelphiLogo44>
<UWP_DelphiLogo150>app.png</UWP_DelphiLogo150>
</PropertyGroup>
<PropertyGroup Condition="'$(Cfg_2)'!=''">
<DCC_Define>DEBUG;$(DCC_Define)</DCC_Define>
@@ -203,6 +209,7 @@ $(PreBuildEvent)]]></PreBuildEvent>
<DelphiCompile Include="$(MainSource)">
<MainSource>MainSource</MainSource>
</DelphiCompile>
<DCCReference Include="Source\PM.SingleInstance.pas"/>
<DCCReference Include="UMainForm.pas">
<Form>MainForm</Form>
</DCCReference>
@@ -219,9 +226,9 @@ $(PreBuildEvent)]]></PreBuildEvent>
<DCCReference Include="Source\PM.Bridge.pas"/>
<DCCReference Include="Source\PM.QuickUnlock.pas"/>
<DCCReference Include="Source\PM.UserPrefs.pas"/>
<DCCReference Include="Source\PM.SingleInstance.pas"/>
<DCCReference Include="Source\PM.AutoStart.pas"/>
<DCCReference Include="Source\PM.Favicon.pas"/>
<DCCReference Include="Source\PM.ProcessLockdown.pas"/>
<DCCReference Include="Handlers\PM.Handler.Ping.pas"/>
<DCCReference Include="Handlers\PM.Handler.Auth.pas"/>
<DCCReference Include="Handlers\PM.Handler.Folders.pas"/>
+9 -3
View File
@@ -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;
+127 -26
View File
@@ -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 <img src="..."> 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;
+9 -1
View File
@@ -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
Binary file not shown.
+252 -72
View File
@@ -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', () => {