From 3f07c1217b45213f166a98fc96449b1d6d0c314e Mon Sep 17 00:00:00 2001 From: r-zakarya <82443831+r-zakarya@users.noreply.github.com> Date: Wed, 22 Jul 2026 20:16:06 +0100 Subject: [PATCH] feat(notes): eye toggle to mask the note body in the editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The note textarea showed its plaintext to anyone glancing at the screen, unlike every other secret in the editor. An eye button on the NOTE label masks it with -webkit-text-security (a textarea has no type="password"). The button IS the setting: it flips state.maskNotes, which is synced via settings_json, so the choice sticks across entries and devices — no extra Settings row for a one-bit preference. Co-Authored-By: Claude Opus 4.8 --- css/style.css | 4 ++++ js/app.js | 21 +++++++++++++++++++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/css/style.css b/css/style.css index 8716672..12e7d37 100644 --- a/css/style.css +++ b/css/style.css @@ -1891,6 +1891,10 @@ input[type="range"]::-webkit-slider-thumb { line-height: 1.55; white-space: pre-wrap; } +/* A textarea has no type="password"; -webkit-text-security is the Chromium + equivalent (WebView2 is Chromium, so it's always available here). */ +.so-note-body.is-masked { -webkit-text-security: disc; } +.so-note-label { display: flex; align-items: center; justify-content: space-between; } /* ---- + New dropdown ---------------------------------- */ .new-entry-wrap { diff --git a/js/app.js b/js/app.js index 2c6b95e..3e7c970 100644 --- a/js/app.js +++ b/js/app.js @@ -632,6 +632,9 @@ const state = { autoLock: parseInt(localStorage.getItem('autoLockMin') || '5'), askBeforeDelete: localStorage.getItem('askBeforeDelete') !== '0', // default true maskUsernames: localStorage.getItem('maskUsernames') === '1', // default false + // Mask the note body in the editor (shoulder-surfing). Toggled by the eye + // on the Note field itself — that button IS the setting, no Settings row. + maskNotes: localStorage.getItem('maskNotes') === '1', // default false // Show the raw site/URL under the display name on cards. Default OFF // because the display name is meant to be the user-friendly label; // most users don't want the hostname cluttering the card layout. @@ -4176,7 +4179,12 @@ function buildCustomFieldRow(field, idx, rerender) { // 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 lbl = el('div', { class: 'slideover-field-label so-note-label' }, 'Note'); + const eye = el('button', { class: 'icon-btn icon-btn-sm', type: 'button', + title: 'Mask / reveal the note body' }); + eye.appendChild(icon('i-eye')); + lbl.appendChild(eye); + wrap.appendChild(lbl); const ta = el('textarea', { id: 'soNoteBody', class: 'so-input so-note-body', @@ -4185,6 +4193,14 @@ function soNoteBodyField(value) { autocomplete: 'off', autocorrect: 'off', spellcheck: 'false', }); ta.value = value || ''; + ta.classList.toggle('is-masked', state.maskNotes); + eye.addEventListener('click', ev => { + ev.stopPropagation(); // see "Click-outside-slideover bug" in CLAUDE.md + state.maskNotes = !state.maskNotes; + localStorage.setItem('maskNotes', state.maskNotes ? '1' : '0'); + ta.classList.toggle('is-masked', state.maskNotes); + saveServerSettings(); + }); wrap.appendChild(ta); return wrap; } @@ -6703,7 +6719,7 @@ async function autoPurgeTrashIfNeeded() { // Synced keys (user preferences, portable across devices). Device-specific // toggles (quickUnlockEnabled, autofillEnabled) stay in localStorage. const SYNCED_SETTING_KEYS = [ - 'theme', 'autoLock', 'askBeforeDelete', 'maskUsernames', + 'theme', 'autoLock', 'askBeforeDelete', 'maskUsernames', 'maskNotes', 'compactActions', 'viewMode', 'hibpEnabled', 'showSiteOnCards', 'sortBy', 'sortDir', 'pageSize', // Hotkey combos are user preferences — values are portable. The @@ -6773,6 +6789,7 @@ async function loadServerSettings() { case 'autoLock': localStorage.setItem('autoLockMin', String(v)); break; case 'askBeforeDelete': localStorage.setItem('askBeforeDelete', v ? '1' : '0'); break; case 'maskUsernames': localStorage.setItem('maskUsernames', v ? '1' : '0'); break; + case 'maskNotes': localStorage.setItem('maskNotes', v ? '1' : '0'); break; case 'compactActions': localStorage.setItem('compactActions', v ? '1' : '0'); break; case 'viewMode': localStorage.setItem('viewMode', v); break; case 'hibpEnabled': localStorage.setItem('hibpEnabled', v ? '1' : '0'); break;