);
+var
+ LUserId, LDays, LPurged: Integer;
+ LQ: TFDQuery;
+ LObj: TJSONObject;
+begin
+ try
+ LUserId := Authenticate(ARequest, AResponse);
+ RequireCSRF(ARequest, AResponse, LUserId);
+ except
+ on ESessionRejected do Exit;
+ end;
+
+ LDays := StrToIntDef(GetQueryParam(ARequest, 'days', '0'), 0);
+ if (LDays <= 0) or (LDays > 3650) then
+ begin
+ TJSONHelper.SendError(AResponse, 400, 'Invalid days');
+ Exit;
+ end;
+
+ DB.Lock;
+ try
+ LQ := TFDQuery.Create(nil);
+ try
+ LQ.Connection := DB.Connection;
+ LQ.SQL.Text :=
+ 'DELETE FROM vault_entries ' +
+ 'WHERE user_id = :uid AND deleted = 1 ' +
+ ' AND deleted_at IS NOT NULL ' +
+ ' AND (julianday(''now'') - julianday(deleted_at)) >= :d';
+ LQ.ParamByName('uid').AsInteger := LUserId;
+ LQ.ParamByName('d').AsInteger := LDays;
+ LQ.ExecSQL;
+ LPurged := LQ.RowsAffected;
+ finally
+ LQ.Free;
+ end;
+ finally
+ DB.Unlock;
+ end;
+
+ if LPurged > 0 then
+ LogAudit(LUserId, Format('auto_purge_trash %d entries (> %d days)',
+ [LPurged, LDays]), GetClientIP(ARequest));
+ LObj := TJSONObject.Create;
+ LObj.AddPair('purged', TJSONNumber.Create(LPurged));
+ TJSONHelper.SendJSON(AResponse, LObj);
+end;
+
// ===== DELETE /entries/trash/empty ===========================================
procedure HandleEmptyTrash(ARequest: TIdHTTPRequestInfo;
@@ -771,11 +963,13 @@ initialization
// /entries/trash/empty must be registered BEFORE /entries/{id} to win the regex match.
// Same logic for /entries/bulk-import — register before the catch-all /entries/{id}.
Router.Register('DELETE', '/entries/trash/empty', HandleEmptyTrash);
+ Router.Register('DELETE', '/entries/trash/old', HandleAutoPurgeTrash);
Router.Register('DELETE', '/entries/icons/all', HandleClearAllIcons);
Router.Register('POST', '/entries/bulk-import', HandleBulkImport);
Router.Register('POST', '/entries/(\d+)/restore', HandleRestoreEntry);
Router.Register('POST', '/entries/(\d+)/favorite', HandleToggleFavorite);
Router.Register('POST', '/entries/(\d+)/icon', HandleSetEntryIcon);
+ Router.Register('GET', '/entries/(\d+)/history', HandleGetEntryHistory);
Router.Register('GET', '/entries/count', HandleEntriesCount);
Router.Register('GET', '/entries', HandleGetEntries);
Router.Register('POST', '/entries', HandleCreateEntry);
diff --git a/delphi-backend/Source/PM.Database.pas b/delphi-backend/Source/PM.Database.pas
index e7787f8..3ade9d7 100644
--- a/delphi-backend/Source/PM.Database.pas
+++ b/delphi-backend/Source/PM.Database.pas
@@ -189,6 +189,25 @@ begin
' created_at DATETIME DEFAULT CURRENT_TIMESTAMP,' +
' FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE' +
')');
+ // Password history — keeps the last N versions of each entry's
+ // encrypted_password + iv. Populated by HandleUpdateEntry before each
+ // PUT overwrites the row; pruned to 20 entries per row after each insert.
+ // kind mirrors vault_entries.kind so notes can be restored too.
+ FConn.ExecSQL(
+ 'CREATE TABLE IF NOT EXISTS entries_password_history (' +
+ ' id INTEGER PRIMARY KEY AUTOINCREMENT,' +
+ ' entry_id INTEGER NOT NULL,' +
+ ' user_id INTEGER NOT NULL,' +
+ ' encrypted_password TEXT NOT NULL,' +
+ ' iv TEXT NOT NULL,' +
+ ' kind TEXT NOT NULL DEFAULT ''login'',' +
+ ' changed_at DATETIME DEFAULT CURRENT_TIMESTAMP,' +
+ ' FOREIGN KEY (entry_id) REFERENCES vault_entries(id) ON DELETE CASCADE,' +
+ ' FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE' +
+ ')');
+ FConn.ExecSQL(
+ 'CREATE INDEX IF NOT EXISTS idx_history_entry ' +
+ ' ON entries_password_history(entry_id, changed_at DESC)');
end;
function TPMDatabase.ColumnExists(const ATable, AColumn: string): Boolean;
@@ -241,6 +260,17 @@ begin
// sees the plaintext secret. NULL = no TOTP configured for this entry.
AddColumnIfMissing('vault_entries', 'totp_secret', 'TEXT');
AddColumnIfMissing('vault_entries', 'totp_iv', 'TEXT');
+ // Entry kind: 'login' (default — site/user/encrypted_password/iv/totp)
+ // or 'note' (free-text secure note — body stored in encrypted_password
+ // + iv, site/username/totp_* unused). Legacy rows default to 'login'.
+ AddColumnIfMissing('vault_entries', 'kind', 'TEXT DEFAULT ''login''');
+ // Custom fields: opaque encrypted JSON array of
+ // [{label, value, is_secret}, ...]
+ // Same crypto pipeline as encrypted_password (AES-GCM with the vault
+ // key). NULL = no custom fields configured. The server treats both
+ // columns as opaque ciphertext + IV.
+ AddColumnIfMissing('vault_entries', 'custom_fields', 'TEXT');
+ AddColumnIfMissing('vault_entries', 'custom_fields_iv', 'TEXT');
// Cached favicon as a base64 data URI (e.g. "data:image/png;base64,...").
// Fetched on demand by the Delphi favicon proxy when the user opts in.
// NULL = no icon cached → JS falls back to the first-letter avatar.
diff --git a/delphi-backend/Source/PM.Favicon.pas b/delphi-backend/Source/PM.Favicon.pas
index 06614e4..f1f3467 100644
--- a/delphi-backend/Source/PM.Favicon.pas
+++ b/delphi-backend/Source/PM.Favicon.pas
@@ -39,7 +39,8 @@ uses
const
ICON_URL_TEMPLATE = 'https://icons.duckduckgo.com/ip3/%s.ico';
- MAX_ICON_BYTES = 65536; // 64 KB cap (matches handler's SetEntryIcon limit)
+ MAX_ICON_BYTES = 262144; // 256 KB cap (DDG sometimes serves full-res
+ // assets; matches handler + JS upload limits)
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.
@@ -153,48 +154,51 @@ begin
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.
+ // Strategy: prefer the SLD (brand domain) when the host has a subdomain,
+ // because DDG often returns a generic placeholder for chat.X.com / app.X.com
+ // / etc. (passes our byte threshold but looks wrong) while having the real
+ // brand icon under X.com. For bare 2-label hosts we go straight to step 2.
LSld := ExtractSLD(LHost);
LOk := False;
- // 1) DDG full host.
- LUrl := Format(ICON_URL_TEMPLATE, [LHost]);
- if FetchOneIcon(LUrl, LBytes) then
+ // 1) DDG SLD first when host has a subdomain (e.g. chat.deepseek.com →
+ // try deepseek.com.ico first). Skipped for bare hosts.
+ if LSld <> '' then
begin
- if Length(LBytes) >= MIN_REAL_ICON_BYTES then
+ LUrl := Format(ICON_URL_TEMPLATE, [LSld]);
+ if FetchOneIcon(LUrl, LBytes) then
begin
- LOk := True;
- Trace(Format('OK step1 DDG host: %s (%d bytes)', [LUrl, Length(LBytes)]));
+ if Length(LBytes) >= MIN_REAL_ICON_BYTES then
+ begin
+ LOk := True;
+ Trace(Format('OK step1 DDG sld: %s (%d bytes)', [LUrl, Length(LBytes)]));
+ end
+ else
+ Trace(Format('skip step1 DDG sld: %s only %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);
+ Trace('fail step1 DDG sld: ' + LUrl);
+ end;
- // 2) DDG SLD (e.g. "deepseek.com" when "chat.deepseek.com" 404s).
- if (not LOk) and (LSld <> '') then
+ // 2) DDG full host as fallback (covers brands whose subdomain has its own
+ // distinct icon, OR plain hosts like github.com that have no SLD step).
+ if not LOk then
begin
var LTry: TBytes;
- LUrl := Format(ICON_URL_TEMPLATE, [LSld]);
+ LUrl := Format(ICON_URL_TEMPLATE, [LHost]);
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)]));
+ Trace(Format('OK step2 DDG host: %s (%d bytes)', [LUrl, Length(LTry)]));
end
else
- Trace(Format('skip step2 DDG sld: %s only %d bytes', [LUrl, Length(LTry)]));
+ Trace(Format('skip step2 DDG host: %s only %d bytes', [LUrl, Length(LTry)]));
end
else
- Trace('fail step2 DDG sld: ' + LUrl);
+ Trace('fail step2 DDG host: ' + LUrl);
end;
if (not LOk) or (Length(LBytes) = 0) then
diff --git a/delphi-backend/UMainForm.pas b/delphi-backend/UMainForm.pas
index 5cbb906..84f4f19 100644
--- a/delphi-backend/UMainForm.pas
+++ b/delphi-backend/UMainForm.pas
@@ -16,7 +16,7 @@ interface
uses
System.SysUtils, System.Classes, System.UITypes, System.NetEncoding,
System.StrUtils, System.Generics.Collections,
- Winapi.Windows,
+ Winapi.Windows, Winapi.ShellAPI,
FMX.Forms, FMX.Controls, FMX.Controls.Presentation, FMX.StdCtrls,
FMX.Memo, FMX.Memo.Types, FMX.ScrollBox, FMX.Edit, FMX.Layouts, FMX.Types,
FMX.Dialogs, FMX.DialogService,
@@ -742,6 +742,22 @@ begin
else if ACmd = 'app/theme' then
FBridge.ApplyTitleBarTheme(GetParam('mode') = 'dark')
+ // Open the entry's site in the user's default browser. We restrict the
+ // scheme to http(s) so JS can't smuggle a file:// or other handler that
+ // would invoke arbitrary Windows applications.
+ else if ACmd = 'app/open-url' then
+ begin
+ var LUrl := GetParam('url');
+ if (LUrl <> '') and
+ (LUrl.ToLower.StartsWith('http://') or LUrl.ToLower.StartsWith('https://')) then
+ begin
+ ShellExecute(0, 'open', PChar(LUrl), nil, nil, 1); // SW_SHOWNORMAL = 1
+ LogLine('Opened URL: ' + LUrl);
+ end
+ else
+ LogLine('Refused to open non-http(s) URL: ' + LUrl);
+ end
+
// ---- Device-bound prefs (DPAPI key/value) ----------------------------
// Used for prefs that must survive the ephemeral-port reset of the
// WebView2 localStorage (rememberedUsername, etc.).
diff --git a/delphi-backend/assets/assets.res b/delphi-backend/assets/assets.res
index caddabf..472f7e4 100644
Binary files a/delphi-backend/assets/assets.res and b/delphi-backend/assets/assets.res differ
diff --git a/index.html b/index.html
index cefef98..524429b 100644
--- a/index.html
+++ b/index.html
@@ -168,6 +168,11 @@
Favorites
0
+
+
-
+
+
+
+
+
+
+
+ Auto-purge trash after
+
+ Permanently delete entries that have been in
+ the trash for longer than this. Runs at every
+ unlock.
+
+
+
+
Check passwords against breach database (HIBP)
@@ -723,6 +763,42 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/js/app.js b/js/app.js
index c1d6e38..831a262 100644
--- a/js/app.js
+++ b/js/app.js
@@ -281,6 +281,14 @@ const Bridge = (() => {
if (!active) return;
cmd('cmd://tray/notifications?enabled=' + (enabled ? '1' : '0'));
},
+
+ // Open an http(s) URL in the user's default browser via ShellExecute.
+ // Delphi validates the scheme so a malformed entry can't smuggle a
+ // file:// or custom handler.
+ openUrl(url) {
+ if (!active) return;
+ cmd('cmd://app/open-url?url=' + encodeURIComponent(url));
+ },
};
})();
@@ -350,6 +358,9 @@ const state = {
// Show the "running in tray" balloon (and any future tray balloon).
// Default ON — gates Shell_NotifyIcon NIF_INFO calls in PM.Bridge.
trayNotificationsEnabled: localStorage.getItem('trayNotificationsEnabled') !== '0',
+ // Days after which trashed entries are permanently purged. 0 = never.
+ // Synced across devices because it's a user-level preference.
+ trashAutoPurgeDays: parseInt(localStorage.getItem('trashAutoPurgeDays') || '0') || 0,
};
// ============================================================
@@ -576,6 +587,33 @@ async function decryptTotpSecret(encB64, ivB64) {
return await decryptPwd(encB64, ivB64);
}
+// ---- Custom fields (per-entry encrypted JSON array) ----------------
+//
+// Stored as:
+// vault_entries.custom_fields = base64 AES-GCM ciphertext of JSON
+// vault_entries.custom_fields_iv = base64 12-byte IV
+// Plaintext shape:
+// [{ "label": "PIN", "value": "1234", "is_secret": true }, ...]
+//
+// Same crypto pipeline as encrypted_password (reuses encryptPwd /
+// decryptPwd over the JSON string) so the master-pw rotation logic
+// works without any special-casing — it just sees one more ciphertext
+// blob per entry to re-encrypt.
+async function encryptCustomFields(fieldsArray) {
+ if (!Array.isArray(fieldsArray) || fieldsArray.length === 0)
+ return { encrypted: '', iv: '' };
+ return await encryptPwd(JSON.stringify(fieldsArray));
+}
+async function decryptCustomFields(encB64, ivB64) {
+ if (!encB64 || !ivB64) return [];
+ const plain = await decryptPwd(encB64, ivB64);
+ if (plain === '[ERROR]' || !plain) return [];
+ try {
+ const arr = JSON.parse(plain);
+ return Array.isArray(arr) ? arr : [];
+ } catch (e) { return []; }
+}
+
// ============================================================
// FAVICONS (opt-in, cached server-side as base64 data URI)
// ============================================================
@@ -810,6 +848,191 @@ async function quickSearchPickEntry(entry, copyUsername) {
closeQuickSearchModal();
}
+// ============================================================
+// CHEATSHEET — press '?' anywhere to see all hotkeys
+// ============================================================
+//
+// Discovery aid. Built dynamically so adding a new hotkey only requires
+// extending CHEATSHEET_GROUPS — the overlay picks it up automatically.
+
+const CHEATSHEET_GROUPS = [
+ {
+ title: 'Inside the app',
+ items: [
+ { keys: ['Ctrl', 'K'], desc: 'Command palette / quick search' },
+ { keys: ['?'], desc: 'Show this cheatsheet' },
+ { keys: ['Esc'], desc: 'Close modal / panel / cheatsheet' },
+ { keys: ['Enter'], desc: 'Open / confirm / submit' },
+ ],
+ },
+ {
+ title: 'Global (Windows-only, works even when minimised)',
+ items: [
+ { keys: ['Ctrl', 'Shift', 'L'], desc: 'Autofill username + password into the active window' },
+ { keys: ['Ctrl', 'Shift', 'P'], desc: 'Autofill password only (step-2 forms, unlock screens)' },
+ { keys: ['Ctrl', 'Shift', 'Q'], desc: 'Quick search → SendInput password into the active window' },
+ { keys: ['Ctrl', 'Shift', 'A'], desc: 'Quick-add a new entry pre-filled with the foreground window title' },
+ ],
+ },
+ {
+ title: 'Tray',
+ items: [
+ { keys: ['Right-click tray'], desc: 'Open / Quick search… / Lock vault / Quit' },
+ { keys: ['Click tray'], desc: 'Restore window' },
+ ],
+ },
+ {
+ title: 'On each card',
+ items: [
+ { keys: [{ icon: 'i-globe' }], desc: 'Open the site in your default browser' },
+ { keys: [{ icon: 'i-copy' }], desc: 'Copy password to the secure clipboard (auto-clears in 30s)' },
+ { keys: ['Click card'], desc: 'Open the entry details / edit panel' },
+ ],
+ },
+];
+
+function renderCheatsheet() {
+ const body = document.getElementById('cheatsheetBody');
+ body.innerHTML = '';
+ CHEATSHEET_GROUPS.forEach(group => {
+ const section = el('section', { class: 'cheatsheet-group' });
+ section.appendChild(el('h4', null, group.title));
+ const list = el('div', { class: 'cheatsheet-list' });
+ group.items.forEach(item => {
+ const row = el('div', { class: 'cheatsheet-row' });
+ const kc = el('div', { class: 'cheatsheet-keys' });
+ item.keys.forEach((k, i) => {
+ if (i > 0) kc.appendChild(el('span', { class: 'cheatsheet-plus' }, '+'));
+ if (k && typeof k === 'object' && k.icon) {
+ // SVG icon — wrap in kbd-shaped chip for visual consistency
+ // with the text key chips next to it.
+ const chip = el('span', { class: 'cheatsheet-icon-chip' });
+ chip.appendChild(icon(k.icon));
+ kc.appendChild(chip);
+ } else {
+ kc.appendChild(el('kbd', null, String(k)));
+ }
+ });
+ row.appendChild(kc);
+ row.appendChild(el('div', { class: 'cheatsheet-desc' }, item.desc));
+ list.appendChild(row);
+ });
+ section.appendChild(list);
+ body.appendChild(section);
+ });
+}
+
+// ============================================================
+// PASSWORD HISTORY — open the modal, decrypt previous versions,
+// optionally revert one into the current field.
+// ============================================================
+
+async function openHistoryModal(entryId) {
+ const modal = document.getElementById('historyModal');
+ const body = document.getElementById('historyBody');
+ body.innerHTML = '';
+ body.appendChild(el('div', { class: 'history-loading' }, 'Loading…'));
+ modal.classList.remove('is-hidden');
+
+ let rows;
+ try {
+ rows = await fetch(API + '/entries/' + entryId + '/history', {
+ headers: authHeaders(),
+ }).then(r => r.ok ? r.json() : []);
+ } catch (e) {
+ rows = [];
+ }
+ body.innerHTML = '';
+ if (!rows.length) {
+ body.appendChild(el('p', { class: 'history-empty' },
+ 'No previous versions yet — they accumulate on each save.'));
+ return;
+ }
+
+ // Decrypt each row's stored ciphertext with the CURRENT vault key
+ // (master-pw change wipes the history, so the key always works).
+ const list = el('ul', { class: 'history-list' });
+ for (const row of rows) {
+ const li = el('li', { class: 'history-row' });
+ const meta = el('div', { class: 'history-meta' });
+ meta.appendChild(el('span', { class: 'history-date' },
+ formatDateShort(row.changed_at) + ' · ' + row.changed_at.slice(11, 16)));
+ let plain = '';
+ try {
+ plain = await decryptPwd(row.encrypted_password, row.iv);
+ } catch (_) { plain = '[ERROR]'; }
+ if (plain === '[ERROR]') plain = '';
+
+ const preview = el('div', { class: 'history-preview' });
+ const isNote = (row.kind === 'note');
+ const snippet = isNote
+ ? (plain.replace(/\s+/g, ' ').slice(0, 80) +
+ (plain.length > 80 ? '…' : ''))
+ : '•'.repeat(Math.max(plain.length, 8));
+ const valueSpan = el('span', { class: 'history-value' }, snippet);
+ preview.appendChild(valueSpan);
+ let revealed = false;
+ if (!isNote) {
+ const eye = el('button', { class: 'icon-btn icon-btn-sm', type: 'button',
+ title: 'Show / hide' });
+ eye.appendChild(icon('i-eye'));
+ eye.addEventListener('click', () => {
+ revealed = !revealed;
+ valueSpan.textContent = revealed ? plain
+ : '•'.repeat(Math.max(plain.length, 8));
+ });
+ preview.appendChild(eye);
+ }
+ const copy = el('button', { class: 'icon-btn icon-btn-sm', type: 'button',
+ title: 'Copy' });
+ copy.appendChild(icon('i-copy'));
+ copy.addEventListener('click', () => {
+ if (Bridge.active) Bridge.copySecure(plain, 30000);
+ else { try { navigator.clipboard.writeText(plain); } catch (_) {} }
+ toast('Copied · clears in 30s');
+ });
+ preview.appendChild(copy);
+
+ const revert = el('button', { class: 'btn btn-ghost btn-xs', type: 'button' });
+ revert.appendChild(icon('i-rotate-ccw'));
+ revert.appendChild(document.createTextNode(' Revert'));
+ revert.addEventListener('click', () => {
+ const target = isNote
+ ? document.getElementById('soNoteBody')
+ : document.getElementById('soPassword');
+ if (target) {
+ target.value = plain;
+ target.dispatchEvent(new Event('input', { bubbles: true }));
+ soDirtyCheck();
+ toast('Restored — click Save to commit', 'warning');
+ }
+ closeHistoryModal();
+ });
+
+ const actions = el('div', { class: 'history-actions' });
+ actions.appendChild(revert);
+
+ li.appendChild(meta);
+ li.appendChild(preview);
+ li.appendChild(actions);
+ list.appendChild(li);
+ }
+ body.appendChild(list);
+}
+
+function closeHistoryModal() {
+ document.getElementById('historyModal').classList.add('is-hidden');
+}
+
+function openCheatsheet() {
+ renderCheatsheet();
+ document.getElementById('cheatsheetModal').classList.remove('is-hidden');
+}
+
+function closeCheatsheet() {
+ document.getElementById('cheatsheetModal').classList.add('is-hidden');
+}
+
function openQuickSearchModal(hideAfter, forFill) {
const modal = document.getElementById('quickSearchModal');
const input = document.getElementById('quickSearchInput');
@@ -1513,6 +1736,7 @@ function filteredEntries() {
} else {
list = state.entries;
if (state.view === 'favorites') list = list.filter(e => e.favorite);
+ else if (state.view === 'notes') list = list.filter(e => e.kind === 'note');
else if (state.view.startsWith('folder:')) {
const f = state.view.slice(7);
// 'folder:All' is now the "(no folder)" pseudo-entry → filter
@@ -1555,6 +1779,7 @@ function allTags() {
function viewTitle() {
if (state.view === 'all') return 'All items';
if (state.view === 'favorites') return 'Favorites';
+ if (state.view === 'notes') return 'Notes';
if (state.view === 'trash') return 'Trash';
if (state.view === 'authenticator') return 'Authenticator';
if (state.view === 'health') return 'Vault health';
@@ -1576,6 +1801,9 @@ function renderSidebar() {
// counts
$('#countAll').textContent = state.entries.length;
$('#countFav').textContent = state.entries.filter(e => e.favorite).length;
+ const noteCount = state.entries.filter(e => e.kind === 'note').length;
+ const countNotes = document.getElementById('countNotes');
+ if (countNotes) countNotes.textContent = noteCount || '';
// Prefer the server-side count (always up-to-date even if user never
// navigated to Trash this session) ; fall back to local array length.
const trashN = state.trashedCount || state.trashed.length || 0;
@@ -1935,7 +2163,11 @@ function entryAgeDays(e) {
async function computeHealthCache() {
const weak = [], old = [], pwned = [];
const byPwd = new Map(); // plaintext → [entries]
+ // Notes have no password to weigh — their encrypted_password is just
+ // the free-text body. Skipping them avoids polluting the "weak / reused"
+ // categories with note content.
for (const e of state.entries) {
+ if ((e.kind || 'login') !== 'login') continue;
const ageD = entryAgeDays(e);
if (ageD > HEALTH_OLD_DAYS) old.push({ entry: e, ageDays: ageD });
@@ -2216,6 +2448,35 @@ function entryDisplayName(e) {
return t || e.site || '';
}
+// Decide if entry.site can be opened in a browser. Accepts:
+// "https://github.com/login" → kept as-is
+// "github.com" → prefixed with https://
+// "Gitea" / "my note" → returns '' (no dot or not a hostname)
+// Returns the canonical URL to pass to ShellExecute, or '' if not openable.
+function entryOpenUrl(site) {
+ if (!site) return '';
+ let s = String(site).trim();
+ // Already-scheme'd: only allow http(s).
+ if (/^https?:\/\//i.test(s)) return s;
+ if (/^[a-z][a-z0-9+.-]*:/i.test(s)) return ''; // ftp://, file://, mailto:…
+ // Plain hostname or hostname/path. Require at least one dot and a
+ // letter TLD ≥ 2 chars to avoid opening "Gitea" or "Brand name".
+ const host = s.split('/')[0].split(':')[0].toLowerCase();
+ if (!host.includes('.')) return '';
+ if (!/\.[a-z]{2,}$/i.test(host)) return '';
+ return 'https://' + s;
+}
+
+function entryOpenInBrowser(url) {
+ if (!url) return;
+ if (Bridge.active && typeof Bridge.openUrl === 'function') {
+ Bridge.openUrl(url);
+ } else {
+ // PHP frontend / fallback: regular window.open.
+ try { window.open(url, '_blank', 'noopener'); } catch (e) {}
+ }
+}
+
// Display label for a folder value. "All" is the default "uncategorized"
// bucket; we relabel it so users don't see two "All" entries in folder
// pickers (the top nav "All items" also says "All").
@@ -2266,14 +2527,20 @@ function buildKebabMenu(entry) {
wrap.appendChild(btn);
const menu = el('div', { class: 'entry-kebab-menu' });
+ const isNote = entry.kind === 'note';
const items = [
{ lbl: entry.favorite ? 'Unfavorite' : 'Favorite', ic: 'i-star', fn: () => toggleFavorite(entry.id) },
- { lbl: 'Copy password', ic: 'i-copy', fn: () => copyPassword(entry) },
- { lbl: 'Copy username', ic: 'i-user', fn: () => copyUsername(entry) },
+ { lbl: isNote ? 'Copy note content' : 'Copy password',
+ ic: 'i-copy', fn: () => copyPassword(entry) },
+ ];
+ // Username is login-only — hide the menu item for notes (no username field).
+ if (!isNote)
+ items.push({ lbl: 'Copy username', ic: 'i-user', fn: () => copyUsername(entry) });
+ items.push(
{ lbl: 'Edit', ic: 'i-edit', fn: () => openSlideOver(entry.id) },
{ lbl: 'Duplicate', ic: 'i-copy', fn: () => duplicateEntry(entry) },
{ lbl: 'Move to trash', ic: 'i-trash', fn: () => deleteEntry(entry.id), danger: true },
- ];
+ );
items.forEach(it => {
const mi = el('button', {
class: 'kebab-item' + (it.danger ? ' is-danger' : ''),
@@ -2407,17 +2674,47 @@ function renderCard(e) {
}
card.appendChild(head);
- // password row (placeholder dots, click reveals via slide-over)
- const pwRow = el('div', { class: 'entry-pw-row' });
- pwRow.appendChild(el('span', { class: 'entry-pw', id: 'pw-' + e.id }, '••••••••'));
- const copyBtn = el('button', {
- class: 'icon-btn icon-btn-sm',
- title: 'Copy password',
- on: { click: ev => { ev.stopPropagation(); copyPassword(e); } },
- });
- copyBtn.appendChild(icon('i-copy'));
- pwRow.appendChild(copyBtn);
- card.appendChild(pwRow);
+ // Body row: password placeholder for logins, content snippet for notes.
+ const isNoteCard = (e.kind === 'note');
+ if (isNoteCard) {
+ // Single placeholder line — the body is encrypted client-side,
+ // we don't decrypt it eagerly for every card.
+ const noteRow = el('div', { class: 'entry-note-row' });
+ noteRow.appendChild(el('span', { class: 'entry-note-placeholder' },
+ 'Encrypted note · click to read'));
+ // Same crypto pipeline as a password — copyPassword decrypts the
+ // body and drops it in the secure clipboard.
+ const copyBtn = el('button', {
+ class: 'icon-btn icon-btn-sm',
+ title: 'Copy note content',
+ on: { click: ev => { ev.stopPropagation(); copyPassword(e); } },
+ });
+ copyBtn.appendChild(icon('i-copy'));
+ noteRow.appendChild(copyBtn);
+ card.appendChild(noteRow);
+ } else {
+ const pwRow = el('div', { class: 'entry-pw-row' });
+ pwRow.appendChild(el('span', { class: 'entry-pw', id: 'pw-' + e.id }, '••••••••'));
+ const copyBtn = el('button', {
+ class: 'icon-btn icon-btn-sm',
+ title: 'Copy password',
+ on: { click: ev => { ev.stopPropagation(); copyPassword(e); } },
+ });
+ copyBtn.appendChild(icon('i-copy'));
+ pwRow.appendChild(copyBtn);
+ // Open URL — only when the site looks like a real http(s) target.
+ const openUrl = entryOpenUrl(e.site);
+ if (openUrl) {
+ const openBtn = el('button', {
+ class: 'icon-btn icon-btn-sm',
+ title: 'Open ' + openUrl + ' in browser',
+ on: { click: ev => { ev.stopPropagation(); entryOpenInBrowser(openUrl); } },
+ });
+ openBtn.appendChild(icon('i-globe'));
+ pwRow.appendChild(openBtn);
+ }
+ card.appendChild(pwRow);
+ }
// meta chips: folder + first 2 tags. "All" is the default "uncategorized"
// bucket and shouldn't be shown as a chip (visually duplicates "All items").
@@ -2545,6 +2842,37 @@ function renderPagination(total, totalPages) {
});
wrap.appendChild(sizeSel);
+ // Inline sort dropdown — same options as Settings → Appearance "Sort
+ // entries by" but accessible without opening the settings panel.
+ const sortSel = el('select', {
+ class: 'pagination-size pagination-sort',
+ on: { change: ev => {
+ const [by, dir] = ev.target.value.split(':');
+ state.sortBy = by;
+ state.sortDir = dir;
+ state.currentPage = 1;
+ localStorage.setItem('sortBy', state.sortBy);
+ localStorage.setItem('sortDir', state.sortDir);
+ saveServerSettings();
+ render();
+ } },
+ });
+ const SORT_OPTIONS = [
+ ['name:asc', 'Name A → Z'],
+ ['name:desc', 'Name Z → A'],
+ ['updated:desc', 'Recently updated'],
+ ['updated:asc', 'Oldest updated'],
+ ['created:desc', 'Recently created'],
+ ['created:asc', 'Oldest created'],
+ ];
+ const cur = state.sortBy + ':' + state.sortDir;
+ SORT_OPTIONS.forEach(([v, label]) => {
+ const opt = el('option', { value: v }, label);
+ if (v === cur) opt.selected = true;
+ sortSel.appendChild(opt);
+ });
+ wrap.appendChild(sortSel);
+
return wrap;
}
@@ -2659,24 +2987,34 @@ function renderTableRow(e) {
td.appendChild(avatar);
const nameWrap = el('span', { class: 'cell-name-wrap' });
nameWrap.appendChild(el('b', null, entryDisplayName(e)));
+ if (e.kind === 'note')
+ nameWrap.appendChild(el('span', { class: 'kind-badge', title: 'Secure note' }, 'note'));
if (e.favorite) nameWrap.appendChild(el('span', { class: 'fav-dot', title: 'Favorite' }, '★'));
td.appendChild(nameWrap);
break;
}
case 'site':
- td = el('td', { class: 'col-site' }, e.site || '');
+ td = el('td', { class: 'col-site' },
+ e.kind === 'note' ? '' : (e.site || ''));
break;
case 'user': {
td = el('td', { class: 'col-user' });
- td.appendChild(el('span', null, displayUsername(e.username)));
- if (e.username) {
- const btn = el('button', {
- class: 'icon-btn icon-btn-sm',
- title: 'Copy username',
- on: { click: ev => { ev.stopPropagation(); copyUsername(e); } },
- });
- btn.appendChild(icon('i-copy'));
- td.appendChild(btn);
+ if (e.kind === 'note') {
+ // Notes have no username — show a faint "Encrypted note"
+ // placeholder instead of the "—" mask the login path uses.
+ td.appendChild(el('span', { class: 'col-user-note' },
+ 'Encrypted note'));
+ } else {
+ td.appendChild(el('span', null, displayUsername(e.username)));
+ if (e.username) {
+ const btn = el('button', {
+ class: 'icon-btn icon-btn-sm',
+ title: 'Copy username',
+ on: { click: ev => { ev.stopPropagation(); copyUsername(e); } },
+ });
+ btn.appendChild(icon('i-copy'));
+ td.appendChild(btn);
+ }
}
break;
}
@@ -2692,7 +3030,7 @@ function renderTableRow(e) {
td = el('td', { class: 'col-actions' });
const pwBtn = el('button', {
class: 'icon-btn icon-btn-sm',
- title: 'Copy password',
+ title: e.kind === 'note' ? 'Copy note content' : 'Copy password',
on: { click: ev => { ev.stopPropagation(); copyPassword(e); } },
});
pwBtn.appendChild(icon('i-copy'));
@@ -2954,12 +3292,16 @@ async function openSlideOver(id, opts) {
if (!isNew && !e) return;
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.
+ // Resolve kind early: opts.kind for new entries (login default), entry's
+ // own kind for existing. Drives the field layout below.
+ const kind = isNew ? (opts.kind || 'login') : (e.kind || 'login');
+ const isNote = (kind === 'note');
+ // Title prefix reflects mode + kind (note vs login).
const titleEl = $('#slideoverTitle');
+ const kindLabel = isNote ? 'note' : 'entry';
titleEl.textContent = isNew
- ? '+ New entry'
- : 'Edit · ' + entryDisplayName(e);
+ ? ('+ New ' + kindLabel)
+ : ('Edit · ' + entryDisplayName(e));
titleEl.classList.toggle('is-new-mode', isNew);
titleEl.classList.toggle('is-edit-mode', !isNew);
const body = $('#slideoverBody');
@@ -2967,6 +3309,7 @@ async function openSlideOver(id, opts) {
let plain = '';
let plainTotp = '';
+ let plainCustom = [];
if (!isNew) {
plain = await decryptPwd(e.encrypted_password, e.iv);
// Decrypt TOTP secret if present. Empty string when no TOTP configured
@@ -2975,6 +3318,11 @@ async function openSlideOver(id, opts) {
plainTotp = await decryptTotpSecret(e.totp_secret, e.totp_iv);
if (plainTotp === '[ERROR]') plainTotp = '';
}
+ // Custom fields: same crypto pipeline, but the plaintext is a JSON
+ // array of {label, value, is_secret}.
+ if (e.custom_fields && e.custom_fields_iv) {
+ plainCustom = await decryptCustomFields(e.custom_fields, e.custom_fields_iv);
+ }
}
// Track original values so we can detect "dirty". For new entries the
@@ -2983,36 +3331,61 @@ async function openSlideOver(id, opts) {
? state.view.slice(7) : 'All';
soState = {
id: isNew ? null : e.id,
+ kind: kind,
original: {
- site: isNew ? (opts.presetSite || '') : e.site,
+ site: isNew ? (opts.presetSite || '') : (e.site || ''),
title: isNew ? (opts.presetTitle || '') : (e.title || ''),
username: isNew ? '' : (e.username || ''),
- password: plain,
+ password: plain, // for notes this holds the note body
folder: isNew ? defaultFolder : (e.folder || 'All'),
tags: isNew ? '' : parseTags(e.tags).join(','),
totp: plainTotp,
},
tags: isNew ? [] : parseTags(e.tags),
+ // Working copy of the custom-fields array — mutated in place by
+ // buildCustomFieldRow handlers. The serialized JSON of this array
+ // at Save time is what gets encrypted into custom_fields/iv.
+ customFields: plainCustom.map(f => ({
+ label: f.label || '', value: f.value || '',
+ is_secret: !!f.is_secret,
+ })),
+ originalCustomJson: JSON.stringify(plainCustom),
originalEncrypted: isNew ? null : e.encrypted_password,
originalIV: isNew ? null : e.iv,
originalTotpEncrypted: isNew ? null : e.totp_secret,
originalTotpIV: isNew ? null : e.totp_iv,
+ originalCustomEncrypted: isNew ? null : (e.custom_fields || null),
+ originalCustomIV: isNew ? null : (e.custom_fields_iv || null),
};
- // 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(soState.original.folder));
- body.appendChild(soTagsField());
+ if (isNote) {
+ // Notes: minimal layout — name + multiline body + folder + tags.
+ // No icon (covered by sidebar icon), no site/user/totp.
+ body.appendChild(soEditableField('Title', 'soTitle', soState.original.title));
+ body.appendChild(soNoteBodyField(plain));
+ const hist = soHistoryButton();
+ if (hist) body.appendChild(hist);
+ body.appendChild(soCustomFieldsField());
+ body.appendChild(soFolderField(soState.original.folder));
+ body.appendChild(soTagsField());
+ } else {
+ // Login (existing layout).
+ 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));
+ const histLogin = soHistoryButton();
+ if (histLogin) body.appendChild(histLogin);
+ body.appendChild(soTotpField(plainTotp));
+ body.appendChild(soCustomFieldsField());
+ body.appendChild(soFolderField(soState.original.folder));
+ body.appendChild(soTagsField());
+ }
// Action row — Save button is hidden until dirty. No Delete here:
// the quick-X on each card handles deletion (avoids duplication).
@@ -3026,8 +3399,9 @@ async function openSlideOver(id, opts) {
actions.appendChild(saveBtn);
body.appendChild(actions);
- // Wire change detection
- ['#soTitle', '#soSite', '#soUsername', '#soPassword', '#soFolder'].forEach(sel => {
+ // Wire change detection (selectors absent for notes are ignored).
+ ['#soTitle', '#soSite', '#soUsername', '#soPassword', '#soFolder',
+ '#soNoteBody'].forEach(sel => {
const el = $(sel); if (el) el.addEventListener('input', soDirtyCheck);
if (el) el.addEventListener('change', soDirtyCheck);
});
@@ -3035,12 +3409,14 @@ async function openSlideOver(id, opts) {
$('#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).
+ // and focus the most relevant pivot field (Title for notes, Site for logins).
if (isNew) {
const save = document.getElementById('soSaveBtn');
if (save) save.style.display = '';
setTimeout(() => {
- const f = document.getElementById('soSite');
+ const f = isNote
+ ? document.getElementById('soTitle')
+ : document.getElementById('soSite');
if (f) f.focus();
}, 50);
}
@@ -3048,6 +3424,163 @@ async function openSlideOver(id, opts) {
renderGrid();
}
+// "Show history" launcher — placed below the password field for logins,
+// below the note body for notes. Reads soState.id so it works in edit
+// mode only (new entries have no history yet).
+function soHistoryButton() {
+ if (!soState || soState.id == null) return null;
+ const wrap = el('div', { class: 'slideover-field so-history-wrap' });
+ const btn = el('button', {
+ class: 'btn btn-ghost btn-xs', type: 'button',
+ });
+ btn.appendChild(icon('i-rotate-ccw'));
+ btn.appendChild(document.createTextNode(' Show previous versions'));
+ btn.addEventListener('click', () => openHistoryModal(soState.id));
+ wrap.appendChild(btn);
+ return wrap;
+}
+
+// Custom fields editor — dynamic list of {label, value, is_secret} rows.
+// Mutates soState.customFields in place; on change calls soDirtyCheck so
+// the Save button surfaces. The whole array is re-encrypted on Save (no
+// per-row IVs to keep simple).
+function soCustomFieldsField() {
+ const wrap = el('div', { class: 'slideover-field so-custom-wrap' });
+ wrap.appendChild(el('div', { class: 'slideover-field-label' },
+ 'Custom fields'));
+ const list = el('div', { class: 'so-custom-list', id: 'soCustomList' });
+ wrap.appendChild(list);
+
+ function renderRows() {
+ list.innerHTML = '';
+ (soState.customFields || []).forEach((f, idx) => {
+ list.appendChild(buildCustomFieldRow(f, idx, renderRows));
+ });
+ }
+ renderRows();
+
+ const addWrap = el('div', { class: 'so-custom-add' });
+ const addBtn = el('button', { class: 'btn btn-ghost btn-sm', type: 'button' });
+ addBtn.appendChild(icon('i-plus'));
+ addBtn.appendChild(document.createTextNode(' Add field'));
+ addBtn.addEventListener('click', ev => {
+ // stopPropagation — the document-level "click outside slideover"
+ // listener would otherwise see this click as outside (the button
+ // isn't in any of the allow-listed containers) and close the panel.
+ // Same pattern as #newEntryBtn, health-dashboard "Fix", etc.
+ ev.stopPropagation();
+ soState.customFields = soState.customFields || [];
+ soState.customFields.push({ label: '', value: '', is_secret: false });
+ renderRows();
+ soDirtyCheck();
+ setTimeout(() => {
+ const inputs = list.querySelectorAll('.so-custom-label');
+ const last = inputs[inputs.length - 1];
+ if (last) last.focus();
+ }, 0);
+ });
+ addWrap.appendChild(addBtn);
+ wrap.appendChild(addWrap);
+ return wrap;
+}
+
+function buildCustomFieldRow(field, idx, rerender) {
+ const row = el('div', { class: 'so-custom-row' });
+ const labelInput = el('input', {
+ type: 'text', class: 'so-input so-custom-label',
+ placeholder: 'Label (e.g. PIN, Account #)',
+ });
+ labelInput.value = field.label || '';
+ labelInput.addEventListener('input', () => {
+ field.label = labelInput.value;
+ soDirtyCheck();
+ });
+
+ const valueInput = el('input', {
+ type: field.is_secret ? 'password' : 'text',
+ class: 'so-input so-custom-value',
+ placeholder: 'Value',
+ });
+ valueInput.value = field.value || '';
+ valueInput.addEventListener('input', () => {
+ field.value = valueInput.value;
+ soDirtyCheck();
+ });
+
+ // Reveal eye — only meaningful for secret fields.
+ const eye = el('button', { class: 'icon-btn icon-btn-sm', type: 'button',
+ title: 'Show / hide' });
+ eye.appendChild(icon('i-eye'));
+ // All button handlers below stopPropagation — see CLAUDE.md
+ // "Click-outside-slideover bug" for why.
+ eye.addEventListener('click', ev => {
+ ev.stopPropagation();
+ if (!field.is_secret) return;
+ valueInput.type = valueInput.type === 'password' ? 'text' : 'password';
+ });
+ if (!field.is_secret) eye.style.visibility = 'hidden';
+
+ // Secret-flag toggle (chip-style) — flips both the storage flag and
+ // the visible input type.
+ const secretBtn = el('button', {
+ class: 'so-custom-secret-toggle' + (field.is_secret ? ' is-on' : ''),
+ type: 'button',
+ title: field.is_secret ? 'Secret field — value hidden' : 'Public field',
+ }, field.is_secret ? '🔒' : '👁');
+ secretBtn.addEventListener('click', ev => {
+ ev.stopPropagation();
+ field.is_secret = !field.is_secret;
+ soDirtyCheck();
+ rerender();
+ });
+
+ const copyBtn = el('button', { class: 'icon-btn icon-btn-sm', type: 'button',
+ title: 'Copy value' });
+ copyBtn.appendChild(icon('i-copy'));
+ copyBtn.addEventListener('click', ev => {
+ ev.stopPropagation();
+ const v = field.value || '';
+ if (!v) return;
+ if (Bridge.active) Bridge.copySecure(v, 30000);
+ else { try { navigator.clipboard.writeText(v); } catch (_) {} }
+ toast('Copied · clears in 30s');
+ });
+
+ const delBtn = el('button', { class: 'icon-btn icon-btn-sm', type: 'button',
+ title: 'Remove field' });
+ delBtn.appendChild(icon('i-x'));
+ delBtn.addEventListener('click', ev => {
+ ev.stopPropagation();
+ soState.customFields.splice(idx, 1);
+ rerender();
+ soDirtyCheck();
+ });
+
+ row.appendChild(labelInput);
+ row.appendChild(valueInput);
+ row.appendChild(eye);
+ row.appendChild(secretBtn);
+ row.appendChild(copyBtn);
+ row.appendChild(delBtn);
+ return row;
+}
+
+// Multiline note body. Maps to soState.original.password and the
+// encrypted_password+iv columns (same crypto pipeline as login passwords).
+function soNoteBodyField(value) {
+ const wrap = el('div', { class: 'slideover-field' });
+ wrap.appendChild(el('div', { class: 'slideover-field-label' }, 'Note'));
+ const ta = el('textarea', {
+ id: 'soNoteBody',
+ class: 'so-input so-note-body',
+ rows: 12,
+ placeholder: 'Encrypted with your vault key. Nothing leaves your device.',
+ });
+ ta.value = value || '';
+ wrap.appendChild(ta);
+ return wrap;
+}
+
function soEditableField(label, id, value) {
const wrap = el('div', { class: 'slideover-field' });
wrap.appendChild(el('div', { class: 'slideover-field-label' }, label));
@@ -3062,7 +3595,7 @@ function soEditableField(label, id, value) {
// 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
+const ICON_MAX_BYTES = 256 * 1024; // matches server-side cap (256 KB)
function soIconField(entry) {
const wrap = el('div', { class: 'slideover-field so-icon-field' });
@@ -3101,7 +3634,7 @@ function soIconField(entry) {
return;
}
if (f.size > ICON_MAX_BYTES) {
- toast('Icon too large (max 64 KB)', 'error');
+ toast('Icon too large (max 256 KB)', 'error');
return;
}
const reader = new FileReader();
@@ -3398,15 +3931,21 @@ function soDirtyCheck() {
if (btn) btn.style.display = '';
return;
}
+ // For notes, soNoteBody plays the role of the password (secret body).
+ // Selectors that don't exist for the current kind read as empty strings,
+ // which match the empty originals → never flag dirty.
const cur = {
title: ($('#soTitle') || {}).value || '',
site: ($('#soSite') || {}).value || '',
username: ($('#soUsername') || {}).value || '',
- password: ($('#soPassword') || {}).value || '',
+ password: (($('#soPassword') || $('#soNoteBody')) || {}).value || '',
folder: ($('#soFolder') || {}).value || '',
totp: ($('#soTotpSecret') || {}).value || '',
tags: soState.tags.join(','),
};
+ // Stringify current custom-fields array — same JSON encoding used at
+ // load time so the comparison is exact.
+ const curCustom = JSON.stringify(soState.customFields || []);
const dirty =
cur.title !== soState.original.title ||
cur.site !== soState.original.site ||
@@ -3414,7 +3953,8 @@ function soDirtyCheck() {
cur.password !== soState.original.password ||
cur.folder !== soState.original.folder ||
cur.totp !== soState.original.totp ||
- cur.tags !== soState.original.tags;
+ cur.tags !== soState.original.tags ||
+ curCustom !== (soState.originalCustomJson || '[]');
const btn = $('#soSaveBtn');
if (btn) btn.style.display = dirty ? '' : 'none';
}
@@ -3428,13 +3968,24 @@ async function soSave() {
soState.tags.push(pendingTag);
$('#soTagsField').value = '';
}
+ const kind = soState.kind || 'login';
+ const isNote = (kind === 'note');
const title = ($('#soTitle') || {}).value || '';
- const site = $('#soSite').value.trim();
- const user = $('#soUsername').value.trim();
- const pwd = $('#soPassword').value;
- const fold = $('#soFolder').value;
- const totp = (($('#soTotpSecret') || {}).value || '').trim();
- if (!site || !pwd) return toast('Site and password required', 'error');
+ const site = isNote ? '' : ($('#soSite').value.trim());
+ const user = isNote ? '' : ($('#soUsername').value.trim());
+ // For notes the textarea body is the encrypted payload (reuses
+ // encrypted_password/iv column pair).
+ const pwd = isNote
+ ? (($('#soNoteBody') || {}).value || '')
+ : ($('#soPassword').value);
+ const fold = ($('#soFolder') || {}).value || 'All';
+ const totp = isNote ? '' : (($('#soTotpSecret') || {}).value || '').trim();
+ if (isNote) {
+ if (!title.trim()) return toast('Title required', 'error');
+ if (!pwd) return toast('Note body required', 'error');
+ } else {
+ if (!site || !pwd) return toast('Site and password required', 'error');
+ }
const isNew = (soState.id == null);
@@ -3467,11 +4018,38 @@ async function soSave() {
}
}
+ // Custom fields: drop rows with an empty label (treat as removed).
+ // Re-encrypt only when the array actually changed; otherwise reuse the
+ // stored ciphertext so the row's updated_at doesn't get bumped for nothing.
+ const cleanCustom = (soState.customFields || [])
+ .filter(f => (f.label || '').trim() !== '')
+ .map(f => ({
+ label: (f.label || '').trim(),
+ value: f.value || '',
+ is_secret: !!f.is_secret,
+ }));
+ let cfEnc = '', cfIv = '';
+ const curCustomJson = JSON.stringify(cleanCustom);
+ if (cleanCustom.length === 0) {
+ // Empty array → send empty strings → server stores NULL.
+ cfEnc = ''; cfIv = '';
+ } else if (!isNew && curCustomJson === soState.originalCustomJson &&
+ soState.originalCustomEncrypted) {
+ cfEnc = soState.originalCustomEncrypted;
+ cfIv = soState.originalCustomIV;
+ } else {
+ const e = await encryptCustomFields(cleanCustom);
+ cfEnc = e.encrypted;
+ cfIv = e.iv;
+ }
+
const body = JSON.stringify({
site, title: title.trim(), username: user,
encrypted_password: enc.encrypted, iv: enc.iv,
totp_secret: totpEnc, totp_iv: totpIv,
+ custom_fields: cfEnc, custom_fields_iv: cfIv,
folder: fold, tags: soState.tags.join(','),
+ kind,
});
try {
@@ -3503,8 +4081,9 @@ async function soSave() {
render();
if (isNew && targetId) {
flashEntry(targetId);
- // Auto-fetch favicon for the new entry if the user opted in.
- if (state.faviconsEnabled && updated) ensureEntryFavicon(updated);
+ // Auto-fetch favicon only for logins (notes don't have a site).
+ if (state.faviconsEnabled && updated && (updated.kind || 'login') === 'login')
+ ensureEntryFavicon(updated);
}
} catch (err) { toast(err.message, 'error'); }
}
@@ -3818,7 +4397,7 @@ async function duplicateEntry(entry) {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({
- site: entry.site,
+ site: entry.site || '',
title: entryDisplayName(entry) + ' (copy)',
username: entry.username || '',
encrypted_password: entry.encrypted_password,
@@ -3827,6 +4406,15 @@ async function duplicateEntry(entry) {
tags: entry.tags || '',
totp_secret: entry.totp_secret || '',
totp_iv: entry.totp_iv || '',
+ // Preserve the source kind — without this notes were
+ // sent without 'kind', the server defaulted to 'login',
+ // then rejected the empty site as "Site required".
+ kind: entry.kind || 'login',
+ // Carry the encrypted custom-fields blob across as-is; it's
+ // already encrypted with the current vault key so the copy
+ // decrypts the same way as the source.
+ custom_fields: entry.custom_fields || '',
+ custom_fields_iv: entry.custom_fields_iv || '',
}),
});
await loadEntries();
@@ -4063,11 +4651,15 @@ function closePalette() { $('#cmdPalette').classList.add('is-hidden'); }
function paletteCommands() {
return [
{ id: 'new', label: 'New entry', icon: 'i-plus', run: () => { closePalette(); openSlideOver(null); } },
+ { id: 'new-note', label: 'New note', icon: 'i-edit', run: () => { closePalette(); openSlideOver(null, { kind: 'note' }); } },
+ { id: 'shortcuts', label: 'Show keyboard shortcuts (?)', icon: 'i-command',
+ run: () => { closePalette(); openCheatsheet(); } },
{ 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(); } },
{ id: 'all', label: 'Show all items', icon: 'i-globe', run: () => { closePalette(); setView('all'); } },
{ id: 'fav', label: 'Show favorites', icon: 'i-star', run: () => { closePalette(); setView('favorites'); } },
+ { id: 'notes', label: 'Show notes', icon: 'i-edit', run: () => { closePalette(); setView('notes'); } },
{ id: 'trash', label: 'Show trash', icon: 'i-trash', run: () => { closePalette(); setView('trash'); } },
];
}
@@ -4811,12 +5403,28 @@ async function doChangeMasterPassword() {
totpIv = t.iv;
}
}
+ // Custom fields — same dance: decrypt with OLD key, encrypt
+ // with NEW key, send fresh ciphertext.
+ let cfEnc = '', cfIv = '';
+ if (e.custom_fields && e.custom_fields_iv) {
+ state.cryptoKey = oldKey;
+ const plainCf = await decryptCustomFields(
+ e.custom_fields, e.custom_fields_iv);
+ state.cryptoKey = newKey;
+ if (plainCf.length > 0) {
+ const c = await encryptCustomFields(plainCf);
+ cfEnc = c.encrypted;
+ cfIv = c.iv;
+ }
+ }
encrypted.push({
id: e.id,
encrypted_password: re.encrypted,
iv: re.iv,
totp_secret: totpEnc,
totp_iv: totpIv,
+ custom_fields: cfEnc,
+ custom_fields_iv: cfIv,
});
} finally {
state.cryptoKey = oldKey; // restore until server confirms
@@ -5664,6 +6272,7 @@ function openSettings() {
}
$('#settingTrayNotif').checked = state.trayNotificationsEnabled !== false;
$('#settingTrayNotifRow').style.display = Bridge.active ? '' : 'none';
+ $('#settingTrashPurge').value = String(state.trashAutoPurgeDays || 0);
$('#settingUser').textContent = state.username;
// Async: query server for recovery key state and update the label
refreshRecoveryStatus();
@@ -5794,6 +6403,32 @@ async function enterApp() {
// settings_json may have flipped it).
if (Bridge.active && typeof Bridge.setTrayNotifications === 'function')
Bridge.setTrayNotifications(state.trayNotificationsEnabled !== false);
+ // Trash auto-purge (configured via Settings → Security). Fire-and-
+ // forget: failures are silent — the user can run "Empty trash" manually.
+ autoPurgeTrashIfNeeded();
+}
+
+async function autoPurgeTrashIfNeeded() {
+ const days = parseInt(state.trashAutoPurgeDays, 10) || 0;
+ if (days <= 0) return;
+ try {
+ const r = await fetch(API + '/entries/trash/old?days=' + days, {
+ method: 'DELETE',
+ headers: authHeaders(),
+ });
+ if (!r.ok) return;
+ const body = await r.json().catch(() => ({}));
+ const n = parseInt(body.purged, 10) || 0;
+ if (n > 0) {
+ toast(n + ' old entr' + (n === 1 ? 'y' : 'ies') +
+ ' permanently removed from trash');
+ // Refresh the count so the sidebar reflects the purge.
+ await loadEntryCounts();
+ render();
+ }
+ } catch (e) {
+ // Silent — user can manually empty trash if they care.
+ }
}
// ============================================================
@@ -5815,6 +6450,7 @@ const SYNCED_SETTING_KEYS = [
'sidebarCollapsed',
'faviconsEnabled',
'trayNotificationsEnabled',
+ 'trashAutoPurgeDays',
];
function applySidebarCollapsed() {
@@ -5865,6 +6501,9 @@ async function loadServerSettings() {
if (Bridge.active && typeof Bridge.setTrayNotifications === 'function')
Bridge.setTrayNotifications(v);
break;
+ case 'trashAutoPurgeDays':
+ localStorage.setItem('trashAutoPurgeDays', String(v));
+ break;
}
});
// Apply visual settings immediately.
@@ -6036,11 +6675,28 @@ async function init() {
saveServerSettings();
});
$('#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);
+ // Default click → new login (preserves the muscle memory of the
+ // existing button). The chevron next to it opens the kind picker.
+ $('#newEntryMenu').classList.add('is-hidden');
+ openSlideOver(null, { kind: 'login' });
+ });
+ $('#newEntryCaretBtn').addEventListener('click', ev => {
+ ev.stopPropagation();
+ $('#newEntryMenu').classList.toggle('is-hidden');
+ });
+ $$('#newEntryMenu [data-new-kind]').forEach(b => {
+ b.addEventListener('click', ev => {
+ ev.stopPropagation();
+ const kind = b.getAttribute('data-new-kind') || 'login';
+ $('#newEntryMenu').classList.add('is-hidden');
+ openSlideOver(null, { kind });
+ });
+ });
+ // Close the dropdown on any other click.
+ document.addEventListener('click', e => {
+ if (!e.target.closest('.new-entry-wrap'))
+ $('#newEntryMenu').classList.add('is-hidden');
});
$('#userChip').addEventListener('click', () => $('#userDropdown').classList.toggle('is-hidden'));
$('#lockBtn').addEventListener('click', lockVault);
@@ -6296,6 +6952,14 @@ async function init() {
? 'Tray notifications enabled'
: 'Tray notifications disabled');
});
+ $('#settingTrashPurge').addEventListener('change', e => {
+ const n = parseInt(e.target.value, 10) || 0;
+ state.trashAutoPurgeDays = n;
+ localStorage.setItem('trashAutoPurgeDays', String(n));
+ saveServerSettings();
+ if (n === 0) toast('Trash auto-purge disabled');
+ else toast('Trash will auto-purge after ' + n + ' days (next unlock)');
+ });
$('#settingFavicons').addEventListener('change', e => {
state.faviconsEnabled = e.target.checked;
localStorage.setItem('faviconsEnabled', state.faviconsEnabled ? '1' : '0');
@@ -6470,6 +7134,15 @@ async function init() {
const visible = filteredEntries();
visible.forEach(en => state.checked.add(en.id));
renderGrid();
+ } else if (e.key === '?' &&
+ !/^(INPUT|TEXTAREA|SELECT)$/.test((e.target||{}).tagName) &&
+ !e.ctrlKey && !e.metaKey && !e.altKey) {
+ // '?' anywhere outside an input shows the hotkey cheatsheet.
+ // Skipped while the auth screen is up — discovery is for the
+ // unlocked workflow.
+ if ($('#appShell').classList.contains('is-hidden')) return;
+ e.preventDefault();
+ openCheatsheet();
} else if (e.key === 'Escape') {
// Close in priority order: confirm first (most modal-y) then others
if (!$('#confirmModal').classList.contains('is-hidden')) {
@@ -6480,6 +7153,14 @@ async function init() {
closeChangeMasterModal();
return;
}
+ if (!$('#cheatsheetModal').classList.contains('is-hidden')) {
+ closeCheatsheet();
+ return;
+ }
+ if (!$('#historyModal').classList.contains('is-hidden')) {
+ closeHistoryModal();
+ return;
+ }
closePalette();
closeSlideOver();
closeEntryModal();
@@ -6495,6 +7176,12 @@ async function init() {
});
$('#cmdInput').addEventListener('input', e => renderPaletteResults(e.target.value));
$$('#cmdPalette [data-close]').forEach(b => b.addEventListener('click', closePalette));
+ $$('#cheatsheetModal [data-close]').forEach(b =>
+ b.addEventListener('click', closeCheatsheet));
+ $$('#historyModal [data-close]').forEach(b =>
+ b.addEventListener('click', closeHistoryModal));
+ const cheatBtn = document.getElementById('cheatsheetBtn');
+ if (cheatBtn) cheatBtn.addEventListener('click', openCheatsheet);
// Quick-search modal (tray menu) — keyboard nav + close
const qsInput = document.getElementById('quickSearchInput');