Files
relicario/extension/src/popup/components/fields.ts
adlee-was-taken 6bca0b3526 feat(ext/popup/item-detail): colorize revealed password field
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.
2026-05-02 18:49:56 -04:00

367 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// 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 };
}
});
});
}