/// Field history view — shows password/concealed field history for an item. import { getState, setState, sendMessage, navigate, escapeHtml } from '../../shared/state'; import { colorizePassword } from '../../shared/password-coloring'; import type { FieldHistoryView } from '../../shared/types'; import { GLYPH_COPY } from '../../shared/glyphs'; function relativeTime(unixSec: number): string { const now = Math.floor(Date.now() / 1000); const diff = now - unixSec; if (diff < 60) return 'just now'; if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`; if (diff < 2592000) return `${Math.floor(diff / 604800)}w ago`; return `${Math.floor(diff / 2592000)}mo ago`; } const revealedSet = new Set(); // Map from entry key → plaintext value; populated on each render so we never // embed the secret in the DOM (no data-copy attribute holds the raw secret). const valueStore = new Map(); export function teardown(): void { revealedSet.clear(); valueStore.clear(); } export async function renderFieldHistory(app: HTMLElement): Promise { const state = getState(); const itemId = state.historyItemId; const item = state.selectedItem; if (!itemId || !item) { navigate('list'); return; } // Fetch field history const resp = await sendMessage({ type: 'get_field_history', id: itemId }); if (!resp.ok) { app.innerHTML = `

Failed to load history

`; return; } const history = (resp.data as { history: FieldHistoryView[] }).history; if (history.length === 0) { app.innerHTML = `

password history

No history available

`; app.querySelector('#back-btn')?.addEventListener('click', () => navigate('detail')); return; } // Rebuild the value store for this render pass valueStore.clear(); function renderEntry(fieldId: string, value: string, timestamp: number, isCurrent: boolean): string { const entryKey = `${fieldId}-${timestamp}`; const isRevealed = revealedSet.has(entryKey); const displayValue = isRevealed ? escapeHtml(value) : '••••••••••••'; valueStore.set(entryKey, value); return `
${displayValue}
`; } let content = ''; for (const field of history) { if (history.length > 1) { content += `
${escapeHtml(field.field_name)}
`; } // Current value first content += renderEntry(field.field_id, field.current_value, item.modified, true); // Historical values for (const entry of field.entries) { content += renderEntry(field.field_id, entry.value, entry.changed_at, false); } } app.innerHTML = `

password history

${escapeHtml(item.title)}
${content}
`; // Colorize revealed entries: replace plain-text content with colorized spans app.querySelectorAll('.history-entry__value.revealed').forEach((el) => { const key = el.closest('.history-entry')?.dataset.entry ?? ''; const plaintext = valueStore.get(key); if (plaintext !== undefined) { el.textContent = ''; el.appendChild(colorizePassword(plaintext)); } }); // Wire handlers app.querySelector('#back-btn')?.addEventListener('click', () => navigate('detail')); // Toggle reveal on click app.querySelectorAll('.history-entry').forEach((el) => { el.addEventListener('click', (e) => { if ((e.target as HTMLElement).classList.contains('history-entry__copy')) return; const key = el.dataset.entry; if (!key) return; if (revealedSet.has(key)) { revealedSet.delete(key); } else { revealedSet.add(key); } renderFieldHistory(app); }); }); // Copy buttons app.querySelectorAll('[data-entry-copy]').forEach((btn) => { btn.addEventListener('click', async (e) => { e.stopPropagation(); const key = btn.dataset.entryCopy ?? ''; const value = valueStore.get(key) ?? ''; await navigator.clipboard.writeText(value); btn.textContent = '✓'; setTimeout(() => { btn.textContent = GLYPH_COPY; }, 1500); }); }); }