From 3b4788e5dc1dd7f0d7185aa9e37b2978a6965812 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Mon, 27 Apr 2026 00:23:54 -0400 Subject: [PATCH] =?UTF-8?q?feat(ext/popup):=20field=20history=20view=20?= =?UTF-8?q?=E2=80=94=20masked=20values=20with=20reveal=20toggle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shows current + historical values for tracked fields (password/concealed). Click to reveal, copy button per entry (plaintext stored in a module-level Map, never embedded in the DOM). Grouped by field name if multiple tracked fields exist. Adds historyItemId to PopupState and 'field-history' to View. Co-Authored-By: Claude --- .../__tests__/field-history.test.ts | 69 +++++++++ .../src/popup/components/field-history.ts | 135 ++++++++++++++++++ extension/src/popup/popup.ts | 8 +- extension/src/popup/styles.css | 78 ++++++++++ 4 files changed, 289 insertions(+), 1 deletion(-) create mode 100644 extension/src/popup/components/__tests__/field-history.test.ts create mode 100644 extension/src/popup/components/field-history.ts diff --git a/extension/src/popup/components/__tests__/field-history.test.ts b/extension/src/popup/components/__tests__/field-history.test.ts new file mode 100644 index 0000000..78ab386 --- /dev/null +++ b/extension/src/popup/components/__tests__/field-history.test.ts @@ -0,0 +1,69 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { renderFieldHistory, teardown } from '../field-history'; + +// Mock popup module +vi.mock('../../popup', () => ({ + getState: vi.fn(() => ({ + historyItemId: 'item123', + selectedItem: { id: 'item123', title: 'Test Item', modified: 1000 }, + })), + setState: vi.fn(), + sendMessage: vi.fn(), + navigate: vi.fn(), + escapeHtml: (s: string) => s, +})); + +import { sendMessage, navigate } from '../../popup'; + +describe('field-history view', () => { + let app: HTMLElement; + + beforeEach(() => { + app = document.createElement('div'); + teardown(); + vi.clearAllMocks(); + }); + + it('renders empty state when no history', async () => { + (sendMessage as ReturnType).mockResolvedValueOnce({ + ok: true, + data: { history: [] }, + }); + + await renderFieldHistory(app); + + expect(app.innerHTML).toContain('No history available'); + }); + + it('renders history entries masked by default', async () => { + (sendMessage as ReturnType).mockResolvedValueOnce({ + ok: true, + data: { + history: [{ + field_id: 'f1', + field_name: 'password', + current_value: 'secret123', + entries: [{ value: 'oldpass', changed_at: 500 }], + }], + }, + }); + + await renderFieldHistory(app); + + expect(app.innerHTML).toContain('••••••••••••'); + expect(app.innerHTML).not.toContain('secret123'); + expect(app.innerHTML).toContain('current'); + }); + + it('back button navigates to detail', async () => { + (sendMessage as ReturnType).mockResolvedValueOnce({ + ok: true, + data: { history: [] }, + }); + + await renderFieldHistory(app); + app.querySelector('#back-btn')?.click(); + + expect(navigate).toHaveBeenCalledWith('detail'); + }); +}); diff --git a/extension/src/popup/components/field-history.ts b/extension/src/popup/components/field-history.ts new file mode 100644 index 0000000..e1f8e6f --- /dev/null +++ b/extension/src/popup/components/field-history.ts @@ -0,0 +1,135 @@ +/// Field history view — shows password/concealed field history for an item. + +import { getState, setState, sendMessage, navigate, escapeHtml } from '../popup'; +import type { FieldHistoryView } from '../../shared/types'; + +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} +
+ `; + + // 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 = '📋'; }, 1500); + }); + }); +} diff --git a/extension/src/popup/popup.ts b/extension/src/popup/popup.ts index 2111685..c5207d2 100644 --- a/extension/src/popup/popup.ts +++ b/extension/src/popup/popup.ts @@ -11,6 +11,7 @@ import { renderItemDetail } from './components/item-detail'; import { renderItemForm } from './components/item-form'; import { renderSettings } from './components/settings'; import { renderVaultSettings } from './components/settings-vault'; +import { renderFieldHistory } from './components/field-history'; // --- Escape HTML to prevent XSS --- export function escapeHtml(str: string): string { @@ -24,7 +25,7 @@ export function escapeHtml(str: string): string { // --- State --- -export type View = 'locked' | 'list' | 'detail' | 'add' | 'edit' | 'settings' | 'settings-vault'; +export type View = 'locked' | 'list' | 'detail' | 'add' | 'edit' | 'settings' | 'settings-vault' | 'field-history'; export interface PopupState { view: View; @@ -45,6 +46,7 @@ export interface PopupState { newType: import('../shared/types').ItemType | null; vaultSettings: import('../shared/types').VaultSettings | null; generatorDefaults: import('../shared/types').GeneratorRequest | null; + historyItemId: import('../shared/types').ItemId | null; } let currentState: PopupState = { @@ -62,6 +64,7 @@ let currentState: PopupState = { newType: null, vaultSettings: null, generatorDefaults: null, + historyItemId: null, }; export function getState(): PopupState { @@ -150,6 +153,9 @@ function render(): void { case 'settings-vault': renderVaultSettings(app); break; + case 'field-history': + renderFieldHistory(app); + break; } } diff --git a/extension/src/popup/styles.css b/extension/src/popup/styles.css index 267d3db..896c583 100644 --- a/extension/src/popup/styles.css +++ b/extension/src/popup/styles.css @@ -1133,3 +1133,81 @@ textarea { opacity: 0.5; cursor: default; } + +/* --- Field history view --- */ + +.history-header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 8px; +} + +.history-item-title { + font-size: 14px; + font-weight: 600; + color: #c9d1d9; + margin-bottom: 12px; +} + +.history-field-label { + font-size: 11px; + color: #8b949e; + text-transform: uppercase; + margin: 12px 0 6px; +} + +.history-entry { + display: flex; + align-items: center; + gap: 8px; + padding: 10px; + border-radius: 4px; + background: #161b22; + margin-bottom: 6px; + cursor: pointer; +} + +.history-entry:hover { + background: #1c2128; +} + +.history-entry__value { + flex: 1; + font-family: monospace; + font-size: 13px; +} + +.history-entry__value.masked { + color: #8b949e; +} + +.history-entry__value.revealed { + color: #c9d1d9; +} + +.history-entry__meta { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 2px; + font-size: 11px; + color: #8b949e; +} + +.history-entry__current { + color: #58a6ff; + font-weight: 500; +} + +.history-entry__copy { + background: none; + border: none; + cursor: pointer; + font-size: 14px; + padding: 4px; +} + +.history-entry__copy:hover { + opacity: 0.8; +}