diff --git a/extension/src/popup/components/__tests__/generator-popover.test.ts b/extension/src/popup/components/__tests__/generator-popover.test.ts new file mode 100644 index 0000000..07e4f98 --- /dev/null +++ b/extension/src/popup/components/__tests__/generator-popover.test.ts @@ -0,0 +1,124 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../popup', async () => { + const sendMessage = vi.fn(); + return { sendMessage }; +}); + +import { openGeneratorPopover, closeGeneratorPopover } from '../generator-popover'; +import { sendMessage } from '../../popup'; +import type { GeneratorRequest } from '../../../shared/types'; + +const DEFAULT_REQ: GeneratorRequest = { + kind: 'random', + length: 20, + classes: { lower: true, upper: true, digits: true, symbols: true }, + symbol_charset: { kind: 'safe_only' }, +}; + +function setupAnchor(): HTMLElement { + document.body.innerHTML = ''; + return document.getElementById('anchor')!; +} + +describe('generator-popover', () => { + beforeEach(() => { + vi.mocked(sendMessage).mockReset(); + vi.mocked(sendMessage).mockResolvedValue({ ok: true, data: { password: 'Kj7%pW@2xNq!8rMvT' } }); + }); + + it('opens a popover with Random kind by default', async () => { + const anchor = setupAnchor(); + openGeneratorPopover({ anchor, initial: DEFAULT_REQ, onPicked: vi.fn() }); + await new Promise((r) => setTimeout(r, 200)); + expect(document.querySelector('.generator-popover')).not.toBeNull(); + expect(document.querySelector('#gen-kind-random')?.classList.contains('active')).toBe(true); + }); + + it('sends generate_password on knob change (debounced)', async () => { + const anchor = setupAnchor(); + openGeneratorPopover({ anchor, initial: DEFAULT_REQ, onPicked: vi.fn() }); + await new Promise((r) => setTimeout(r, 200)); + const slider = document.querySelector('#gen-length') as HTMLInputElement; + slider.value = '32'; + slider.dispatchEvent(new Event('input', { bubbles: true })); + await new Promise((r) => setTimeout(r, 200)); + const calls = vi.mocked(sendMessage).mock.calls.filter( + ([msg]) => (msg as { type: string }).type === 'generate_password', + ); + const latest = calls[calls.length - 1]![0] as { request: GeneratorRequest }; + expect(latest.request.kind).toBe('random'); + if (latest.request.kind === 'random') { + expect(latest.request.length).toBe(32); + } + }); + + it('BIP39 toggle swaps to generate_passphrase', async () => { + const anchor = setupAnchor(); + openGeneratorPopover({ anchor, initial: DEFAULT_REQ, onPicked: vi.fn() }); + await new Promise((r) => setTimeout(r, 200)); + (document.getElementById('gen-kind-bip39') as HTMLButtonElement).click(); + await new Promise((r) => setTimeout(r, 200)); + const calls = vi.mocked(sendMessage).mock.calls; + expect(calls.some(([msg]) => (msg as { type: string }).type === 'generate_passphrase')).toBe(true); + }); + + it('use-this-value invokes onPicked with current preview and closes', async () => { + const anchor = setupAnchor(); + const onPicked = vi.fn(); + openGeneratorPopover({ anchor, initial: DEFAULT_REQ, onPicked }); + await new Promise((r) => setTimeout(r, 200)); + (document.querySelector('#gen-use') as HTMLButtonElement).click(); + expect(onPicked).toHaveBeenCalledWith('Kj7%pW@2xNq!8rMvT'); + expect(document.querySelector('.generator-popover')).toBeNull(); + }); + + it('save-as-default sends update_vault_settings with the current request', async () => { + vi.mocked(sendMessage).mockImplementation(async (msg: any) => { + if (msg.type === 'generate_password') return { ok: true, data: { password: 'abc' } }; + if (msg.type === 'get_vault_settings') { + return { ok: true, data: { settings: { + trash_retention: { kind: 'days', value: 30 }, + field_history_retention: { kind: 'forever' }, + generator_defaults: DEFAULT_REQ, + attachment_caps: {}, + autofill_origin_acks: {}, + } } }; + } + if (msg.type === 'update_vault_settings') return { ok: true }; + return { ok: false, error: 'unhandled' }; + }); + const anchor = setupAnchor(); + openGeneratorPopover({ anchor, initial: DEFAULT_REQ, onPicked: vi.fn() }); + await new Promise((r) => setTimeout(r, 200)); + (document.querySelector('#gen-save-default') as HTMLButtonElement).click(); + await new Promise((r) => setTimeout(r, 50)); + const updateCall = vi.mocked(sendMessage).mock.calls.find( + ([m]) => (m as any).type === 'update_vault_settings', + ); + expect(updateCall).toBeDefined(); + const msg = updateCall![0] as { settings: { generator_defaults: GeneratorRequest } }; + expect(msg.settings.generator_defaults.kind).toBe('random'); + }); + + it('disables use-button when no char class selected (Random)', async () => { + const anchor = setupAnchor(); + openGeneratorPopover({ anchor, initial: DEFAULT_REQ, onPicked: vi.fn() }); + await new Promise((r) => setTimeout(r, 200)); + for (const id of ['gen-lower', 'gen-upper', 'gen-digits', 'gen-symbols']) { + const cb = document.getElementById(id) as HTMLInputElement; + cb.checked = false; + cb.dispatchEvent(new Event('change', { bubbles: true })); + } + const useBtn = document.querySelector('#gen-use') as HTMLButtonElement; + expect(useBtn.disabled).toBe(true); + }); + + it('closeGeneratorPopover removes the DOM + handlers', async () => { + const anchor = setupAnchor(); + openGeneratorPopover({ anchor, initial: DEFAULT_REQ, onPicked: vi.fn() }); + await new Promise((r) => setTimeout(r, 200)); + closeGeneratorPopover(); + expect(document.querySelector('.generator-popover')).toBeNull(); + }); +}); diff --git a/extension/src/popup/components/generator-popover.ts b/extension/src/popup/components/generator-popover.ts new file mode 100644 index 0000000..1669c88 --- /dev/null +++ b/extension/src/popup/components/generator-popover.ts @@ -0,0 +1,350 @@ +/// Inline generator popover — anchored to a "gen" button, renders a +/// live preview that updates as knobs change (150ms debounce). Single +/// underlying GeneratorRequest; kind toggle swaps between Random + +/// BIP39 knob sets. Actions: use / save-as-default / reset / cancel. + +import { sendMessage } from '../popup'; +import type { GeneratorRequest, VaultSettings } from '../../shared/types'; + +interface UiKnobs { + kind: 'random' | 'bip39'; + // Random + length: number; + lower: boolean; + upper: boolean; + digits: boolean; + symbols: boolean; + symbolCharset: 'safe_only' | 'extended' | 'custom'; + customSymbols: string; + // BIP39 + wordCount: number; + separator: string; + capitalization: 'lower' | 'upper' | 'first_of_each' | 'title' | 'mixed'; +} + +function knobsFromRequest(req: GeneratorRequest): UiKnobs { + const defaults: UiKnobs = { + kind: 'random', + length: 20, lower: true, upper: true, digits: true, symbols: true, + symbolCharset: 'safe_only', customSymbols: '', + wordCount: 5, separator: ' ', capitalization: 'lower', + }; + if (req.kind === 'random') { + return { + ...defaults, + kind: 'random', + length: req.length, + lower: req.classes.lower, + upper: req.classes.upper, + digits: req.classes.digits, + symbols: req.classes.symbols, + symbolCharset: req.symbol_charset.kind, + customSymbols: req.symbol_charset.kind === 'custom' ? req.symbol_charset.value : '', + }; + } + return { + ...defaults, + kind: 'bip39', + wordCount: req.word_count, + separator: req.separator, + capitalization: req.capitalization, + }; +} + +function requestFromKnobs(knobs: UiKnobs): GeneratorRequest { + if (knobs.kind === 'random') { + return { + kind: 'random', + length: knobs.length, + classes: { + lower: knobs.lower, upper: knobs.upper, + digits: knobs.digits, symbols: knobs.symbols, + }, + symbol_charset: + knobs.symbolCharset === 'safe_only' ? { kind: 'safe_only' } : + knobs.symbolCharset === 'extended' ? { kind: 'extended' } : + { kind: 'custom', value: knobs.customSymbols }, + }; + } + return { + kind: 'bip39', + word_count: knobs.wordCount, + separator: knobs.separator, + capitalization: knobs.capitalization, + }; +} + +let activePopover: { + host: HTMLElement; + cleanup: () => void; +} | null = null; +let debounceTimer: ReturnType | null = null; + +export interface OpenPopoverOpts { + anchor: HTMLElement; + initial: GeneratorRequest; + onPicked: (value: string) => void; +} + +export function openGeneratorPopover(opts: OpenPopoverOpts): void { + closeGeneratorPopover(); + + const knobs = knobsFromRequest(opts.initial); + let currentPreview = ''; + + const host = document.createElement('div'); + host.className = 'generator-popover'; + document.body.appendChild(host); + + // Position below anchor + const rect = opts.anchor.getBoundingClientRect(); + host.style.top = `${rect.bottom + 6}px`; + host.style.left = `${rect.left}px`; + + const render = (): void => { + host.innerHTML = buildInnerHtml(knobs); + wireInner(); + refreshPreview(); + }; + + const refreshPreview = (): void => { + if (debounceTimer !== null) clearTimeout(debounceTimer); + debounceTimer = setTimeout(async () => { + debounceTimer = null; + const request = requestFromKnobs(knobs); + const msg = knobs.kind === 'random' + ? { type: 'generate_password' as const, request } + : { type: 'generate_passphrase' as const, request }; + const resp = await sendMessage(msg); + if (resp.ok) { + const d = resp.data as { password?: string; passphrase?: string }; + currentPreview = d.password ?? d.passphrase ?? ''; + const el = host.querySelector('.gen-preview__value'); + if (el) el.textContent = currentPreview; + updateValidation(); + } + }, 150); + }; + + const updateValidation = (): void => { + const useBtn = host.querySelector('#gen-use') as HTMLButtonElement | null; + if (!useBtn) return; + const noClass = knobs.kind === 'random' + && !(knobs.lower || knobs.upper || knobs.digits || knobs.symbols); + useBtn.disabled = noClass; + const note = host.querySelector('.gen-validation'); + if (note) (note as HTMLElement).style.display = noClass ? 'block' : 'none'; + }; + + const wireInner = (): void => { + host.querySelector('#gen-kind-random')?.addEventListener('click', () => { + knobs.kind = 'random'; render(); + }); + host.querySelector('#gen-kind-bip39')?.addEventListener('click', () => { + knobs.kind = 'bip39'; render(); + }); + + host.querySelector('#gen-length')?.addEventListener('input', (e) => { + knobs.length = Number((e.target as HTMLInputElement).value); + const out = host.querySelector('#gen-length-val'); + if (out) out.textContent = String(knobs.length); + refreshPreview(); + }); + + for (const { id, key } of [ + { id: 'gen-lower', key: 'lower' as const }, + { id: 'gen-upper', key: 'upper' as const }, + { id: 'gen-digits', key: 'digits' as const }, + { id: 'gen-symbols', key: 'symbols' as const }, + ]) { + host.querySelector(`#${id}`)?.addEventListener('change', (e) => { + knobs[key] = (e.target as HTMLInputElement).checked; + updateValidation(); + refreshPreview(); + }); + } + + host.querySelectorAll('[data-symbol-charset]').forEach((btn) => { + btn.addEventListener('click', () => { + knobs.symbolCharset = btn.dataset.symbolCharset as UiKnobs['symbolCharset']; + render(); + }); + }); + + host.querySelector('#gen-word-count')?.addEventListener('input', (e) => { + knobs.wordCount = Number((e.target as HTMLInputElement).value); + const out = host.querySelector('#gen-word-count-val'); + if (out) out.textContent = String(knobs.wordCount); + refreshPreview(); + }); + + host.querySelectorAll('[data-separator]').forEach((btn) => { + btn.addEventListener('click', () => { + knobs.separator = btn.dataset.separator ?? ' '; + render(); + }); + }); + + host.querySelectorAll('[data-capitalization]').forEach((btn) => { + btn.addEventListener('click', () => { + knobs.capitalization = btn.dataset.capitalization as UiKnobs['capitalization']; + render(); + }); + }); + + host.querySelector('.gen-preview__regen')?.addEventListener('click', () => { + refreshPreview(); + }); + + host.querySelector('#gen-use')?.addEventListener('click', () => { + opts.onPicked(currentPreview); + closeGeneratorPopover(); + }); + + host.querySelector('#gen-save-default')?.addEventListener('click', async () => { + const getResp = await sendMessage({ type: 'get_vault_settings' }); + if (!getResp.ok) return; + const vs = (getResp.data as { settings: VaultSettings }).settings; + const updated: VaultSettings = { ...vs, generator_defaults: requestFromKnobs(knobs) }; + await sendMessage({ type: 'update_vault_settings', settings: updated }); + const btn = host.querySelector('#gen-save-default') as HTMLButtonElement | null; + if (btn) { + const original = btn.textContent; + btn.textContent = 'saved'; + setTimeout(() => { if (btn.textContent === 'saved') btn.textContent = original; }, 1500); + } + }); + + host.querySelector('#gen-reset')?.addEventListener('click', async () => { + const getResp = await sendMessage({ type: 'get_vault_settings' }); + if (!getResp.ok) return; + const vs = (getResp.data as { settings: VaultSettings }).settings; + Object.assign(knobs, knobsFromRequest(vs.generator_defaults)); + render(); + }); + + host.querySelector('#gen-cancel')?.addEventListener('click', () => closeGeneratorPopover()); + host.querySelector('#gen-close')?.addEventListener('click', () => closeGeneratorPopover()); + }; + + const onOutsideClick = (e: MouseEvent) => { + if (!host.contains(e.target as Node) && e.target !== opts.anchor) { + closeGeneratorPopover(); + } + }; + const onEsc = (e: KeyboardEvent) => { + if (e.key === 'Escape') closeGeneratorPopover(); + }; + + const cleanup = (): void => { + document.removeEventListener('click', onOutsideClick, true); + document.removeEventListener('keydown', onEsc); + host.remove(); + }; + + activePopover = { host, cleanup }; + + setTimeout(() => { + document.addEventListener('click', onOutsideClick, true); + document.addEventListener('keydown', onEsc); + }, 0); + + render(); +} + +export function closeGeneratorPopover(): void { + if (activePopover === null) return; + if (debounceTimer !== null) { clearTimeout(debounceTimer); debounceTimer = null; } + activePopover.cleanup(); + activePopover = null; +} + +// --- HTML builders --- + +function buildInnerHtml(knobs: UiKnobs): string { + return ` +
+ generate + +
+
+ kind +
+ + +
+
+ ${knobs.kind === 'random' ? buildRandomKnobs(knobs) : buildBip39Knobs(knobs)} +
+ + +
+ ${knobs.kind === 'random' + ? `` + : ''} +
+ + + + +
+ `; +} + +function buildRandomKnobs(k: UiKnobs): string { + return ` +
+ length + + ${k.length} +
+
+ + + + +
+
+ symbols +
+ + +
+
+ `; +} + +function buildBip39Knobs(k: UiKnobs): string { + const sepChip = (label: string, sep: string) => ` + + `; + const capChip = (label: string, val: string) => ` + + `; + return ` +
+ words + + ${k.wordCount} +
+
+ separator +
+ ${sepChip('space', ' ')} + ${sepChip('-', '-')} + ${sepChip('_', '_')} + ${sepChip('.', '.')} + ${sepChip(':', ':')} +
+
+
+ case +
+ ${capChip('lower', 'lower')} + ${capChip('upper', 'upper')} + ${capChip('first', 'first_of_each')} + ${capChip('title', 'title')} +
+
+ `; +} diff --git a/extension/src/popup/styles.css b/extension/src/popup/styles.css index 04e5a52..41a199e 100644 --- a/extension/src/popup/styles.css +++ b/extension/src/popup/styles.css @@ -588,3 +588,64 @@ textarea { width: 100%; font-size: 11px; font-family: inherit; } .disclosure__body .add-section:hover { border-color: #484f58; color: #c9d1d9; } + +/* --- generator popover (β₂ slice 4) --- */ +.generator-popover { + position: absolute; z-index: 9999999; + background: #161b22; border: 1px solid #30363d; border-radius: 6px; + box-shadow: 0 4px 16px rgba(0,0,0,0.5); + padding: 14px; min-width: 300px; max-width: 340px; + font-size: 11px; font-family: system-ui, sans-serif; color: #c9d1d9; +} +.generator-popover .gen-header { + display: flex; justify-content: space-between; align-items: center; + margin-bottom: 8px; +} +.generator-popover .gen-title { font-size: 11px; font-weight: 600; color: #8b949e; text-transform: lowercase; letter-spacing: 0.08em; } +.generator-popover .gen-close { + background: transparent; border: 0; color: #8b949e; cursor: pointer; + font-size: 14px; padding: 2px 6px; +} +.generator-popover .gen-row { + display: flex; align-items: center; gap: 8px; margin: 6px 0; +} +.generator-popover .gen-row__label { + color: #8b949e; width: 70px; flex-shrink: 0; + font-size: 10px; text-transform: lowercase; +} +.generator-popover .gen-toggle-group { + display: flex; gap: 0; border: 1px solid #30363d; border-radius: 3px; overflow: hidden; +} +.generator-popover .gen-toggle-group button { + background: transparent; border: 0; color: #8b949e; + padding: 3px 10px; cursor: pointer; font: inherit; font-size: 10px; +} +.generator-popover .gen-toggle-group button.active { background: #1f6feb; color: #fff; } +.generator-popover .gen-slider { flex: 1; } +.generator-popover .gen-slider + span { + color: #c9d1d9; font-variant-numeric: tabular-nums; + font-family: monospace; min-width: 24px; text-align: right; +} +.generator-popover .gen-check-grid { + display: grid; grid-template-columns: 1fr 1fr; + gap: 4px 16px; margin: 6px 0; font-size: 11px; +} +.generator-popover .gen-check-grid label { + display: flex; align-items: center; gap: 6px; +} +.generator-popover .gen-preview { + margin: 10px 0 8px; padding: 8px 10px; + background: #0d1117; border: 1px solid #30363d; border-radius: 4px; + font-family: "SF Mono", "JetBrains Mono", monospace; color: #c9d1d9; + display: flex; justify-content: space-between; align-items: center; gap: 8px; + word-break: break-all; +} +.generator-popover .gen-preview__regen { + flex-shrink: 0; background: transparent; border: 0; + color: #58a6ff; cursor: pointer; font-size: 12px; +} +.generator-popover .gen-actions { + display: grid; grid-template-columns: 1fr 1fr; + gap: 6px; margin-top: 10px; +} +.generator-popover .gen-actions .btn { font-size: 11px; padding: 5px 10px; }