- trash.ts TYPE_ICONS map uses GLYPH_TYPE_* constants - field-history.ts copy button uses GLYPH_COPY - attachments-disclosure.ts thumbnail/icon uses GLYPH_TYPE_DOCUMENT - settings.ts sync button uses GLYPH_SYNC - document.ts thumb/sigblock/preview use GLYPH_TYPE_DOCUMENT + GLYPH_PREVIEW - glyphs.ts adds GLYPH_COPY, GLYPH_SYNC, GLYPH_PREVIEW - vault.ts adds GLYPH_DEVICES import + devices sidebar nav button Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
148 lines
5.2 KiB
TypeScript
148 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 { 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<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);
|
|
|
|
return `
|
|
<div class="history-entry" data-entry="${escapeHtml(entryKey)}">
|
|
<div class="history-entry__value ${isRevealed ? 'revealed' : 'masked'}">${displayValue}</div>
|
|
<div class="history-entry__meta">
|
|
${isCurrent ? '<span class="history-entry__current">current</span>' : ''}
|
|
<span>${isCurrent ? 'set' : 'changed'} ${relativeTime(timestamp)}</span>
|
|
</div>
|
|
<button class="history-entry__copy" data-entry-copy="${escapeHtml(entryKey)}" title="Copy">${GLYPH_COPY}</button>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
let content = '';
|
|
for (const field of history) {
|
|
if (history.length > 1) {
|
|
content += `<div class="history-field-label">${escapeHtml(field.field_name)}</div>`;
|
|
}
|
|
// 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 = `
|
|
<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'));
|
|
|
|
// Toggle reveal on click
|
|
app.querySelectorAll<HTMLElement>('.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<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);
|
|
});
|
|
});
|
|
}
|