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.

`; + return; + } + pendingSettings = JSON.parse(JSON.stringify(base)) as VaultSettings; + + function rerender(): void { + if (!pendingSettings) return; + const acksEntries = Object.entries(pendingSettings.autofill_origin_acks) + .sort(([, a], [, b]) => b - a); + + app.innerHTML = ` +
+
+ +

vault settings

+
+ +
+
retention
+
+ trash + +
+
+ field history + +
+
+ +
+
generator
+

${escapeHtml(generatorSummary(pendingSettings.generator_defaults))}

+ +
+ +
+
autofill origins
+ ${acksEntries.length === 0 + ? `

No origins acknowledged yet.

` + : acksEntries.map(([host, ts]) => ` +
+ ${escapeHtml(host)} + ${escapeHtml(relativeTime(ts))} + +
+ `).join('')} +
+ + +
+ `; + + // Set current select values + (document.getElementById('trash-retention') as HTMLSelectElement).value = + trashRetentionToValue(pendingSettings.trash_retention); + (document.getElementById('history-retention') as HTMLSelectElement).value = + historyRetentionToValue(pendingSettings.field_history_retention); + + wireHandlers(); + updateSaveEnabled(); + } + + function updateSaveEnabled(): void { + const saveBtn = document.getElementById('save-btn') as HTMLButtonElement | null; + if (!saveBtn || !pendingSettings || !base) return; + const changed = JSON.stringify(pendingSettings) !== JSON.stringify(base); + saveBtn.disabled = !changed; + } + + function wireHandlers(): void { + document.getElementById('back-btn')?.addEventListener('click', () => navigate('list')); + document.getElementById('discard-btn')?.addEventListener('click', () => navigate('list')); + + document.getElementById('trash-retention')?.addEventListener('change', (e) => { + if (!pendingSettings) return; + pendingSettings.trash_retention = valueToTrashRetention((e.target as HTMLSelectElement).value); + updateSaveEnabled(); + }); + + document.getElementById('history-retention')?.addEventListener('change', (e) => { + if (!pendingSettings) return; + pendingSettings.field_history_retention = valueToHistoryRetention((e.target as HTMLSelectElement).value); + updateSaveEnabled(); + }); + + document.querySelectorAll('[data-revoke]').forEach((btn) => { + btn.addEventListener('click', () => { + if (!pendingSettings) return; + const host = btn.dataset.revoke ?? ''; + delete pendingSettings.autofill_origin_acks[host]; + rerender(); + }); + }); + + document.getElementById('configure-gen')?.addEventListener('click', (e) => { + if (!pendingSettings) return; + const anchor = e.currentTarget as HTMLElement; + openGeneratorPopover({ + anchor, + initial: pendingSettings.generator_defaults, + onPicked: () => {/* no-op — user is here to save as default, not pick */}, + }); + }); + + document.getElementById('save-btn')?.addEventListener('click', async () => { + if (!pendingSettings) return; + const resp = await sendMessage({ type: 'update_vault_settings', settings: pendingSettings }); + if (resp.ok) { + // Refresh cached state and navigate back. + const refreshed = await sendMessage({ type: 'get_vault_settings' }); + if (refreshed.ok && refreshed.data) { + const vs = (refreshed.data as { settings: VaultSettings }).settings; + if (vs) { + setState({ vaultSettings: vs, generatorDefaults: vs.generator_defaults }); + } + } + navigate('list'); + } else { + setState({ error: resp.error }); + } + }); + } + + rerender(); + + const handler = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + if (activeKeyHandler) document.removeEventListener('keydown', activeKeyHandler); + activeKeyHandler = null; + navigate('list'); + } + }; + activeKeyHandler = handler; + document.addEventListener('keydown', handler); +} diff --git a/extension/src/popup/styles.css b/extension/src/popup/styles.css index 41a199e..7297e8f 100644 --- a/extension/src/popup/styles.css +++ b/extension/src/popup/styles.css @@ -649,3 +649,45 @@ textarea { gap: 6px; margin-top: 10px; } .generator-popover .gen-actions .btn { font-size: 11px; padding: 5px 10px; } + +/* --- settings-vault screen (β₂ slice 5) --- */ +.settings-header { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; } +.settings-section { + margin-top: 14px; padding-top: 10px; + border-top: 1px solid #21262d; +} +.settings-section__title { + color: #8b949e; font-size: 10px; + text-transform: uppercase; letter-spacing: 0.08em; + margin-bottom: 6px; +} +.settings-row { + display: grid; grid-template-columns: 110px 1fr; + gap: 6px 10px; align-items: center; + margin: 4px 0; font-size: 12px; +} +.settings-row__label { color: #8b949e; } +.settings-row select { + background: #0d1117; border: 1px solid #30363d; color: #c9d1d9; + padding: 3px 8px; border-radius: 3px; font: inherit; font-size: 11px; +} +.gen-preview-line { + margin: 0 0 6px; font-size: 11px; color: #c9d1d9; + font-family: "SF Mono", "JetBrains Mono", monospace; +} +.ack-row { + display: grid; grid-template-columns: 1fr auto auto; + gap: 8px; align-items: center; + padding: 4px 0; font-size: 11px; + border-bottom: 1px solid #161b22; +} +.ack-row__host { color: #c9d1d9; font-family: monospace; } +.ack-row__meta { color: #6e7681; font-size: 10px; } +.ack-row__revoke { + background: transparent; border: 0; color: #f85149; + cursor: pointer; font-size: 10px; +} +.settings-footer { + display: flex; justify-content: flex-end; gap: 6px; + margin-top: 20px; padding-top: 12px; border-top: 1px solid #21262d; +}