diff --git a/extension/src/popup/components/__tests__/settings-vault.test.ts b/extension/src/popup/components/__tests__/settings-vault.test.ts new file mode 100644 index 0000000..7e836c7 --- /dev/null +++ b/extension/src/popup/components/__tests__/settings-vault.test.ts @@ -0,0 +1,98 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../popup', async () => { + const navigate = vi.fn(); + const setState = vi.fn(); + const sendMessage = vi.fn(); + const getState = vi.fn(() => ({ + view: 'settings-vault', + entries: [], selectedId: null, selectedItem: null, selectedIndex: 0, + searchQuery: '', activeGroup: null, error: null, loading: false, + capturedTabId: null, capturedUrl: '', newType: null, + vaultSettings: { + trash_retention: { kind: 'days', value: 30 }, + field_history_retention: { kind: 'forever' }, + generator_defaults: { + kind: 'random', length: 20, + classes: { lower: true, upper: true, digits: true, symbols: true }, + symbol_charset: { kind: 'safe_only' }, + }, + attachment_caps: {}, + autofill_origin_acks: { 'github.com': 1000000000, 'example.com': 999000000 }, + }, + generatorDefaults: null, + })); + const escapeHtml = (s: string) => s + .replace(/&/g, '&').replace(//g, '>') + .replace(/"/g, '"').replace(/'/g, '''); + return { navigate, setState, sendMessage, getState, escapeHtml }; +}); + +vi.mock('../generator-popover', () => ({ + openGeneratorPopover: vi.fn(), + closeGeneratorPopover: vi.fn(), +})); + +import { renderVaultSettings } from '../settings-vault'; +import { sendMessage } from '../../popup'; + +describe('settings-vault', () => { + beforeEach(() => { + document.body.innerHTML = '
'; + vi.mocked(sendMessage).mockReset(); + vi.mocked(sendMessage).mockResolvedValue({ ok: true }); + }); + + it('renders with seeded vault-settings values', () => { + const app = document.getElementById('app')!; + renderVaultSettings(app); + expect(app.textContent).toContain('vault settings'); + expect(app.textContent).toContain('github.com'); + expect(app.textContent).toContain('example.com'); + const trashSel = document.getElementById('trash-retention') as HTMLSelectElement; + expect(trashSel.value).toBe('days:30'); + const histSel = document.getElementById('history-retention') as HTMLSelectElement; + expect(histSel.value).toBe('forever'); + }); + + it('renders origin acks sorted by recency (descending)', () => { + const app = document.getElementById('app')!; + renderVaultSettings(app); + const rows = Array.from(document.querySelectorAll('.ack-row__host')).map((e) => e.textContent); + expect(rows).toEqual(['github.com', 'example.com']); + }); + + it('save button disabled until a change is made', () => { + const app = document.getElementById('app')!; + renderVaultSettings(app); + const saveBtn = document.getElementById('save-btn') as HTMLButtonElement; + expect(saveBtn.disabled).toBe(true); + const trashSel = document.getElementById('trash-retention') as HTMLSelectElement; + trashSel.value = 'forever'; + trashSel.dispatchEvent(new Event('change', { bubbles: true })); + expect(saveBtn.disabled).toBe(false); + }); + + it('revoke button removes origin from pending and enables save', () => { + const app = document.getElementById('app')!; + renderVaultSettings(app); + (document.querySelector('[data-revoke="github.com"]') as HTMLButtonElement).click(); + expect(document.querySelector('[data-revoke="github.com"]')).toBeNull(); + expect((document.getElementById('save-btn') as HTMLButtonElement).disabled).toBe(false); + }); + + it('save button triggers update_vault_settings with pending', async () => { + const app = document.getElementById('app')!; + renderVaultSettings(app); + (document.querySelector('[data-revoke="github.com"]') as HTMLButtonElement).click(); + (document.getElementById('save-btn') as HTMLButtonElement).click(); + await new Promise((r) => setTimeout(r, 10)); + const call = vi.mocked(sendMessage).mock.calls.find( + ([m]) => (m as any).type === 'update_vault_settings', + ); + expect(call).toBeDefined(); + const payload = call![0] as { settings: any }; + expect(payload.settings.autofill_origin_acks).not.toHaveProperty('github.com'); + expect(payload.settings.autofill_origin_acks).toHaveProperty('example.com'); + }); +}); diff --git a/extension/src/popup/components/settings-vault.ts b/extension/src/popup/components/settings-vault.ts new file mode 100644 index 0000000..1e744e6 --- /dev/null +++ b/extension/src/popup/components/settings-vault.ts @@ -0,0 +1,236 @@ +/// Vault-level settings screen. Covers retention (trash + field history), +/// generator defaults (preview + "configure" → opens popover), and +/// autofill origin-ack revocation. + +import { getState, setState, sendMessage, navigate, escapeHtml } from '../popup'; +import type { + VaultSettings, TrashRetention, HistoryRetention, GeneratorRequest, +} from '../../shared/types'; +import { openGeneratorPopover } from './generator-popover'; + +let pendingSettings: VaultSettings | null = null; +let activeKeyHandler: ((e: KeyboardEvent) => void) | null = null; + +export function teardown(): void { + if (activeKeyHandler) { + document.removeEventListener('keydown', activeKeyHandler); + activeKeyHandler = null; + } + pendingSettings = null; +} + +// --- Retention helpers --- + +function trashRetentionToValue(r: TrashRetention): string { + if (r.kind === 'forever') return 'forever'; + return `days:${r.value}`; +} + +function valueToTrashRetention(v: string): TrashRetention { + if (v === 'forever') return { kind: 'forever' }; + const m = /^days:(\d+)$/.exec(v); + if (m) return { kind: 'days', value: Number(m[1]) }; + return { kind: 'forever' }; +} + +function historyRetentionToValue(r: HistoryRetention): string { + if (r.kind === 'forever') return 'forever'; + if (r.kind === 'last_n') return `last_n:${r.value}`; + return `days:${r.value}`; +} + +function valueToHistoryRetention(v: string): HistoryRetention { + if (v === 'forever') return { kind: 'forever' }; + const mLast = /^last_n:(\d+)$/.exec(v); + if (mLast) return { kind: 'last_n', value: Number(mLast[1]) }; + const mDays = /^days:(\d+)$/.exec(v); + if (mDays) return { kind: 'days', value: Number(mDays[1]) }; + return { kind: 'forever' }; +} + +// --- Generator summary --- + +function generatorSummary(req: GeneratorRequest): string { + if (req.kind === 'random') { + const classes: string[] = []; + if (req.classes.lower) classes.push('lower'); + if (req.classes.upper) classes.push('upper'); + if (req.classes.digits) classes.push('digits'); + if (req.classes.symbols) classes.push('symbols'); + const sc = req.symbol_charset.kind; + return `Random, ${req.length} chars, ${classes.join('+') || 'no classes'}, ${sc} symbols`; + } + return `BIP39, ${req.word_count} words, "${req.separator}" separator, ${req.capitalization}`; +} + +// --- Time formatting --- + +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`; + return `${Math.floor(diff / 86400)}d ago`; +} + +// --- Render --- + +export function renderVaultSettings(app: HTMLElement): void { + const state = getState(); + const base = state.vaultSettings; + if (!base) { + app.innerHTML = `Vault settings not loaded yet.
${escapeHtml(generatorSummary(pendingSettings.generator_defaults))}
+ +No origins acknowledged yet.
` + : acksEntries.map(([host, ts]) => ` +