Add data-field-kind attribute to renderConcealedRow so wireFieldHandlers can distinguish password fields from other concealed rows (TOTP secrets, CVV, PIN, private keys). Apply colorizePassword() on reveal when kind is "password"; plain textContent otherwise. Pass kind through renderSections for custom-section password fields.
367 lines
15 KiB
TypeScript
367 lines
15 KiB
TypeScript
/// Field rendering primitives used by every typed-item detail view.
|
||
///
|
||
/// Pure functions that return HTML strings. Caller is responsible for
|
||
/// mounting the strings into the DOM (typically via `app.innerHTML = ...`).
|
||
/// After mounting, call `wireFieldHandlers(scope)` once to bind reveal +
|
||
/// copy click handlers on any rendered rows.
|
||
|
||
import { escapeHtml } from '../../shared/state';
|
||
import { colorizePassword } from '../../shared/password-coloring';
|
||
import type { Item, Section, Field, FieldValue } from '../../shared/types';
|
||
|
||
export interface RowOpts {
|
||
label: string;
|
||
value: string;
|
||
copyable?: boolean;
|
||
href?: string;
|
||
monospace?: boolean;
|
||
multiline?: boolean;
|
||
}
|
||
|
||
/// Plain label/value row. Optional copy button, optional anchor wrap,
|
||
/// optional monospace styling, optional multiline (renders in a <pre>).
|
||
export function renderRow(opts: RowOpts): string {
|
||
const { label, value, copyable, href, monospace, multiline } = opts;
|
||
const valueClass = `field-row__value${monospace ? ' monospace' : ''}`;
|
||
let valueHtml: string;
|
||
if (multiline) {
|
||
valueHtml = `<pre>${escapeHtml(value)}</pre>`;
|
||
} else if (href) {
|
||
valueHtml = `<a href="${escapeHtml(href)}" target="_blank" rel="noopener noreferrer">${escapeHtml(value)}</a>`;
|
||
} else {
|
||
valueHtml = escapeHtml(value);
|
||
}
|
||
const actions = copyable
|
||
? `<button type="button" data-field-action="copy" data-field-value="${escapeHtml(value)}">copy</button>`
|
||
: '';
|
||
return `
|
||
<div class="field-row">
|
||
<span class="field-row__label">${escapeHtml(label)}</span>
|
||
<span class="${valueClass}" data-field-role="value">${valueHtml}</span>
|
||
<span class="field-row__actions">${actions}</span>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
export interface ConcealedRowOpts {
|
||
id: string;
|
||
label: string;
|
||
value: string;
|
||
kind?: 'password' | 'concealed';
|
||
monospace?: boolean;
|
||
multiline?: boolean;
|
||
}
|
||
|
||
/// Concealed row — value rendered hidden until the user clicks "show".
|
||
/// Plaintext is stored in `data-field-value` on the row element and copied
|
||
/// to the visible value span on reveal. Copy button always copies plaintext.
|
||
/// When `kind` is "password", wireFieldHandlers applies colorizePassword on
|
||
/// reveal so digits/symbols/letters are rendered in distinct colours.
|
||
export function renderConcealedRow(opts: ConcealedRowOpts): string {
|
||
const { id, label, value, kind, monospace, multiline } = opts;
|
||
const placeholder = multiline ? `•••• (${value.length} chars)` : '••••';
|
||
const valueClass = `field-row__value${monospace ? ' monospace' : ''}`;
|
||
const kindAttr = kind ? ` data-field-kind="${escapeHtml(kind)}"` : '';
|
||
return `
|
||
<div class="field-row" data-field-id="${escapeHtml(id)}" data-revealed="false" data-field-value="${escapeHtml(value)}" data-field-multiline="${multiline ? 'true' : 'false'}"${kindAttr}>
|
||
<span class="field-row__label">${escapeHtml(label)}</span>
|
||
<span class="${valueClass}" data-field-role="value">${escapeHtml(placeholder)}</span>
|
||
<span class="field-row__actions">
|
||
<button type="button" data-field-action="reveal">show</button>
|
||
<button type="button" data-field-action="copy" data-field-value="${escapeHtml(value)}">copy</button>
|
||
</span>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
export interface SignatureBlockOpts {
|
||
accent?: 'gold' | 'green' | 'amber' | 'red';
|
||
children: string;
|
||
}
|
||
|
||
/// Container for the type-specific signature panel. `children` is HTML
|
||
/// the caller has already produced (and escaped where needed).
|
||
export function renderSignatureBlock(opts: SignatureBlockOpts): string {
|
||
const accent = opts.accent ?? 'gold';
|
||
return `
|
||
<div class="sig-block sig-block--${accent}">${opts.children}</div>
|
||
`;
|
||
}
|
||
|
||
/// Wire reveal-toggle + copy click handlers within `scope`. Idempotent —
|
||
/// safe to call multiple times against the same scope.
|
||
export function wireFieldHandlers(scope: HTMLElement): void {
|
||
scope.querySelectorAll<HTMLButtonElement>('[data-field-action="reveal"]').forEach((btn) => {
|
||
btn.addEventListener('click', () => {
|
||
const row = btn.closest('[data-field-id]') as HTMLElement | null;
|
||
if (!row) return;
|
||
const valueEl = row.querySelector('[data-field-role="value"]') as HTMLElement | null;
|
||
if (!valueEl) return;
|
||
const revealed = row.getAttribute('data-revealed') === 'true';
|
||
const plaintext = row.getAttribute('data-field-value') ?? '';
|
||
const multiline = row.getAttribute('data-field-multiline') === 'true';
|
||
if (revealed) {
|
||
const placeholder = multiline ? `•••• (${plaintext.length} chars)` : '••••';
|
||
valueEl.textContent = placeholder;
|
||
row.setAttribute('data-revealed', 'false');
|
||
btn.textContent = 'show';
|
||
} else {
|
||
const isPassword = row.getAttribute('data-field-kind') === 'password';
|
||
valueEl.textContent = '';
|
||
if (isPassword) {
|
||
valueEl.appendChild(colorizePassword(plaintext));
|
||
} else {
|
||
valueEl.textContent = plaintext;
|
||
}
|
||
row.setAttribute('data-revealed', 'true');
|
||
btn.textContent = 'hide';
|
||
}
|
||
});
|
||
});
|
||
|
||
scope.querySelectorAll<HTMLButtonElement>('[data-field-action="copy"]').forEach((btn) => {
|
||
btn.addEventListener('click', async () => {
|
||
const value = btn.getAttribute('data-field-value') ?? '';
|
||
try { await navigator.clipboard.writeText(value); } catch { /* swallow — UX is the visual flash below */ }
|
||
const original = btn.textContent;
|
||
btn.textContent = 'copied';
|
||
setTimeout(() => { if (btn.textContent === 'copied') btn.textContent = original; }, 1500);
|
||
});
|
||
});
|
||
}
|
||
|
||
/// Render an Item's sections as read-only field rows. Each section with
|
||
/// ≥1 field emits a header (if named) or thin separator (if anonymous)
|
||
/// plus field rows via renderRow / renderConcealedRow. Sections with
|
||
/// 0 fields are skipped. Fields with unsupported kinds are silently
|
||
/// skipped (β₂ supports text, password, concealed only).
|
||
///
|
||
/// `idPrefix` uniquifies concealed-row IDs (`${idPrefix}-s{i}-f{j}`)
|
||
/// so multiple typed-item detail views rendered in sequence don't
|
||
/// collide on wireFieldHandlers lookups.
|
||
export function renderSections(item: Item, idPrefix: string): string {
|
||
let out = '';
|
||
item.sections.forEach((section, sIdx) => {
|
||
const visibleFields = section.fields.filter(
|
||
(f) => f.value.kind === 'text' || f.value.kind === 'password' || f.value.kind === 'concealed',
|
||
);
|
||
if (visibleFields.length === 0) return;
|
||
|
||
if (section.name) {
|
||
out += `<div class="section-header">${escapeHtml(section.name)}</div>`;
|
||
} else {
|
||
out += `<hr class="section-separator">`;
|
||
}
|
||
|
||
visibleFields.forEach((field, fIdx) => {
|
||
if (field.value.kind === 'text') {
|
||
out += renderRow({ label: field.label, value: field.value.value, copyable: true });
|
||
} else if (field.value.kind === 'password' || field.value.kind === 'concealed') {
|
||
out += renderConcealedRow({
|
||
id: `${idPrefix}-s${sIdx}-f${fIdx}`,
|
||
label: field.label,
|
||
value: field.value.value,
|
||
kind: field.value.kind,
|
||
});
|
||
}
|
||
});
|
||
});
|
||
return out;
|
||
}
|
||
|
||
/// 16-char hex FieldId. crypto.getRandomValues for 8 bytes.
|
||
export function generateFieldId(): string {
|
||
const bytes = new Uint8Array(8);
|
||
crypto.getRandomValues(bytes);
|
||
return Array.from(bytes).map((b) => b.toString(16).padStart(2, '0')).join('');
|
||
}
|
||
|
||
function makeField(kind: 'text' | 'password' | 'concealed'): Field {
|
||
const value: FieldValue = { kind, value: '' };
|
||
return {
|
||
id: generateFieldId(),
|
||
label: 'new field',
|
||
kind,
|
||
value,
|
||
hidden_by_default: kind !== 'text',
|
||
};
|
||
}
|
||
|
||
/// Render the collapsible custom-sections editor. Returns HTML for the
|
||
/// disclosure toggle + body. The expanded-state is owned externally
|
||
/// (via a module-scope flag in the caller); this helper reads it as
|
||
/// the `expanded` parameter.
|
||
export function renderSectionsEditor(sections: Section[], expanded: boolean): string {
|
||
const sectionCount = sections.length;
|
||
const fieldCount = sections.reduce((sum, s) => sum + s.fields.length, 0);
|
||
const sectionLabel = sectionCount === 1 ? '1 section' : `${sectionCount} sections`;
|
||
const fieldLabel = fieldCount === 1 ? '1 field' : `${fieldCount} fields`;
|
||
const summary = sectionCount === 0 && fieldCount === 0
|
||
? 'no custom fields'
|
||
: `${sectionLabel}, ${fieldLabel}`;
|
||
|
||
const body = sections.map((section, sIdx) => renderSectionBlock(section, sIdx)).join('');
|
||
|
||
return `
|
||
<div class="disclosure" data-expanded="${expanded ? 'true' : 'false'}">
|
||
<button type="button" class="disclosure__toggle">▾ custom sections & fields (${escapeHtml(summary)})</button>
|
||
<div class="disclosure__body">
|
||
${body}
|
||
<button type="button" class="add-section">+ add section</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderSectionBlock(section: Section, sIdx: number): string {
|
||
const nameDisplay = section.name
|
||
? `<span class="name">${escapeHtml(section.name)}</span>`
|
||
: `<span class="name anon">(anonymous)</span>`;
|
||
|
||
// Only render supported kinds. Other-kind fields stay in sectionsDraft
|
||
// untouched so they survive save intact.
|
||
const editable = section.fields.filter(
|
||
(f) => f.value.kind === 'text' || f.value.kind === 'password' || f.value.kind === 'concealed',
|
||
);
|
||
const fieldsHtml = editable.map((f) => renderEditorField(f, sIdx, 0)).join('');
|
||
|
||
const preservedCount = section.fields.length - editable.length;
|
||
const preservedNote = preservedCount > 0
|
||
? `<div class="section-editor__preserved">${preservedCount} field${preservedCount === 1 ? '' : 's'} of unsupported kind (edit via CLI)</div>`
|
||
: '';
|
||
|
||
return `
|
||
<div class="section-editor" data-section-idx="${sIdx}">
|
||
<div class="section-editor__head">
|
||
${nameDisplay}
|
||
<span class="actions">
|
||
<button type="button" data-rename-section="${sIdx}">rename</button>
|
||
<button type="button" data-remove-section="${sIdx}">× remove section</button>
|
||
</span>
|
||
</div>
|
||
${fieldsHtml}
|
||
${preservedNote}
|
||
<div class="section-editor__add">
|
||
<button type="button" data-add-field="text" data-section-idx="${sIdx}">+ text</button>
|
||
<button type="button" data-add-field="password" data-section-idx="${sIdx}">+ password</button>
|
||
<button type="button" data-add-field="concealed" data-section-idx="${sIdx}">+ concealed</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderEditorField(field: Field, sIdx: number, _fIdx: number): string {
|
||
const valueStr = (field.value.kind === 'text' || field.value.kind === 'password' || field.value.kind === 'concealed')
|
||
? field.value.value
|
||
: '';
|
||
const inputType = field.value.kind === 'text' ? 'text' : 'password';
|
||
return `
|
||
<div class="section-editor__field">
|
||
<input type="text" data-field-label="${escapeHtml(field.id)}" value="${escapeHtml(field.label)}" placeholder="label">
|
||
<input type="${inputType}" data-field-value-input="${escapeHtml(field.id)}" value="${escapeHtml(valueStr)}" placeholder="value">
|
||
<button type="button" class="delete-field" data-delete-field="${escapeHtml(field.id)}" data-section-idx="${sIdx}">×</button>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function findField(
|
||
sectionsDraft: Section[],
|
||
fieldId: string,
|
||
): { section: Section; fieldIdx: number } | null {
|
||
for (const section of sectionsDraft) {
|
||
const idx = section.fields.findIndex((f) => f.id === fieldId);
|
||
if (idx >= 0) return { section, fieldIdx: idx };
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/// Wire click + input handlers on a rendered sections-editor. Mutations
|
||
/// happen in place on `sectionsDraft`. `rerender` is called after any
|
||
/// structural change (add/remove) to regenerate the disclosure body;
|
||
/// label/value edits do NOT trigger rerender (would steal focus).
|
||
export function wireSectionsEditor(
|
||
scope: HTMLElement,
|
||
sectionsDraft: Section[],
|
||
rerender: () => void,
|
||
): void {
|
||
const toggle = scope.querySelector('.disclosure__toggle') as HTMLButtonElement | null;
|
||
toggle?.addEventListener('click', () => {
|
||
const disclosure = scope.querySelector('.disclosure') as HTMLElement | null;
|
||
if (!disclosure) return;
|
||
const expanded = disclosure.getAttribute('data-expanded') === 'true';
|
||
disclosure.setAttribute('data-expanded', expanded ? 'false' : 'true');
|
||
});
|
||
|
||
scope.querySelector('.add-section')?.addEventListener('click', () => {
|
||
sectionsDraft.push({ name: undefined, fields: [] });
|
||
rerender();
|
||
});
|
||
|
||
scope.querySelectorAll<HTMLButtonElement>('[data-rename-section]').forEach((btn) => {
|
||
btn.addEventListener('click', () => {
|
||
const sIdx = Number(btn.dataset.renameSection);
|
||
const current = sectionsDraft[sIdx]?.name ?? '';
|
||
const name = window.prompt('Section name (empty for none):', current);
|
||
if (name === null) return;
|
||
const trimmed = name.trim();
|
||
sectionsDraft[sIdx].name = trimmed || undefined;
|
||
rerender();
|
||
});
|
||
});
|
||
|
||
scope.querySelectorAll<HTMLButtonElement>('[data-remove-section]').forEach((btn) => {
|
||
btn.addEventListener('click', () => {
|
||
const sIdx = Number(btn.dataset.removeSection);
|
||
const name = sectionsDraft[sIdx]?.name ?? '(anonymous)';
|
||
if (!window.confirm(`Remove section "${name}" and all its fields?`)) return;
|
||
sectionsDraft.splice(sIdx, 1);
|
||
rerender();
|
||
});
|
||
});
|
||
|
||
scope.querySelectorAll<HTMLButtonElement>('[data-add-field]').forEach((btn) => {
|
||
btn.addEventListener('click', () => {
|
||
const sIdx = Number(btn.dataset.sectionIdx);
|
||
const kind = btn.dataset.addField as 'text' | 'password' | 'concealed';
|
||
sectionsDraft[sIdx].fields.push(makeField(kind));
|
||
rerender();
|
||
});
|
||
});
|
||
|
||
scope.querySelectorAll<HTMLButtonElement>('[data-delete-field]').forEach((btn) => {
|
||
btn.addEventListener('click', () => {
|
||
const fieldId = btn.dataset.deleteField ?? '';
|
||
const found = findField(sectionsDraft, fieldId);
|
||
if (!found) return;
|
||
found.section.fields = found.section.fields.filter((f) => f.id !== fieldId);
|
||
rerender();
|
||
});
|
||
});
|
||
|
||
scope.querySelectorAll<HTMLInputElement>('[data-field-label]').forEach((input) => {
|
||
input.addEventListener('input', () => {
|
||
const fieldId = input.dataset.fieldLabel ?? '';
|
||
const found = findField(sectionsDraft, fieldId);
|
||
if (found) {
|
||
found.section.fields[found.fieldIdx].label = input.value;
|
||
}
|
||
});
|
||
});
|
||
|
||
scope.querySelectorAll<HTMLInputElement>('[data-field-value-input]').forEach((input) => {
|
||
input.addEventListener('input', () => {
|
||
const fieldId = input.dataset.fieldValueInput ?? '';
|
||
const found = findField(sectionsDraft, fieldId);
|
||
if (!found) return;
|
||
const field = found.section.fields[found.fieldIdx];
|
||
// Only mutate supported kinds. Unsupported kinds are never rendered
|
||
// as editable (filtered by renderSectionBlock), so this path shouldn't
|
||
// fire for them — but guard defensively.
|
||
if (field.value.kind === 'text' || field.value.kind === 'password' || field.value.kind === 'concealed') {
|
||
const kind = field.value.kind;
|
||
field.value = { kind, value: input.value };
|
||
}
|
||
});
|
||
});
|
||
}
|