feat(ext/popup): field history view — masked values with reveal toggle
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
data: { history: [] },
|
||||||
|
});
|
||||||
|
|
||||||
|
await renderFieldHistory(app);
|
||||||
|
app.querySelector<HTMLButtonElement>('#back-btn')?.click();
|
||||||
|
|
||||||
|
expect(navigate).toHaveBeenCalledWith('detail');
|
||||||
|
});
|
||||||
|
});
|
||||||
135
extension/src/popup/components/field-history.ts
Normal file
135
extension/src/popup/components/field-history.ts
Normal file
@@ -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<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">📋</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>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 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 = '📋'; }, 1500);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import { renderItemDetail } from './components/item-detail';
|
|||||||
import { renderItemForm } from './components/item-form';
|
import { renderItemForm } from './components/item-form';
|
||||||
import { renderSettings } from './components/settings';
|
import { renderSettings } from './components/settings';
|
||||||
import { renderVaultSettings } from './components/settings-vault';
|
import { renderVaultSettings } from './components/settings-vault';
|
||||||
|
import { renderFieldHistory } from './components/field-history';
|
||||||
|
|
||||||
// --- Escape HTML to prevent XSS ---
|
// --- Escape HTML to prevent XSS ---
|
||||||
export function escapeHtml(str: string): string {
|
export function escapeHtml(str: string): string {
|
||||||
@@ -24,7 +25,7 @@ export function escapeHtml(str: string): string {
|
|||||||
|
|
||||||
// --- State ---
|
// --- 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 {
|
export interface PopupState {
|
||||||
view: View;
|
view: View;
|
||||||
@@ -45,6 +46,7 @@ export interface PopupState {
|
|||||||
newType: import('../shared/types').ItemType | null;
|
newType: import('../shared/types').ItemType | null;
|
||||||
vaultSettings: import('../shared/types').VaultSettings | null;
|
vaultSettings: import('../shared/types').VaultSettings | null;
|
||||||
generatorDefaults: import('../shared/types').GeneratorRequest | null;
|
generatorDefaults: import('../shared/types').GeneratorRequest | null;
|
||||||
|
historyItemId: import('../shared/types').ItemId | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
let currentState: PopupState = {
|
let currentState: PopupState = {
|
||||||
@@ -62,6 +64,7 @@ let currentState: PopupState = {
|
|||||||
newType: null,
|
newType: null,
|
||||||
vaultSettings: null,
|
vaultSettings: null,
|
||||||
generatorDefaults: null,
|
generatorDefaults: null,
|
||||||
|
historyItemId: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getState(): PopupState {
|
export function getState(): PopupState {
|
||||||
@@ -150,6 +153,9 @@ function render(): void {
|
|||||||
case 'settings-vault':
|
case 'settings-vault':
|
||||||
renderVaultSettings(app);
|
renderVaultSettings(app);
|
||||||
break;
|
break;
|
||||||
|
case 'field-history':
|
||||||
|
renderFieldHistory(app);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1133,3 +1133,81 @@ textarea {
|
|||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
cursor: default;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user