feat(entries): multiline Notes field for any entry, masked by default
The note body of a note entry is the encrypted_password column, so logins had no way to carry notes. Rather than a dedicated encrypted column and the seven call sites that come with it, this rides on custom_fields, which rotation, duplicate, export/import and sync already carry. Both the open-time normalisation and the save-time rebuild drop any key they do not name, so multiline is carried explicitly in each - otherwise the field silently reopens single-line.
This commit is contained in:
+11
-1
@@ -1601,6 +1601,15 @@ input[type="range"]::-webkit-slider-thumb {
|
|||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
/* Multiline custom field (Notes). The row stays a grid, so the textarea just
|
||||||
|
grows in its own column and the buttons keep their places. */
|
||||||
|
.so-custom-multiline {
|
||||||
|
min-height: 72px;
|
||||||
|
resize: vertical;
|
||||||
|
align-self: stretch;
|
||||||
|
font-family: inherit;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
/* Editable combobox for option-backed custom fields (card brand, expiry
|
/* Editable combobox for option-backed custom fields (card brand, expiry
|
||||||
year…). Input fills the cell; the arrow drops a menu of ALL options. */
|
year…). Input fills the cell; the arrow drops a menu of ALL options. */
|
||||||
.so-combo { position: relative; display: flex; align-items: center; }
|
.so-combo { position: relative; display: flex; align-items: center; }
|
||||||
@@ -1875,7 +1884,8 @@ input[type="range"]::-webkit-slider-thumb {
|
|||||||
}
|
}
|
||||||
/* A textarea has no type="password"; -webkit-text-security is the Chromium
|
/* A textarea has no type="password"; -webkit-text-security is the Chromium
|
||||||
equivalent (WebView2 is Chromium, so it's always available here). */
|
equivalent (WebView2 is Chromium, so it's always available here). */
|
||||||
.so-note-body.is-masked { -webkit-text-security: disc; }
|
.so-note-body.is-masked,
|
||||||
|
.so-custom-multiline.is-masked { -webkit-text-security: disc; }
|
||||||
.so-note-label { display: flex; align-items: center; justify-content: space-between; }
|
.so-note-label { display: flex; align-items: center; justify-content: space-between; }
|
||||||
|
|
||||||
/* ---- + New dropdown ---------------------------------- */
|
/* ---- + New dropdown ---------------------------------- */
|
||||||
|
|||||||
@@ -3983,6 +3983,10 @@ async function openSlideOver(id, opts) {
|
|||||||
};
|
};
|
||||||
if (Array.isArray(f.options) && f.options.length > 0)
|
if (Array.isArray(f.options) && f.options.length > 0)
|
||||||
out.options = f.options.slice();
|
out.options = f.options.slice();
|
||||||
|
// Carried explicitly: this normalisation drops every key it does not
|
||||||
|
// name, so a multiline field would silently come back single-line on
|
||||||
|
// the next open.
|
||||||
|
if (f.multiline) out.multiline = true;
|
||||||
return out;
|
return out;
|
||||||
});
|
});
|
||||||
soState.originalCustomJson = JSON.stringify(soState.customFields);
|
soState.originalCustomJson = JSON.stringify(soState.customFields);
|
||||||
@@ -4128,6 +4132,31 @@ function soCustomFieldsField() {
|
|||||||
}, 0);
|
}, 0);
|
||||||
});
|
});
|
||||||
addWrap.appendChild(addBtn);
|
addWrap.appendChild(addBtn);
|
||||||
|
|
||||||
|
// Notes for a login: a multiline custom field rather than a column of its
|
||||||
|
// own. The note body of a note entry IS encrypted_password, so a dedicated
|
||||||
|
// notes column would mean a new encrypted field and the seven call sites
|
||||||
|
// CLAUDE.md lists — while custom_fields already rides along with rotation,
|
||||||
|
// duplicate, export/import and sync for free. Secret by default.
|
||||||
|
const noteBtn = el('button', { class: 'btn btn-ghost btn-sm', type: 'button' });
|
||||||
|
noteBtn.appendChild(icon('i-edit'));
|
||||||
|
noteBtn.appendChild(document.createTextNode(' Add note'));
|
||||||
|
noteBtn.addEventListener('click', ev => {
|
||||||
|
ev.stopPropagation();
|
||||||
|
soState.customFields = soState.customFields || [];
|
||||||
|
soState.customFields.push({
|
||||||
|
label: 'Notes', value: '', is_secret: true, multiline: true,
|
||||||
|
});
|
||||||
|
renderRows();
|
||||||
|
soDirtyCheck();
|
||||||
|
setTimeout(() => {
|
||||||
|
const areas = list.querySelectorAll('.so-custom-multiline');
|
||||||
|
const last = areas[areas.length - 1];
|
||||||
|
if (last) last.focus();
|
||||||
|
}, 0);
|
||||||
|
});
|
||||||
|
addWrap.appendChild(noteBtn);
|
||||||
|
|
||||||
wrap.appendChild(addWrap);
|
wrap.appendChild(addWrap);
|
||||||
return wrap;
|
return wrap;
|
||||||
}
|
}
|
||||||
@@ -4154,6 +4183,17 @@ function buildCustomFieldRow(field, idx, rerender) {
|
|||||||
let valueInput;
|
let valueInput;
|
||||||
let valueSlot; // what actually goes into the row (input or combo wrap)
|
let valueSlot; // what actually goes into the row (input or combo wrap)
|
||||||
const hasOptions = Array.isArray(field.options) && field.options.length > 0;
|
const hasOptions = Array.isArray(field.options) && field.options.length > 0;
|
||||||
|
// Multiline fields (Notes) are a <textarea>, which has no type=password:
|
||||||
|
// masking goes through the same -webkit-text-security class as the note
|
||||||
|
// body of a note entry. Storage shape is unchanged — still field.value.
|
||||||
|
if (field.multiline) {
|
||||||
|
valueInput = el('textarea', {
|
||||||
|
class: 'so-input so-custom-value so-custom-multiline',
|
||||||
|
rows: 5, placeholder: 'Value',
|
||||||
|
autocomplete: 'off', autocorrect: 'off', spellcheck: 'false',
|
||||||
|
});
|
||||||
|
valueInput.classList.toggle('is-masked', !!field.is_secret);
|
||||||
|
} else {
|
||||||
valueInput = el('input', {
|
valueInput = el('input', {
|
||||||
type: field.is_secret ? 'password' : 'text',
|
type: field.is_secret ? 'password' : 'text',
|
||||||
class: 'so-input so-custom-value',
|
class: 'so-input so-custom-value',
|
||||||
@@ -4161,12 +4201,13 @@ function buildCustomFieldRow(field, idx, rerender) {
|
|||||||
autocomplete: field.is_secret ? 'new-password' : 'off',
|
autocomplete: field.is_secret ? 'new-password' : 'off',
|
||||||
spellcheck: 'false',
|
spellcheck: 'false',
|
||||||
});
|
});
|
||||||
|
}
|
||||||
valueInput.value = field.value || '';
|
valueInput.value = field.value || '';
|
||||||
valueInput.addEventListener('input', () => {
|
valueInput.addEventListener('input', () => {
|
||||||
field.value = valueInput.value;
|
field.value = valueInput.value;
|
||||||
soDirtyCheck();
|
soDirtyCheck();
|
||||||
});
|
});
|
||||||
if (hasOptions && !field.is_secret) {
|
if (hasOptions && !field.is_secret && !field.multiline) {
|
||||||
const combo = el('div', { class: 'so-combo' });
|
const combo = el('div', { class: 'so-combo' });
|
||||||
valueInput.classList.add('so-combo-input');
|
valueInput.classList.add('so-combo-input');
|
||||||
const arrow = el('button', {
|
const arrow = el('button', {
|
||||||
@@ -4213,11 +4254,12 @@ function buildCustomFieldRow(field, idx, rerender) {
|
|||||||
eye.addEventListener('click', ev => {
|
eye.addEventListener('click', ev => {
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
if (!field.is_secret) return;
|
if (!field.is_secret) return;
|
||||||
|
if (field.multiline) { valueInput.classList.toggle('is-masked'); return; }
|
||||||
// No-op on <select> — it has no type=password equivalent.
|
// No-op on <select> — it has no type=password equivalent.
|
||||||
if (valueInput.tagName !== 'INPUT') return;
|
if (valueInput.tagName !== 'INPUT') return;
|
||||||
valueInput.type = valueInput.type === 'password' ? 'text' : 'password';
|
valueInput.type = valueInput.type === 'password' ? 'text' : 'password';
|
||||||
});
|
});
|
||||||
if (!field.is_secret || valueInput.tagName !== 'INPUT')
|
if (!field.is_secret || (valueInput.tagName !== 'INPUT' && !field.multiline))
|
||||||
eye.style.visibility = 'hidden';
|
eye.style.visibility = 'hidden';
|
||||||
|
|
||||||
// Secret-flag toggle (chip-style) — flips both the storage flag and
|
// Secret-flag toggle (chip-style) — flips both the storage flag and
|
||||||
@@ -4896,6 +4938,9 @@ async function soSaveInner() {
|
|||||||
if (Array.isArray(f.options) && f.options.length > 0) {
|
if (Array.isArray(f.options) && f.options.length > 0) {
|
||||||
out.options = f.options.slice();
|
out.options = f.options.slice();
|
||||||
}
|
}
|
||||||
|
// Same reason as options: without this the field is stored flat and
|
||||||
|
// reopens as a one-line input.
|
||||||
|
if (f.multiline) out.multiline = true;
|
||||||
return out;
|
return out;
|
||||||
});
|
});
|
||||||
let cfEnc = '', cfIv = '';
|
let cfEnc = '', cfIv = '';
|
||||||
|
|||||||
Reference in New Issue
Block a user