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:
@@ -3983,6 +3983,10 @@ async function openSlideOver(id, opts) {
|
||||
};
|
||||
if (Array.isArray(f.options) && f.options.length > 0)
|
||||
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;
|
||||
});
|
||||
soState.originalCustomJson = JSON.stringify(soState.customFields);
|
||||
@@ -4128,6 +4132,31 @@ function soCustomFieldsField() {
|
||||
}, 0);
|
||||
});
|
||||
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);
|
||||
return wrap;
|
||||
}
|
||||
@@ -4154,19 +4183,31 @@ function buildCustomFieldRow(field, idx, rerender) {
|
||||
let valueInput;
|
||||
let valueSlot; // what actually goes into the row (input or combo wrap)
|
||||
const hasOptions = Array.isArray(field.options) && field.options.length > 0;
|
||||
valueInput = el('input', {
|
||||
type: field.is_secret ? 'password' : 'text',
|
||||
class: 'so-input so-custom-value',
|
||||
placeholder: hasOptions ? 'Pick or type…' : 'Value',
|
||||
autocomplete: field.is_secret ? 'new-password' : 'off',
|
||||
spellcheck: 'false',
|
||||
});
|
||||
// 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', {
|
||||
type: field.is_secret ? 'password' : 'text',
|
||||
class: 'so-input so-custom-value',
|
||||
placeholder: hasOptions ? 'Pick or type…' : 'Value',
|
||||
autocomplete: field.is_secret ? 'new-password' : 'off',
|
||||
spellcheck: 'false',
|
||||
});
|
||||
}
|
||||
valueInput.value = field.value || '';
|
||||
valueInput.addEventListener('input', () => {
|
||||
field.value = valueInput.value;
|
||||
soDirtyCheck();
|
||||
});
|
||||
if (hasOptions && !field.is_secret) {
|
||||
if (hasOptions && !field.is_secret && !field.multiline) {
|
||||
const combo = el('div', { class: 'so-combo' });
|
||||
valueInput.classList.add('so-combo-input');
|
||||
const arrow = el('button', {
|
||||
@@ -4213,11 +4254,12 @@ function buildCustomFieldRow(field, idx, rerender) {
|
||||
eye.addEventListener('click', ev => {
|
||||
ev.stopPropagation();
|
||||
if (!field.is_secret) return;
|
||||
if (field.multiline) { valueInput.classList.toggle('is-masked'); return; }
|
||||
// No-op on <select> — it has no type=password equivalent.
|
||||
if (valueInput.tagName !== 'INPUT') return;
|
||||
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';
|
||||
|
||||
// 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) {
|
||||
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;
|
||||
});
|
||||
let cfEnc = '', cfIv = '';
|
||||
|
||||
Reference in New Issue
Block a user