Quick unlock
diff --git a/js/app.js b/js/app.js
index f223a4c..f00f4c9 100644
--- a/js/app.js
+++ b/js/app.js
@@ -545,6 +545,21 @@ const state = {
// Synced across devices because it's a user-level preference.
trashAutoPurgeDays: parseInt(localStorage.getItem('trashAutoPurgeDays') || '0') || 0,
passwordExpiryDays: parseInt(localStorage.getItem('passwordExpiryDays') || '0') || 0,
+ // Table-view hidden columns. Array of keys that the user opted to
+ // hide (e.g. ['folder', 'updated']). Synced across devices.
+ tableColsHidden: (() => {
+ try { return JSON.parse(localStorage.getItem('tableColsHidden') || '[]'); }
+ catch { return []; }
+ })(),
+ editorPosition: localStorage.getItem('editorPosition') || 'right',
+ confirmOnUnsaved: localStorage.getItem('confirmOnUnsaved') !== '0',
+ // 'pw' (master only) | 'pin' (PIN only) | 'both' (master + PIN). 'pw'
+ // is the safe default — any device without a configured PIN behaves
+ // identically to the legacy unlock flow.
+ unlockMode: localStorage.getItem('unlockMode') || 'pw',
+ // Runtime mirror of the DPAPI blob existence — populated on init by
+ // bridgePinStatus(). Editing this directly doesn't touch storage.
+ pinConfigured: false,
};
// ============================================================
@@ -1659,6 +1674,39 @@ async function doLogin(e) {
e && e.preventDefault();
const u = $('#loginUsername').value.trim();
const p = $('#loginPassword').value;
+ const pinInput = ($('#loginPin') || {}).value || '';
+ const havePin = Bridge.active && state.pinConfigured;
+ const mode = havePin ? (state.unlockMode || 'pw') : 'pw';
+
+ // ---- PIN-only mode -------------------------------------------------
+ if (mode === 'pin') {
+ if (!pinInput) return;
+ $('#loginBtn').disabled = true;
+ const ok = await loginViaPin(pinInput);
+ $('#loginBtn').disabled = false;
+ $('#loginPin').value = '';
+ if (ok) { await enterApp(); return; }
+ const fresh = await bridgePinStatus();
+ state.pinConfigured = fresh;
+ applyAuthScreenMode();
+ $('#authHint').textContent = fresh
+ ? 'Wrong PIN. Try again or "Use master password".'
+ : 'Too many wrong PIN attempts. Sign in with your master password.';
+ return;
+ }
+
+ // ---- 'both' mode: master pw first, then PIN verification ----------
+ if (mode === 'both') {
+ if (!u || !p || !pinInput) return;
+ // Capture the PIN BEFORE the master-pw path clears the form, so
+ // a verification error doesn't lose what the user just typed.
+ const pendingPin = pinInput;
+ // Fall through to the master-pw login below (return after we
+ // tag a post-login PIN check). The hook is in enterApp via
+ // window._pinAfterMaster.
+ window._pinAfterMaster = pendingPin;
+ }
+
if (!u || !p) return;
// If we are in locked mode (token still valid), try fast unlock first.
if (state.locked && state.token && state.salt && u === state.username) {
@@ -1808,6 +1856,26 @@ async function doLogout() {
// Lock: do NOT hit /logout — keep server session alive, just drop the in-memory
// crypto key. On unlock, /reauth validates the master password and we re-derive.
+// Confirms with the user when there are unsaved edits BEFORE clearing
+// the session. Returns true if the lock/logout should proceed, false
+// if the user cancelled.
+async function confirmDiscardForSessionExit(action) {
+ if (!$('#slideover').classList.contains('is-open')) return true;
+ if (!state.confirmOnUnsaved) return true;
+ if (!isSoDirty()) return true;
+ return await confirmDialog({
+ title: action === 'logout' ? 'Log out without saving?' : 'Lock without saving?',
+ message: 'You have edits in the open entry that haven\'t been saved. ' +
+ (action === 'logout' ? 'Logging out' : 'Locking the vault') +
+ ' will discard them.' +
+ '
' +
+ 'Tip: turn this prompt off in Settings → Appearance → Confirm before closing unsaved edits.' +
+ '
',
+ okText: action === 'logout' ? 'Log out' : 'Lock',
+ danger: true,
+ });
+}
+
function lockVault() {
sessionStorage.removeItem('cryptoKey');
state.cryptoKey = null;
@@ -1825,6 +1893,16 @@ function lockVault() {
if (typeof auditCache !== 'undefined') auditCache = null;
auditFilter = '';
state.activeFilters.clear();
+ // Force-close any open editor / Settings panel BEFORE switching to
+ // the auth screen. Otherwise their .is-open class survives the lock
+ // and (a) the centered-modal backdrop keeps blurring the auth screen
+ // (b) the click-outside handler later prompts "discard changes?" for
+ // the slideover the user can no longer interact with.
+ soState = null;
+ const so = $('#slideover');
+ if (so) so.classList.remove('is-open');
+ const sp = $('#settingsPanel');
+ if (sp) sp.classList.remove('is-open');
showAuth();
// Two UI variants for the auth screen:
@@ -3535,21 +3613,76 @@ function renderCard(e) {
// Columns built dynamically — the Site column tracks the user's
// "Show site under display name" preference so card view and table view
// stay consistent (default: site hidden, can be re-enabled from Settings).
+// Keys that the column-picker can't toggle off — the table breaks
+// visually or behaviourally without them.
+const TABLE_PINNED_COLS = new Set(['check', 'name', 'actions']);
+
+// Optional columns offered in the picker. Order = picker order.
+const TABLE_TOGGLEABLE_COLS = [
+ { key: 'site', label: 'Site' },
+ { key: 'user', label: 'Username' },
+ { key: 'folder', label: 'Folder' },
+ { key: 'updated', label: 'Updated' },
+];
+
function getTableColumns() {
+ const hidden = new Set(state.tableColsHidden || []);
+ // Default behaviour: Site stays hidden unless the user opts in via
+ // the column picker — matches the long-standing "Show site under
+ // display name" behaviour while still letting the picker reveal it.
+ if (!state.showSiteOnCards && !hidden.has('site')) hidden.add('site');
const cols = [
{ key: 'check', label: '', sortKey: null },
{ key: 'name', label: 'Name', sortKey: 'name' },
- ];
- if (state.showSiteOnCards) {
- cols.push({ key: 'site', label: 'Site', sortKey: 'site' });
- }
- cols.push(
+ { key: 'site', label: 'Site', sortKey: 'site' },
{ key: 'user', label: 'Username', sortKey: null }, // no sort: derived/varies
{ key: 'folder', label: 'Folder', sortKey: 'folder' },
{ key: 'updated', label: 'Updated', sortKey: 'updated' },
{ key: 'actions', label: '', sortKey: null },
- );
- return cols;
+ ];
+ return cols.filter(c => !hidden.has(c.key) || TABLE_PINNED_COLS.has(c.key));
+}
+
+function renderTableColsMenu() {
+ const menu = document.getElementById('colsMenu');
+ if (!menu) return;
+ // Visible columns = those actually rendered by getTableColumns().
+ // Using that set keeps the picker in sync with the legacy
+ // showSiteOnCards setting (which forces Site off by default).
+ const visible = new Set(getTableColumns().map(c => c.key));
+ menu.innerHTML = '';
+ TABLE_TOGGLEABLE_COLS.forEach(c => {
+ const item = el('label', { class: 'cols-menu-item' });
+ const cb = el('input', { type: 'checkbox' });
+ cb.checked = visible.has(c.key);
+ cb.addEventListener('change', () => {
+ // Site has a legacy second source of truth (showSiteOnCards in
+ // Settings). The picker now owns it: a check enables the
+ // setting + clears the hidden flag, an uncheck adds it to hidden.
+ if (c.key === 'site') {
+ state.showSiteOnCards = cb.checked;
+ localStorage.setItem('showSiteOnCards', cb.checked ? '1' : '0');
+ }
+ const next = new Set(state.tableColsHidden || []);
+ if (cb.checked) next.delete(c.key);
+ else next.add(c.key);
+ state.tableColsHidden = Array.from(next);
+ localStorage.setItem('tableColsHidden',
+ JSON.stringify(state.tableColsHidden));
+ saveServerSettings();
+ renderGrid();
+ renderTableColsMenu();
+ });
+ item.appendChild(cb);
+ item.appendChild(el('span', null, c.label));
+ menu.appendChild(item);
+ });
+}
+
+function applyColsWrapVisibility() {
+ const wrap = document.getElementById('colsWrap');
+ if (!wrap) return;
+ wrap.style.display = (state.viewMode === 'table') ? '' : 'none';
}
function renderPagination(total, totalPages) {
@@ -3745,8 +3878,19 @@ function renderTableRow(e) {
}
case 'name': {
td = el('td', { class: 'col-name' });
- const avatar = el('span', { class: 'entry-avatar entry-avatar-sm' },
- initials(entryDisplayName(e)));
+ const displayName = entryDisplayName(e);
+ const avatar = el('span', { class: 'entry-avatar entry-avatar-sm' });
+ if (e.icon_b64) {
+ const img = el('img', { src: e.icon_b64, alt: '',
+ class: 'entry-avatar-img' });
+ img.addEventListener('error', () => {
+ avatar.innerHTML = '';
+ avatar.textContent = initials(displayName);
+ });
+ avatar.appendChild(img);
+ } else {
+ avatar.textContent = initials(displayName);
+ }
td.appendChild(avatar);
const nameWrap = el('span', { class: 'cell-name-wrap' });
nameWrap.appendChild(el('b', null, entryDisplayName(e)));
@@ -3803,6 +3947,18 @@ function renderTableRow(e) {
});
pwBtn.appendChild(icon('i-copy'));
td.appendChild(pwBtn);
+ // Open URL — only for login entries whose site looks like
+ // a real http(s) target. Same gate as the card view.
+ const openUrl = entryOpenUrl(e.site);
+ if (e.kind !== 'note' && openUrl) {
+ const openBtn = el('button', {
+ class: 'icon-btn icon-btn-sm',
+ title: 'Open in browser',
+ on: { click: ev => { ev.stopPropagation(); entryOpenInBrowser(openUrl); } },
+ });
+ openBtn.appendChild(icon('i-globe'));
+ td.appendChild(openBtn);
+ }
td.appendChild(buildKebabMenu(e));
break;
}
@@ -4150,6 +4306,26 @@ async function openSlideOver(id, opts) {
opts = opts || {};
const isNew = (id == null);
const e = isNew ? null : state.entries.find(x => x.id === id);
+ // Switching to another entry while the current edit has unsaved
+ // changes would silently drop them — gate on the same confirm dialog
+ // used by close paths. Only fires when the panel is already open AND
+ // we're actually moving to a different target.
+ const switching = $('#slideover').classList.contains('is-open') &&
+ soState && (soState.id !== id) &&
+ !(isNew && soState.id == null && soState.id === id);
+ if (switching && state.confirmOnUnsaved && isSoDirty()) {
+ const ok = await confirmDialog({
+ title: 'Discard unsaved changes?',
+ message: 'You have edits in the open entry that haven\'t been saved. ' +
+ 'Switch to the other entry anyway?' +
+ '
' +
+ 'Tip: turn this prompt off in Settings → Appearance → Confirm before closing unsaved edits.' +
+ '
',
+ okText: 'Discard',
+ danger: true,
+ });
+ if (!ok) return;
+ }
if (!isNew && !e) return;
state.selectedId = isNew ? null : id;
@@ -4246,6 +4422,11 @@ async function openSlideOver(id, opts) {
// injected by the template picker on new. Sent back to the server
// on Save so the card/table can label it correctly.
template: isNew ? (opts.presetTemplate || '') : (e.template || ''),
+ // Attachments staged BEFORE the entry exists server-side. Empty on
+ // edit (existing attachments are fetched live via the dedicated
+ // /entries/:id/attachments endpoint). Flushed by soSave after the
+ // POST returns the new entry id.
+ pendingAttachments: [],
};
if (isNote) {
@@ -4256,7 +4437,9 @@ async function openSlideOver(id, opts) {
const hist = soHistoryButton();
if (hist) body.appendChild(hist);
body.appendChild(soCustomFieldsField());
- if (!isNew) body.appendChild(soAttachmentsField(id));
+ // For new entries the attachments are staged in soState.pendingAttachments
+ // and uploaded after the entry has been created (we need its id).
+ body.appendChild(soAttachmentsField(isNew ? null : id));
body.appendChild(soFolderField(soState.original.folder));
body.appendChild(soTagsField());
} else {
@@ -4274,7 +4457,7 @@ async function openSlideOver(id, opts) {
if (histLogin) body.appendChild(histLogin);
body.appendChild(soTotpField(plainTotp));
body.appendChild(soCustomFieldsField());
- if (!isNew) body.appendChild(soAttachmentsField(id));
+ body.appendChild(soAttachmentsField(isNew ? null : id));
body.appendChild(soFolderField(soState.original.folder));
body.appendChild(soTagsField());
}
@@ -4892,18 +5075,12 @@ 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;
- }
- // 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.
+// Returns true when the slideover has unsaved user changes. For new
+// entries: any non-empty content (text, custom fields, staged
+// attachments) counts. For edits: any field that differs from the
+// snapshot taken at openSlideOver time.
+function isSoDirty() {
+ if (!soState) return false;
const cur = {
title: ($('#soTitle') || {}).value || '',
site: ($('#soSite') || {}).value || '',
@@ -4913,10 +5090,21 @@ function soDirtyCheck() {
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 =
+ if (soState.id == null) {
+ const orig = soState.original || {};
+ const staged = (soState.pendingAttachments || []).length > 0;
+ return staged
+ || cur.title !== (orig.title || '')
+ || cur.site !== (orig.site || '')
+ || cur.username !== (orig.username || '')
+ || cur.password !== (orig.password || '')
+ || cur.folder !== (orig.folder || 'All')
+ || cur.totp !== (orig.totp || '')
+ || cur.tags !== ''
+ || curCustom !== (soState.originalCustomJson || '[]');
+ }
+ return (
cur.title !== soState.original.title ||
cur.site !== soState.original.site ||
cur.username !== soState.original.username ||
@@ -4924,9 +5112,20 @@ function soDirtyCheck() {
cur.folder !== soState.original.folder ||
cur.totp !== soState.original.totp ||
cur.tags !== soState.original.tags ||
- curCustom !== (soState.originalCustomJson || '[]');
+ curCustom !== (soState.originalCustomJson || '[]'));
+}
+
+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 btn = $('#soSaveBtn');
- if (btn) btn.style.display = dirty ? '' : 'none';
+ if (btn) btn.style.display = isSoDirty() ? '' : 'none';
}
async function soSave() {
@@ -5048,7 +5247,38 @@ async function soSave() {
body,
});
if (r && typeof r.id === 'number') targetId = r.id;
- toast('Saved');
+ // Flush staged attachments now that we have the entry id.
+ // Best-effort: a single failure doesn't break the save, but
+ // surfaces a warning so the user knows to retry from the
+ // re-opened slideover.
+ const staged = soState.pendingAttachments || [];
+ if (staged.length > 0 && typeof targetId === 'number') {
+ let attachOk = 0, attachFail = 0;
+ for (const att of staged) {
+ try {
+ const { encrypted, iv } = await encryptBlobBytes(att.bytes);
+ await api('/entries/' + targetId + '/attachments', {
+ method: 'POST',
+ headers: authHeaders({ 'Content-Type': 'application/json' }),
+ body: JSON.stringify({
+ filename: att.filename,
+ mime: att.mime,
+ encrypted_blob: encrypted,
+ iv,
+ size_bytes: att.size_bytes,
+ }),
+ });
+ attachOk++;
+ } catch (_) { attachFail++; }
+ }
+ soState.pendingAttachments = [];
+ if (attachFail > 0)
+ toast(attachOk + ' attachment(s) saved · ' + attachFail + ' failed', 'warning');
+ else
+ toast(attachOk + ' attachment(s) saved');
+ } else {
+ toast('Saved');
+ }
} else {
await api('/entries/' + soState.id, {
method: 'PUT',
@@ -5125,6 +5355,29 @@ function closeSlideOver() {
renderGrid();
}
+// Gate every dismissal path through this so unsaved changes aren't lost
+// silently when the user clicks outside, presses Esc, switches entry,
+// or clicks the X. Returns true if the close went through, false if the
+// user cancelled. Async because the confirm dialog awaits user input.
+async function requestCloseSlideOver() {
+ if (!$('#slideover').classList.contains('is-open')) return true;
+ if (state.confirmOnUnsaved && isSoDirty()) {
+ const ok = await confirmDialog({
+ title: 'Discard unsaved changes?',
+ message: 'You have edits that haven\'t been saved. ' +
+ 'Close anyway?' +
+ '
' +
+ 'Tip: turn this prompt off in Settings → Appearance → Confirm before closing unsaved edits.' +
+ '
',
+ okText: 'Discard',
+ danger: true,
+ });
+ if (!ok) return false;
+ }
+ closeSlideOver();
+ return true;
+}
+
// ============================================================
// TAG CHIP INPUT
// ============================================================
@@ -5650,6 +5903,7 @@ async function downloadAttachment(att) {
// Slideover field: header + upload button + list of attachments.
// Re-renders the list in place when the contents change (upload/delete).
function soAttachmentsField(entryId) {
+ const isStaging = (entryId === null || entryId === undefined);
const wrap = el('div', { class: 'slideover-field' });
wrap.appendChild(el('div', { class: 'slideover-field-label' }, 'Attachments'));
@@ -5676,11 +5930,29 @@ function soAttachmentsField(entryId) {
const file = fileInput.files && fileInput.files[0];
if (!file) return;
fileInput.value = ''; // allow re-uploading the same name later
+ if (file.size > ATTACHMENT_MAX_BYTES) {
+ toast('File too large (max 5 MB raw)', 'error');
+ return;
+ }
addBtn.disabled = true;
try {
- await uploadAttachment(entryId, file);
- toast('Attachment uploaded');
- await refreshAttachments(entryId, list);
+ if (isStaging) {
+ // Stage in memory — uploaded by soSave after the new
+ // entry's id is known.
+ const bytes = new Uint8Array(await file.arrayBuffer());
+ soState.pendingAttachments.push({
+ filename: file.name,
+ mime: file.type || 'application/octet-stream',
+ size_bytes: file.size,
+ bytes,
+ });
+ soDirtyCheck();
+ renderStagedAttachments(list);
+ } else {
+ await uploadAttachment(entryId, file);
+ toast('Attachment uploaded');
+ await refreshAttachments(entryId, list);
+ }
} catch (e) {
toast('Upload failed: ' + e.message, 'error');
} finally {
@@ -5688,11 +5960,45 @@ function soAttachmentsField(entryId) {
}
});
- // Initial async load (silent — empty state shows by default).
- refreshAttachments(entryId, list);
+ if (isStaging) renderStagedAttachments(list);
+ else refreshAttachments(entryId, list);
return wrap;
}
+// Renders the pendingAttachments array (new-entry mode). Mirrors the
+// existing-entry layout so users can't tell the difference until they
+// hit Save.
+function renderStagedAttachments(container) {
+ container.innerHTML = '';
+ const list = soState.pendingAttachments || [];
+ if (list.length === 0) {
+ container.appendChild(el('div', { class: 'so-attach-empty' },
+ 'No attachments yet. Files will upload after you save.'));
+ return;
+ }
+ list.forEach((att, idx) => {
+ const row = el('div', { class: 'so-attach-row' });
+ const info = el('div', { class: 'so-attach-info' });
+ info.appendChild(el('div', { class: 'so-attach-name', title: att.filename }, att.filename));
+ info.appendChild(el('div', { class: 'so-attach-meta' },
+ humanFileSize(att.size_bytes) + ' · ' + (att.mime || '?') +
+ ' · pending'));
+ row.appendChild(info);
+
+ const del = el('button',
+ { class: 'icon-btn icon-btn-sm', type: 'button', title: 'Remove' });
+ del.appendChild(icon('i-trash'));
+ del.addEventListener('click', ev => {
+ ev.stopPropagation();
+ soState.pendingAttachments.splice(idx, 1);
+ soDirtyCheck();
+ renderStagedAttachments(container);
+ });
+ row.appendChild(del);
+ container.appendChild(row);
+ });
+}
+
async function refreshAttachments(entryId, container) {
container.innerHTML = '';
let items = [];
@@ -6062,8 +6368,8 @@ function paletteCommands() {
{ 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: 'lock', label: 'Lock vault', icon: 'i-lock', run: async () => { closePalette(); if (await confirmDiscardForSessionExit('lock')) lockVault(); } },
+ { id: 'logout', label: 'Sign out', icon: 'i-log-out', run: async () => { closePalette(); if (await confirmDiscardForSessionExit('logout')) 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'); } },
@@ -6290,6 +6596,333 @@ Bridge.onQuickUnlockStatus = function(configured) {
}
};
+// ============================================================
+// PIN UNLOCK (DPAPI blob, PIN-derived wrap of the vault key)
+// ============================================================
+// Three modes governed by state.unlockMode (synced setting):
+// 'pw' — current behaviour, master pw only (PIN ignored even if set)
+// 'pin' — PIN unlocks the vault on this device
+// 'both' — master pw unlocks; PIN is then verified before access
+//
+// Anti-brute-force: each failed PIN attempt rewrites the blob with an
+// incremented counter. Past PIN_MAX_ATTEMPTS the blob self-destructs and
+// the user has to fall back to master pw (and re-set the PIN if desired).
+//
+// Sensitive actions (export, change master pw, recovery code…) still go
+// through askReauth which always asks for the master pw — the PIN never
+// substitutes there.
+
+const PIN_KDF_ITERS = 100000; // lower than master pw — PIN entropy
+ // is low (4–6 digits) so high iters
+ // mostly slow down honest users.
+const PIN_MAX_ATTEMPTS = 5;
+
+let pinResolver = null;
+let pinStatusResolver = null;
+
+function bridgePinGet() {
+ if (!Bridge.active) return Promise.resolve(null);
+ return new Promise(resolve => {
+ pinResolver = resolve;
+ setTimeout(() => {
+ if (pinResolver === resolve) { pinResolver = null; resolve(null); }
+ }, 3000);
+ window.location.href = 'cmd://pin/get';
+ });
+}
+function bridgePinStatus() {
+ if (!Bridge.active) return Promise.resolve(false);
+ return new Promise(resolve => {
+ pinStatusResolver = resolve;
+ setTimeout(() => {
+ if (pinStatusResolver === resolve) {
+ pinStatusResolver = null; resolve(false);
+ }
+ }, 2000);
+ window.location.href = 'cmd://pin/status';
+ });
+}
+function bridgePinStore(payloadB64) {
+ if (!Bridge.active) return;
+ window.location.href = 'cmd://pin/store?data=' + encodeURIComponent(payloadB64);
+}
+function bridgePinClear() {
+ if (!Bridge.active) return;
+ window.location.href = 'cmd://pin/clear';
+}
+Bridge.onPinResult = function(b64) {
+ if (pinResolver) { const cb = pinResolver; pinResolver = null; cb(b64); }
+};
+Bridge.onPinStatus = function(configured) {
+ if (pinStatusResolver) {
+ const cb = pinStatusResolver;
+ pinStatusResolver = null;
+ cb(!!configured);
+ }
+};
+
+async function derivePinWrapKey(pin, saltBytes, iters) {
+ const km = await crypto.subtle.importKey(
+ 'raw', new TextEncoder().encode(pin), 'PBKDF2', false, ['deriveKey']);
+ return crypto.subtle.deriveKey(
+ { name: 'PBKDF2', salt: saltBytes, iterations: iters, hash: 'SHA-256' },
+ km,
+ { name: 'AES-GCM', length: 256 },
+ true, // extractable: false would be safer but we need to re-wrap on attempt increment
+ ['encrypt', 'decrypt']);
+}
+
+async function pinBuildBlob(pin) {
+ // Snapshot of everything the cold-start unlock needs to log back in
+ // without the master pw. Mirrors the Quick Unlock payload shape.
+ const salt = crypto.getRandomValues(new Uint8Array(16));
+ const iv = crypto.getRandomValues(new Uint8Array(12));
+ const wrap = await derivePinWrapKey(pin, salt, PIN_KDF_ITERS);
+ const raw = await crypto.subtle.exportKey('raw', state.cryptoKey);
+ const wrapped = await crypto.subtle.encrypt(
+ { name: 'AES-GCM', iv }, wrap, raw);
+ return {
+ v: 1,
+ username: state.username,
+ loginSalt: state.salt,
+ loginIters: state.kdfIterations || 600000,
+ salt: bytesToBase64(salt),
+ iters: PIN_KDF_ITERS,
+ iv: bytesToBase64(iv),
+ wrapped: bytesToBase64(wrapped),
+ attempts: 0,
+ };
+}
+
+function pinBlobToB64(obj) {
+ return bytesToBase64(new TextEncoder().encode(JSON.stringify(obj)));
+}
+function pinB64ToBlob(b64) {
+ return JSON.parse(new TextDecoder().decode(base64ToBytes(b64)));
+}
+
+async function pinFetchBlob() {
+ const b64 = await bridgePinGet();
+ if (!b64) return null;
+ try { return pinB64ToBlob(b64); }
+ catch { return null; }
+}
+
+// Validates a PIN against the stored blob. Returns the unwrapped vault
+// key bytes on success, null on failure (and rewrites the blob with the
+// incremented attempts counter or wipes it past the cap).
+async function pinTryUnwrap(pin) {
+ const blob = await pinFetchBlob();
+ if (!blob) return null;
+ try {
+ const wrap = await derivePinWrapKey(pin,
+ base64ToBytes(blob.salt), blob.iters || PIN_KDF_ITERS);
+ const raw = await crypto.subtle.decrypt(
+ { name: 'AES-GCM', iv: base64ToBytes(blob.iv) },
+ wrap, base64ToBytes(blob.wrapped));
+ // Successful unlock — reset the attempts counter so a future
+ // bad-then-good streak doesn't accidentally wipe the blob.
+ if ((blob.attempts || 0) !== 0) {
+ blob.attempts = 0;
+ bridgePinStore(pinBlobToB64(blob));
+ }
+ return { rawKey: new Uint8Array(raw), blob };
+ } catch (_) {
+ const next = (blob.attempts || 0) + 1;
+ if (next >= PIN_MAX_ATTEMPTS) {
+ bridgePinClear();
+ } else {
+ blob.attempts = next;
+ bridgePinStore(pinBlobToB64(blob));
+ }
+ return null;
+ }
+}
+
+async function setupPin(pin) {
+ if (!Bridge.active) return toast('PIN requires the Delphi app', 'warning');
+ if (!state.cryptoKey) return toast('Unlock the vault first', 'warning');
+ const blob = await pinBuildBlob(pin);
+ bridgePinStore(pinBlobToB64(blob));
+ state.pinConfigured = true;
+ toast('PIN set');
+}
+
+async function removePin() {
+ bridgePinClear();
+ state.pinConfigured = false;
+ // If we were in pin-only mode, fall back to pw — leaving the user
+ // unable to log in next time would be a self-foot-gun.
+ if (state.unlockMode === 'pin' || state.unlockMode === 'both') {
+ state.unlockMode = 'pw';
+ localStorage.setItem('unlockMode', 'pw');
+ saveServerSettings();
+ }
+ toast('PIN removed');
+}
+
+// Refresh the Settings panel PIN row: dropdown value, status text,
+// button labels. Idempotent — safe to call from listeners or after the
+// async pinStatus probe completes.
+function refreshPinUnlockUI() {
+ const sel = document.getElementById('settingUnlockMode');
+ const status = document.getElementById('pinStatus');
+ const setBtn = document.getElementById('pinSetBtn');
+ const delBtn = document.getElementById('pinRemoveBtn');
+ if (sel) sel.value = state.unlockMode || 'pw';
+ if (status) {
+ status.textContent = state.pinConfigured
+ ? 'PIN is set on this device.'
+ : 'No PIN set.';
+ }
+ if (setBtn) setBtn.textContent = state.pinConfigured ? 'Change PIN' : 'Set PIN';
+ if (delBtn) delBtn.style.display = state.pinConfigured ? '' : 'none';
+}
+
+// Prompt + setup flow used by the Set/Change PIN button. Validates the
+// PIN client-side (4–12 digits) then writes the DPAPI blob.
+async function pinSetupFlow() {
+ if (!Bridge.active) return toast('PIN requires the Delphi app', 'warning');
+ if (!state.cryptoKey) return toast('Unlock the vault first', 'warning');
+
+ // Setting/changing a PIN creates a new unlock path → treat as a
+ // sensitive operation. An unattended unlocked vault must not be
+ // possible for a passerby to pin-backdoor.
+ const masterPwd = await askReauth(
+ 'Confirm your master password to set or change the PIN.');
+ if (!masterPwd) return;
+ try {
+ const verifier = await computeVerifier(
+ masterPwd, state.salt, state.kdfIterations || 100000);
+ await api('/reauth', {
+ method: 'POST',
+ headers: authHeaders({ 'Content-Type': 'application/json' }),
+ body: JSON.stringify({ verifier: verifier }),
+ });
+ } catch (err) {
+ return toast('Wrong master password', 'error');
+ }
+
+ let lastError = '';
+ let attempts = 0;
+ for (;;) {
+ const pin = await promptDialog({
+ title: state.pinConfigured ? 'Change PIN' : 'Set a PIN',
+ message: '4–12 digits. PIN unlock is device-local and never leaves this machine.',
+ placeholder: 'PIN',
+ password: true,
+ okText: 'Save',
+ error: lastError,
+ });
+ // Cancel / Esc / X resolves with `false` (not null) — treat any
+ // falsy value as "user backed out", not as a wrong attempt.
+ if (pin === false || pin === null || pin === undefined || pin === '') return;
+ if (!/^\d{4,12}$/.test(pin)) {
+ attempts++;
+ if (attempts >= 5) return toast('Too many invalid attempts', 'error');
+ lastError = 'PIN must be 4–12 digits (attempt ' + attempts + ' / 5).';
+ continue;
+ }
+ await setupPin(pin);
+ refreshPinUnlockUI();
+ return;
+ }
+}
+
+// ----- Auth screen mode switching ---------------------------------
+// Picks which inputs are visible on the lock screen based on the
+// user's unlockMode + whether a PIN blob actually exists on this
+// device. Always falls back to the master-pw layout if PIN unlock can't
+// realistically work (no Delphi bridge, no DPAPI blob).
+function applyAuthScreenMode() {
+ const pwField = document.getElementById('loginPasswordField');
+ const pinField = document.getElementById('loginPinField');
+ const useMaster = document.getElementById('loginUseMasterBtn');
+ if (!pwField || !pinField) return;
+ const havePin = Bridge.active && state.pinConfigured;
+ // Drift fix: if the user previously chose pin/both but the blob is
+ // no longer on disk (auto-wiped after 5 wrong attempts, or removed
+ // from another session), demote the mode locally so the Settings
+ // dropdown reflects reality. Server sync happens next time the user
+ // logs in and openSettings saves changes.
+ if (!havePin && (state.unlockMode === 'pin' || state.unlockMode === 'both')) {
+ state.unlockMode = 'pw';
+ localStorage.setItem('unlockMode', 'pw');
+ }
+ const mode = havePin ? (state.unlockMode || 'pw') : 'pw';
+
+ pwField.style.display = (mode === 'pin') ? 'none' : '';
+ pinField.style.display = (mode === 'pin' || mode === 'both') ? '' : 'none';
+ // PIN-only mode: offer a one-shot escape hatch so the user can fall
+ // back to master pw if the PIN blob got corrupted or they forgot it.
+ if (useMaster) useMaster.style.display = (mode === 'pin') ? '' : 'none';
+
+ // Required attribute on hidden inputs blocks form submission — keep
+ // it in sync with visibility.
+ const pwInput = document.getElementById('loginPassword');
+ const pinInput = document.getElementById('loginPin');
+ if (pwInput) pwInput.required = (mode !== 'pin');
+ if (pinInput) pinInput.required = (mode === 'pin' || mode === 'both');
+}
+
+// Try the PIN-unlock cold-start. Mirrors tryQuickUnlock but gated by a
+// user-typed PIN. Returns true on success (vault unlocked), false on
+// any failure (wrong PIN, missing blob, server refused). The caller is
+// responsible for showing the master-pw fallback UI on false.
+async function loginViaPin(pin) {
+ if (!Bridge.active) return false;
+ if (!pin) return false;
+ const ok = await pinTryUnwrap(pin);
+ if (!ok) return false;
+ const { rawKey, blob } = ok;
+
+ // Restore the identity bits we need to call /login with a verifier.
+ state.username = blob.username || state.username;
+ state.salt = blob.loginSalt || state.salt;
+ state.kdfIterations = blob.loginIters || state.kdfIterations || 600000;
+
+ try {
+ state.cryptoKey = await crypto.subtle.importKey(
+ 'raw', rawKey, { name: 'AES-GCM' }, true, ['encrypt', 'decrypt']);
+ } catch (_) { return false; }
+
+ try {
+ const verifier = bytesToHex(rawKey);
+ const r = await api('/login', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ username: state.username, verifier }),
+ });
+ state.token = r.token;
+ state.csrf = r.csrfToken;
+ if (r.salt) state.salt = r.salt;
+ if (r.kdfIterations) state.kdfIterations = r.kdfIterations;
+ } catch (_) {
+ // Server credentials drifted (master pw rotation since PIN
+ // setup). Force user back to master pw + ask them to redo PIN.
+ return false;
+ }
+
+ sessionStorage.setItem('username', state.username);
+ sessionStorage.setItem('salt', state.salt);
+ sessionStorage.setItem('kdfIterations', String(state.kdfIterations));
+ sessionStorage.setItem('authToken', state.token);
+ sessionStorage.setItem('csrfToken', state.csrf);
+ await persistCryptoKey();
+
+ state.locked = false;
+ state.justRecovered = false;
+ return true;
+}
+
+// 'both' mode helper: master pw has already populated state.cryptoKey
+// via the regular login flow. Now check the PIN matches the stored
+// blob (validation only — we don't use the unwrapped key from here).
+async function verifyPinAfterMasterUnlock(pin) {
+ const ok = await pinTryUnwrap(pin);
+ return !!ok;
+}
+
async function enableQuickUnlock() {
if (!Bridge.active) return toast('Quick unlock requires the Delphi app', 'warning');
if (!state.cryptoKey) return toast('Unlock the vault first', 'warning');
@@ -7007,6 +7640,21 @@ async function doChangeMasterPassword() {
localStorage.removeItem('quickUnlockEnabled');
state.quickUnlockEnabled = false;
}
+ // Same problem for the PIN blob — wrapped key is from the old
+ // master, server verifier won't match anymore. Wipe so the user
+ // gets a clean fallback to master pw next time.
+ if (Bridge.active && state.pinConfigured) {
+ bridgePinClear();
+ state.pinConfigured = false;
+ if (state.unlockMode === 'pin' || state.unlockMode === 'both') {
+ state.unlockMode = 'pw';
+ localStorage.setItem('unlockMode', 'pw');
+ }
+ // Settings panel is open during the rotation — refresh its
+ // PIN row so the "Change/Remove PIN" buttons + status text
+ // reflect the wipe without needing to close + reopen.
+ if (typeof refreshPinUnlockUI === 'function') refreshPinUnlockUI();
+ }
state.justRecovered = false;
closeChangeMasterModal();
@@ -7307,6 +7955,18 @@ function parseEntriesFromJSON(text) {
const raw = Array.isArray(data) ? data : (data.entries || []);
if (!Array.isArray(raw) || raw.length === 0)
throw new Error('No entries in JSON file');
+ // Folders metadata (color, icon) — only present on payloads produced
+ // by our own JSON exporter from 2026-06 onward. Silently absent for
+ // older backups or foreign formats; the per-entry `folder` name is
+ // still respected either way.
+ const folders = Array.isArray(data.folders)
+ ? data.folders.filter(f => f && f.name && f.name !== 'All')
+ .map(f => ({
+ name: String(f.name).trim(),
+ color: String(f.color || '').trim(),
+ icon: String(f.icon || '').trim(),
+ }))
+ : [];
const entries = [];
let skipped = 0;
@@ -7352,7 +8012,7 @@ function parseEntriesFromJSON(text) {
icon_b64: String(e.icon_b64 || '').trim(),
});
}
- return { entries, skipped, columns: null }; // JSON: no column report
+ return { entries, skipped, columns: null, folders };
}
// Encrypt one parsed entry (plaintext password + optional TOTP) into the
@@ -7485,6 +8145,38 @@ async function doImport() {
const parsedAtt = parsed.entries.reduce(
(n, e) => n + (Array.isArray(e.attachments) ? e.attachments.length : 0), 0);
+
+ // Apply folder customisation (color, icon) from the payload —
+ // additive only: existing local folders are left untouched so the
+ // user's current customisation isn't overwritten by an older
+ // backup. Folders referenced by entries but absent from the
+ // folders[] block will still be auto-created with defaults during
+ // the bulk-import step server-side.
+ if (Array.isArray(parsed.folders) && parsed.folders.length > 0) {
+ const existing = new Set((state.folders || [])
+ .filter(f => f && f.name).map(f => f.name));
+ let createdFolders = 0;
+ for (const f of parsed.folders) {
+ if (!f.name || existing.has(f.name)) continue;
+ try {
+ await api('/folders', {
+ method: 'POST',
+ headers: authHeaders({ 'Content-Type': 'application/json' }),
+ body: JSON.stringify({
+ name: f.name,
+ color: f.color || '',
+ icon: f.icon || '',
+ }),
+ });
+ createdFolders++;
+ } catch (_) { /* duplicate or invalid — skip silently */ }
+ }
+ if (createdFolders > 0) {
+ await loadFolders();
+ toast(createdFolders + ' folder(s) added');
+ }
+ }
+
toast('Encrypting ' + parsed.entries.length + ' entries…');
const encrypted = [];
@@ -7626,6 +8318,17 @@ async function doExport() {
version: 1,
exported_at: new Date().toISOString(),
username: state.username,
+ // Folder customisation (color, icon) so restoring on a fresh
+ // install brings the sidebar back the way the user had it,
+ // not the default gray + folder-icon. 'All' is synthetic and
+ // never persisted, skip it.
+ folders: (state.folders || [])
+ .filter(f => f && f.name && f.name !== 'All')
+ .map(f => ({
+ name: f.name,
+ color: f.color || '',
+ icon: f.icon || '',
+ })),
entries: [],
};
for (const e of state.entries) {
@@ -8093,6 +8796,17 @@ function openSettings() {
$('#settingTrayNotifRow').style.display = Bridge.active ? '' : 'none';
$('#settingTrashPurge').value = String(state.trashAutoPurgeDays || 0);
$('#settingPasswordExpiry').value = String(state.passwordExpiryDays || 0);
+ $('#settingEditorPosition').value = state.editorPosition || 'right';
+ $('#settingConfirmUnsaved').checked = state.confirmOnUnsaved !== false;
+ // PIN unlock — only meaningful when DPAPI is available.
+ const pinField = document.getElementById('pinUnlockField');
+ if (pinField) {
+ pinField.style.display = Bridge.active ? '' : 'none';
+ if (Bridge.active) bridgePinStatus().then(has => {
+ state.pinConfigured = has;
+ refreshPinUnlockUI();
+ });
+ }
$('#settingUser').textContent = state.username;
// Hide the version row entirely in the PHP/web frontend (no bridge).
if (Bridge.active) {
@@ -8193,6 +8907,15 @@ async function showAuth() {
$('#appShell').classList.add('is-hidden');
if (autoLockTimer) { clearTimeout(autoLockTimer); autoLockTimer = null; }
+ // Re-query PIN presence each time we land on the auth screen — the
+ // user may have set/removed it from another instance, and we want
+ // the correct fields to show up without forcing a full reload.
+ state.pinConfigured = await bridgePinStatus();
+ applyAuthScreenMode();
+ // Clear residual PIN input from a previous attempt.
+ const pinInput = document.getElementById('loginPin');
+ if (pinInput) pinInput.value = '';
+
let remembered = '';
if (Bridge.active) {
remembered = await Bridge.getPref('rememberedUsername');
@@ -8219,12 +8942,43 @@ async function showAuth() {
}
async function enterApp() {
+ // 'both' unlock mode: the master pw just passed — now verify the PIN
+ // the user typed before we open the vault. Bail back to lock screen
+ // if the PIN doesn't match the stored blob.
+ if (window._pinAfterMaster) {
+ const pin = window._pinAfterMaster;
+ window._pinAfterMaster = null;
+ const ok = await verifyPinAfterMasterUnlock(pin);
+ if (!ok) {
+ const fresh = await bridgePinStatus();
+ state.pinConfigured = fresh;
+ lockVault();
+ $('#authHint').textContent = fresh
+ ? 'Wrong PIN. Try again.'
+ : 'Too many wrong PIN attempts — PIN has been removed. Re-set it from Settings after unlocking.';
+ return;
+ }
+ }
$('#authScreen').classList.add('is-hidden');
$('#appShell').classList.remove('is-hidden');
$('#userName').textContent = state.username;
// Server-side prefs override localStorage cache; runs before render so
// theme / view mode / mask flags are applied to the first paint.
await loadServerSettings();
+ // Drift fix: server may have stale unlockMode='pin'|'both' from a
+ // previous device where the PIN blob has since been auto-wiped (5
+ // wrong attempts). Re-check real PIN presence post-sync and push
+ // the corrected mode back up so the Settings dropdown matches the
+ // actual unlock options the user has on this device.
+ if (Bridge.active) {
+ state.pinConfigured = await bridgePinStatus();
+ if (!state.pinConfigured &&
+ (state.unlockMode === 'pin' || state.unlockMode === 'both')) {
+ state.unlockMode = 'pw';
+ localStorage.setItem('unlockMode', 'pw');
+ saveServerSettings();
+ }
+ }
// Show skeleton cards immediately while the initial fetch runs
showSkeletons(6);
await loadFolders();
@@ -8348,7 +9102,7 @@ async function promptAndStoreBackupPwd() {
okText: 'Save',
error: lastError,
});
- if (pwd === null || pwd === undefined) return false; // cancelled
+ if (!pwd) return false; // cancelled (false / null / '' / undefined)
if (pwd.length >= 6) {
Bridge.setPref(ABK.pwd, pwd);
return true;
@@ -8400,6 +9154,13 @@ async function runAutoBackupNow() {
version: 1,
exported_at: new Date().toISOString(),
username: state.username,
+ folders: (state.folders || [])
+ .filter(f => f && f.name && f.name !== 'All')
+ .map(f => ({
+ name: f.name,
+ color: f.color || '',
+ icon: f.icon || '',
+ })),
entries: [],
};
for (const e of state.entries) {
@@ -8516,8 +9277,27 @@ const SYNCED_SETTING_KEYS = [
// Password expiry reminder window (days, 0 = disabled). Cards show
// an "Aged" badge for entries whose password_changed_at exceeds it.
'passwordExpiryDays',
+ // Table view: columns the user has explicitly hidden.
+ 'tableColsHidden',
+ // Where the entry editor docks: 'right' (default slideover),
+ // 'left' (mirrored slideover), or 'center' (centered modal).
+ 'editorPosition',
+ // When the entry editor has unsaved changes, ask before dismissing.
+ 'confirmOnUnsaved',
+ // Unlock method (pw / pin / both). The PIN blob itself is device-local
+ // DPAPI so this synced setting only carries the user's preferred mode.
+ 'unlockMode',
];
+// Sets `data-editor-position` on so CSS can swap the slideover
+// between right / left / centered. No JS-side render changes — same DOM,
+// same JS, just different CSS.
+function applyEditorPosition() {
+ const pos = ['right', 'left', 'center'].includes(state.editorPosition)
+ ? state.editorPosition : 'right';
+ document.body.setAttribute('data-editor-position', pos);
+}
+
function applySidebarCollapsed() {
const s = state.sidebarCollapsed || {};
document.querySelectorAll('.sidebar-section[data-section]').forEach(sec => {
@@ -8572,6 +9352,20 @@ async function loadServerSettings() {
case 'passwordExpiryDays':
localStorage.setItem('passwordExpiryDays', String(v));
break;
+ case 'tableColsHidden':
+ localStorage.setItem('tableColsHidden',
+ JSON.stringify(Array.isArray(v) ? v : []));
+ break;
+ case 'editorPosition':
+ localStorage.setItem('editorPosition', String(v || 'right'));
+ applyEditorPosition();
+ break;
+ case 'confirmOnUnsaved':
+ localStorage.setItem('confirmOnUnsaved', v ? '1' : '0');
+ break;
+ case 'unlockMode':
+ localStorage.setItem('unlockMode', String(v || 'pw'));
+ break;
}
});
// Apply visual settings immediately.
@@ -8728,6 +9522,7 @@ async function init() {
// Top-bar
function applyViewMode() {
$$('.view-btn').forEach(b => b.classList.toggle('is-active', b.dataset.view === state.viewMode));
+ applyColsWrapVisibility();
renderGrid();
}
applyViewMode();
@@ -8739,6 +9534,23 @@ async function init() {
saveServerSettings();
}));
+ // Show/hide table column picker. Built lazily on first open so the
+ // checkbox state always reflects the current `state.tableColsHidden`.
+ const colsBtn = document.getElementById('colsBtn');
+ const colsMenu = document.getElementById('colsMenu');
+ if (colsBtn && colsMenu) {
+ colsBtn.addEventListener('click', ev => {
+ ev.stopPropagation();
+ const isHidden = colsMenu.classList.contains('is-hidden');
+ if (isHidden) renderTableColsMenu();
+ colsMenu.classList.toggle('is-hidden');
+ });
+ document.addEventListener('click', e => {
+ if (!e.target.closest('#colsWrap'))
+ colsMenu.classList.add('is-hidden');
+ });
+ }
+
$('#themeBtn').addEventListener('click', () => {
toggleTheme();
saveServerSettings();
@@ -8776,12 +9588,16 @@ async function init() {
$('#newEntryMenu').classList.add('is-hidden');
});
$('#userChip').addEventListener('click', () => $('#userDropdown').classList.toggle('is-hidden'));
- $('#lockBtn').addEventListener('click', lockVault);
+ $('#lockBtn').addEventListener('click', async () => {
+ if (await confirmDiscardForSessionExit('lock')) lockVault();
+ });
$('#dropdownSettingsBtn').addEventListener('click', () => {
$('#userDropdown').classList.add('is-hidden');
openSettings();
});
- $('#logoutBtn').addEventListener('click', doLogout);
+ $('#logoutBtn').addEventListener('click', async () => {
+ if (await confirmDiscardForSessionExit('logout')) doLogout();
+ });
document.addEventListener('click', e => {
if (!e.target.closest('.user-menu')) $('#userDropdown').classList.add('is-hidden');
// Close any open kebab menu when clicking outside it
@@ -8863,7 +9679,7 @@ async function init() {
});
// Slide-over
- $('#slideoverClose').addEventListener('click', closeSlideOver);
+ $('#slideoverClose').addEventListener('click', () => requestCloseSlideOver());
// Click outside the slide-over closes it. Clicks on cards re-open it for
// another entry (so we don't close in that case; the card's own handler
// will switch state.selectedId).
@@ -8876,7 +9692,7 @@ async function init() {
if (e.key !== 'Escape') return;
if (!$('#slideover').classList.contains('is-open')) return;
if (document.querySelector('.modal:not(.is-hidden)')) return;
- closeSlideOver();
+ requestCloseSlideOver();
});
// Click-outside closes too. mousedown origin is captured so a
// drag-selection that starts inside an input and ends outside doesn't
@@ -8895,19 +9711,34 @@ async function init() {
if (e.target.closest('.modal')) return;
if (e.target.closest('.cmd-palette')) return;
if (e.target.closest('.idle-warning')) return;
- closeSlideOver();
+ // Sidebar + topbar are UI chrome — clicking the theme toggle,
+ // user menu, filter button, pagination etc. shouldn't dismiss
+ // the editor. Same for the table column picker dropdown.
+ if (e.target.closest('.topbar')) return;
+ if (e.target.closest('.sidebar')) return;
+ if (e.target.closest('.pagination')) return;
+ if (e.target.closest('.cols-menu')) return;
+ if (e.target.closest('.filters-menu')) return;
+ if (e.target.closest('.user-menu')) return;
+ if (e.target.closest('.search-history')) return;
+ requestCloseSlideOver();
});
- // Click outside the settings panel closes it. Each setting change has
- // already pushed to server + localStorage, so "close = autosave" is
- // implicit. Ignore clicks on the triggers and on any open modal (so the
- // reauth / confirm flows fired from inside settings don't dismiss it).
- document.addEventListener('click', e => {
+ // Click outside the settings panel closes it. Handled on MOUSEDOWN
+ // (not click) so async handlers that reparent the DOM (e.g.
+ // backfillFavicons → render() during the click→bubble window) can't
+ // make us mistake an inside-click for an outside-click. Ignore the
+ // triggers and any open modal so reauth / confirm flows fired from
+ // inside Settings don't dismiss it.
+ document.addEventListener('mousedown', e => {
if (!$('#settingsPanel').classList.contains('is-open')) return;
+ if (!(e.target && e.target.closest)) return;
if (e.target.closest('#settingsPanel')) return;
if (e.target.closest('#settingsBtn')) return;
if (e.target.closest('#dropdownSettingsBtn')) return;
if (e.target.closest('.modal')) return;
+ if (e.target.closest('.cmd-palette')) return;
+ if (e.target.closest('.toast')) return;
closeSettings();
});
@@ -8923,6 +9754,15 @@ async function init() {
const input = $('#loginPassword');
input.type = input.type === 'password' ? 'text' : 'password';
});
+ // PIN-mode escape hatch: temporarily switch to the master-pw layout
+ // for this unlock attempt (doesn't change the saved unlockMode).
+ const useMasterBtn = document.getElementById('loginUseMasterBtn');
+ if (useMasterBtn) useMasterBtn.addEventListener('click', () => {
+ state.pinConfigured = false; // local-only flag, restored by next showAuth()
+ applyAuthScreenMode();
+ const p = document.getElementById('loginPassword');
+ if (p) p.focus();
+ });
$('#entryPwGen').addEventListener('click', openGen);
// Chip input (tags)
@@ -9018,6 +9858,7 @@ async function init() {
});
});
applySidebarCollapsed();
+ applyEditorPosition();
// Idle warning "Stay unlocked"
$('#idleStayBtn').addEventListener('click', resetAutoLock);
@@ -9118,6 +9959,49 @@ async function init() {
if (n === 0) toast('Password aging reminders disabled');
else toast('Passwords older than ' + n + ' days will be flagged');
});
+ $('#settingEditorPosition').addEventListener('change', e => {
+ const v = ['right', 'left', 'center'].includes(e.target.value)
+ ? e.target.value : 'right';
+ state.editorPosition = v;
+ localStorage.setItem('editorPosition', v);
+ applyEditorPosition();
+ saveServerSettings();
+ });
+ $('#settingConfirmUnsaved').addEventListener('change', e => {
+ state.confirmOnUnsaved = !!e.target.checked;
+ localStorage.setItem('confirmOnUnsaved', state.confirmOnUnsaved ? '1' : '0');
+ saveServerSettings();
+ });
+ // PIN unlock controls. Setting the mode to PIN/both without a PIN
+ // configured first would lock the user out — gate the dropdown so a
+ // missing PIN forces the user through Set PIN first.
+ const pinSet = document.getElementById('pinSetBtn');
+ const pinDel = document.getElementById('pinRemoveBtn');
+ const pinSel = document.getElementById('settingUnlockMode');
+ if (pinSet) pinSet.addEventListener('click', pinSetupFlow);
+ if (pinDel) pinDel.addEventListener('click', async () => {
+ const ok = await confirmDialog({
+ title: 'Remove PIN?',
+ message: 'You will need your master password to unlock until you set a new PIN.',
+ okText: 'Remove',
+ danger: true,
+ });
+ if (!ok) return;
+ await removePin();
+ refreshPinUnlockUI();
+ });
+ if (pinSel) pinSel.addEventListener('change', async e => {
+ const next = e.target.value;
+ if ((next === 'pin' || next === 'both') && !state.pinConfigured) {
+ toast('Set a PIN first', 'warning');
+ e.target.value = state.unlockMode || 'pw';
+ return;
+ }
+ state.unlockMode = next;
+ localStorage.setItem('unlockMode', next);
+ saveServerSettings();
+ toast('Unlock method updated');
+ });
$('#settingAutoBackupEnabled').addEventListener('change', onToggleAutoBackup);
$('#autoBackupPickDirBtn').addEventListener('click', pickAutoBackupFolder);
$('#settingAutoBackupInterval').addEventListener('change', e => {
@@ -9144,9 +10028,20 @@ async function init() {
toast('Website icons disabled (cached icons kept)');
}
});
- $('#settingFaviconsRefresh').addEventListener('click', () => backfillFavicons(false));
- $('#settingFaviconsRefreshAll').addEventListener('click', () => backfillFavicons(true));
- $('#settingFaviconsClear').addEventListener('click', async () => {
+ // stopPropagation on the favicon buttons — the document-level
+ // "click outside Settings closes it" handler was firing because the
+ // click target lost its #settingsPanel ancestor mid-bubble (the
+ // async backfill chain triggers a render that reparents nodes).
+ $('#settingFaviconsRefresh').addEventListener('click', ev => {
+ ev.stopPropagation();
+ backfillFavicons(false);
+ });
+ $('#settingFaviconsRefreshAll').addEventListener('click', ev => {
+ ev.stopPropagation();
+ backfillFavicons(true);
+ });
+ $('#settingFaviconsClear').addEventListener('click', async ev => {
+ ev.stopPropagation();
const ok = await confirmDialog({
title: 'Clear cached icons?',
message: 'All website icons cached in your vault will be removed. They will be re-fetched on demand if the toggle stays on.',
@@ -9335,7 +10230,7 @@ async function init() {
return;
}
closePalette();
- closeSlideOver();
+ requestCloseSlideOver();
closeEntryModal();
closeGen();
// If nothing else needed dismissing and there's an active