Files
relicario/extension/src/popup/components/field-history.ts

136 lines
5.2 KiB
TypeScript

/// 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 { relativeTime } from '../../shared/relative-time';
import { GLYPH_COPY, GLYPH_REVEAL, GLYPH_HIDE } from '../../shared/glyphs';
const revealedSet = new Set<string>();
// 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<string, string>();
export function teardown(): void {
revealedSet.clear();
valueStore.clear();
}
export async function renderFieldHistory(app: HTMLElement): Promise<void> {
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 = `<div class="pad"><p class="error">Failed to load history</p></div>`;
return;
}
const history = (resp.data as { history: FieldHistoryView[] }).history;
if (history.length === 0) {
app.innerHTML = `
<div class="pad">
<div class="history-header">
<button class="btn" id="back-btn">← back to item</button>
<h3 style="margin:0;">password history</h3>
</div>
<p class="muted" style="text-align:center;margin-top:32px;">No history available</p>
</div>
`;
app.querySelector<HTMLButtonElement>('#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);
const revealGlyph = isRevealed ? GLYPH_HIDE : GLYPH_REVEAL;
return `
<div class="history-entry" data-entry="${escapeHtml(entryKey)}">
<div class="history-entry__value ${isRevealed ? 'revealed' : 'masked'}">${displayValue}</div>
<div class="history-entry__meta muted">
${isCurrent ? '<span class="history-entry__current">current · </span>' : ''}
${isCurrent ? 'set' : 'changed'} ${escapeHtml(relativeTime(timestamp))}
</div>
<div class="history-entry__actions">
<button class="glyph-btn" data-entry-reveal="${escapeHtml(entryKey)}" title="${isRevealed ? 'hide' : 'reveal'}" aria-label="${isRevealed ? 'hide' : 'reveal'}">${revealGlyph}</button>
<button class="glyph-btn" data-entry-copy="${escapeHtml(entryKey)}" title="copy" aria-label="copy">${GLYPH_COPY}</button>
</div>
</div>
`;
}
let content = '';
for (const field of history) {
const entryCount = field.entries.length + 1; // +1 for current
content += `<div class="section-header">${escapeHtml(field.field_name.toUpperCase())} · ${entryCount} ${entryCount === 1 ? 'entry' : 'entries'}</div>`;
content += renderEntry(field.field_id, field.current_value, item.modified, true);
for (const entry of field.entries) {
content += renderEntry(field.field_id, entry.value, entry.changed_at, false);
}
}
app.innerHTML = `
<div class="pad">
<div class="history-header">
<button class="btn" id="back-btn">← back to item</button>
<h3 style="margin:0;">password history</h3>
</div>
<div class="history-item-title">${escapeHtml(item.title)}</div>
${content}
</div>
`;
// Colorize revealed entries: replace plain-text content with colorized spans
app.querySelectorAll<HTMLElement>('.history-entry__value.revealed').forEach((el) => {
const key = el.closest<HTMLElement>('.history-entry')?.dataset.entry ?? '';
const plaintext = valueStore.get(key);
if (plaintext !== undefined) {
el.textContent = '';
el.appendChild(colorizePassword(plaintext));
}
});
// Wire handlers
app.querySelector<HTMLButtonElement>('#back-btn')?.addEventListener('click', () => navigate('detail'));
// Reveal toggle via explicit glyph button (decoupled from row click)
app.querySelectorAll<HTMLButtonElement>('[data-entry-reveal]').forEach((btn) => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const key = btn.dataset.entryReveal;
if (!key) return;
if (revealedSet.has(key)) revealedSet.delete(key);
else revealedSet.add(key);
renderFieldHistory(app);
});
});
// Copy buttons
app.querySelectorAll<HTMLButtonElement>('[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);
});
});
}