diff --git a/extension/src/popup/components/__tests__/fields.test.ts b/extension/src/popup/components/__tests__/fields.test.ts index 2647ace..f6cf0ab 100644 --- a/extension/src/popup/components/__tests__/fields.test.ts +++ b/extension/src/popup/components/__tests__/fields.test.ts @@ -115,3 +115,35 @@ describe('wireFieldHandlers', () => { expect(writeText).toHaveBeenCalledWith('alice@example.com'); }); }); + +describe('concealed-row round-trip with special characters', () => { + it('reveals a value containing double quotes correctly', () => { + document.body.innerHTML = renderConcealedRow({ id: 'pw', label: 'p', value: 'a"b"c' }); + wireFieldHandlers(document.body); + const btn = document.querySelector('[data-field-action="reveal"]') as HTMLButtonElement; + btn.click(); + const valueEl = document.querySelector('[data-field-role="value"]') as HTMLElement; + expect(valueEl.textContent).toBe('a"b"c'); + }); + + it('reveals a value containing single quotes correctly', () => { + document.body.innerHTML = renderConcealedRow({ id: 'pw', label: 'p', value: "it's & ok" }); + wireFieldHandlers(document.body); + const btn = document.querySelector('[data-field-action="reveal"]') as HTMLButtonElement; + btn.click(); + const valueEl = document.querySelector('[data-field-role="value"]') as HTMLElement; + expect(valueEl.textContent).toBe("it's & ok"); + }); + + it('copies a value containing double quotes correctly', async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText }, + }); + document.body.innerHTML = renderRow({ label: 'p', value: 'a"b"c', copyable: true }); + wireFieldHandlers(document.body); + (document.querySelector('[data-field-action="copy"]') as HTMLButtonElement).click(); + expect(writeText).toHaveBeenCalledWith('a"b"c'); + }); +}); diff --git a/extension/src/popup/components/item-detail.ts b/extension/src/popup/components/item-detail.ts index 45ead0b..5a522d7 100644 --- a/extension/src/popup/components/item-detail.ts +++ b/extension/src/popup/components/item-detail.ts @@ -7,6 +7,11 @@ import { getState } from '../popup'; import * as login from './types/login'; export async function renderItemDetail(app: HTMLElement): Promise { + // Tear down any tickers/handlers from a previous detail render before + // the next one boots up. Each type module owns its own teardown; we + // call all of them since the dispatcher doesn't know which was active. + login.teardown(); + const item = getState().selectedItem; if (!item) { navigate('list'); return; } diff --git a/extension/src/popup/components/item-form.ts b/extension/src/popup/components/item-form.ts index e22442e..7a4b5a1 100644 --- a/extension/src/popup/components/item-form.ts +++ b/extension/src/popup/components/item-form.ts @@ -6,6 +6,7 @@ import type { Item, ItemType } from '../../shared/types'; import * as login from './types/login'; export function renderItemForm(app: HTMLElement, mode: 'add' | 'edit'): void { + login.teardown(); // detail-view's ticker/listener don't leak into form const state = getState(); const existing = mode === 'edit' ? state.selectedItem : null; const type: ItemType = existing?.type ?? state.newType ?? 'login'; diff --git a/extension/src/popup/components/types/login.ts b/extension/src/popup/components/types/login.ts index 6cf4db7..90a291e 100644 --- a/extension/src/popup/components/types/login.ts +++ b/extension/src/popup/components/types/login.ts @@ -12,6 +12,16 @@ import { wireFieldHandlers, } from '../fields'; +/// Called by the dispatcher before each render. Stops any in-flight +/// tickers / intervals / listeners the previous view may have attached. +export function teardown(): void { + stopTotpTicker(); + if (activeKeyHandler) { + document.removeEventListener('keydown', activeKeyHandler); + activeKeyHandler = null; + } +} + // ---------------------------------------------------------------------- // Detail view // ---------------------------------------------------------------------- @@ -58,10 +68,17 @@ export async function renderDetail(app: HTMLElement, item: Item): Promise wireFieldHandlers(app); - document.getElementById('back-btn')?.addEventListener('click', () => navigate('list')); - document.getElementById('edit-btn')?.addEventListener('click', () => navigate('edit')); + document.getElementById('back-btn')?.addEventListener('click', () => { + teardown(); + navigate('list'); + }); + document.getElementById('edit-btn')?.addEventListener('click', () => { + teardown(); + navigate('edit'); + }); document.getElementById('trash-btn')?.addEventListener('click', async () => { if (!confirm(`Move "${item.title}" to trash?`)) return; + teardown(); const resp = await sendMessage({ type: 'delete_item', id: item.id }); if (!resp.ok) { setState({ error: resp.error }); return; } const listResp = await sendMessage({ type: 'list_items' }); @@ -77,23 +94,77 @@ export async function renderDetail(app: HTMLElement, item: Item): Promise type: 'fill_credentials', id: item.id, capturedTabId, capturedUrl, }); if (!resp.ok) setState({ error: resp.error }); - else window.close(); + else { teardown(); window.close(); } }); if (hasTotp) startTotpTicker(item.id); - const handler = (e: KeyboardEvent) => { + const handler = async (e: KeyboardEvent) => { + // Bail if the user is typing in an editable field — don't steal printable keystrokes. const t = e.target; if (t instanceof HTMLElement) { const tag = t.tagName; if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || t.isContentEditable) return; } - if (e.key === 'Escape') { - document.removeEventListener('keydown', handler); - stopTotpTicker(); - navigate('list'); + + switch (e.key) { + case 'Escape': + teardown(); + navigate('list'); + break; + + case 'c': + if (username) { + try { await navigator.clipboard.writeText(username); } catch { /* no-op */ } + } + break; + + case 'p': + try { await navigator.clipboard.writeText(password); } catch { /* no-op */ } + break; + + case 't': + if (hasTotp) { + const codeEl = document.getElementById('totp-code'); + const code = codeEl?.textContent?.trim(); + if (code && code !== '…') { + try { await navigator.clipboard.writeText(code); } catch { /* no-op */ } + } + } + break; + + case 'f': { + const { capturedTabId, capturedUrl } = getState(); + if (capturedTabId === null) { setState({ error: 'No active tab captured' }); break; } + const resp = await sendMessage({ + type: 'fill_credentials', id: item.id, capturedTabId, capturedUrl, + }); + if (!resp.ok) setState({ error: resp.error }); + else { teardown(); window.close(); } + break; + } + + case 'e': + teardown(); + navigate('edit'); + break; + + case 'd': + e.preventDefault(); + if (confirm(`Move "${item.title}" to trash?`)) { + teardown(); + const resp = await sendMessage({ type: 'delete_item', id: item.id }); + if (!resp.ok) { setState({ error: resp.error }); return; } + const listResp = await sendMessage({ type: 'list_items' }); + if (listResp.ok) { + const data = listResp.data as { items: Array<[ItemId, ManifestEntry]> }; + navigate('list', { entries: data.items, selectedId: null, selectedItem: null }); + } else navigate('list'); + } + break; } }; + activeKeyHandler = handler; document.addEventListener('keydown', handler); } @@ -102,6 +173,7 @@ export async function renderDetail(app: HTMLElement, item: Item): Promise // ---------------------------------------------------------------------- let totpTickerId: ReturnType | null = null; +let activeKeyHandler: ((e: KeyboardEvent) => void) | null = null; function stopTotpTicker(): void { if (totpTickerId !== null) { clearInterval(totpTickerId); totpTickerId = null; } } diff --git a/extension/src/popup/popup.ts b/extension/src/popup/popup.ts index 2f8131c..95320f8 100644 --- a/extension/src/popup/popup.ts +++ b/extension/src/popup/popup.ts @@ -13,9 +13,12 @@ import { renderSettings } from './components/settings'; // --- Escape HTML to prevent XSS --- export function escapeHtml(str: string): string { - const div = document.createElement('div'); - div.textContent = str; - return div.innerHTML; + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); } // --- State ---