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:
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user