diff --git a/extension/src/content/capture.ts b/extension/src/content/capture.ts new file mode 100644 index 0000000..f660415 --- /dev/null +++ b/extension/src/content/capture.ts @@ -0,0 +1,308 @@ +/// Credential capture module. +/// +/// Detects login form submissions and prompts the user to save or update +/// credentials in the vault. Supports bar and toast prompt styles. + +import type { Request, Response } from '../shared/messages'; +import type { IdfotoSettings } from '../shared/types'; + +// --- State --- + +const hookedForms = new WeakSet(); +const hookedButtons = new WeakSet(); + +// --- Messaging --- + +function sendMessage(request: Request): Promise { + return new Promise((resolve) => { + chrome.runtime.sendMessage(request, (response: Response) => { + resolve(response); + }); + }); +} + +// --- Username detection (same priority as detector.ts) --- + +function findUsernameValue(pwField: HTMLInputElement): string { + const form = pwField.closest('form'); + const scope = form ?? document; + const inputs = scope.querySelectorAll('input'); + + // 1. autocomplete="username" + for (const input of inputs) { + if (input === pwField) continue; + if (input.autocomplete === 'username' && input.value) return input.value; + } + + // 2. autocomplete="email" + for (const input of inputs) { + if (input === pwField) continue; + if (input.autocomplete === 'email' && input.value) return input.value; + } + + // 3. type="email" + for (const input of inputs) { + if (input === pwField) continue; + if (input.type === 'email' && input.value) return input.value; + } + + // 4. name/id matching common patterns + const pattern = /user|email|login|account/i; + for (const input of inputs) { + if (input === pwField) continue; + if (input.type === 'hidden' || input.type === 'password') continue; + if ((pattern.test(input.name) || pattern.test(input.id)) && input.value) return input.value; + } + + // 5. Nearest preceding visible text input + const allInputs = Array.from(inputs); + const pwIndex = allInputs.indexOf(pwField); + for (let i = pwIndex - 1; i >= 0; i--) { + const input = allInputs[i]; + if (input.type === 'hidden' || input.type === 'password' || input.type === 'submit') continue; + if (input.offsetWidth > 0 && input.offsetHeight > 0 && input.value) return input.value; + } + + return ''; +} + +// --- Form submission handler --- + +async function onFormSubmit(pwField: HTMLInputElement): Promise { + const password = pwField.value; + if (!password) return; + + const username = findUsernameValue(pwField); + const url = window.location.href; + + const resp = await sendMessage({ + type: 'check_credential', + url, + username, + password, + }); + + if (!resp.ok) return; + + const data = resp.data as { action: string; entryId?: string; entryName?: string }; + if (data.action === 'skip') return; + + // Fetch settings for prompt style + const settingsResp = await sendMessage({ type: 'get_settings' }); + const settings: IdfotoSettings = settingsResp.ok + ? (settingsResp.data as { settings: IdfotoSettings }).settings + : { captureEnabled: true, captureStyle: 'bar' }; + + showPrompt(settings.captureStyle, data.action, url, username, password, data.entryId); +} + +// --- Prompt UI --- + +function removeExistingPrompt(): void { + const existing = document.getElementById('idfoto-capture-prompt'); + if (existing) existing.remove(); +} + +function showPrompt( + style: 'bar' | 'toast', + action: string, + url: string, + username: string, + password: string, + entryId?: string, +): void { + removeExistingPrompt(); + + let hostname: string; + try { + hostname = new URL(url).hostname; + } catch { + hostname = url; + } + + const container = document.createElement('div'); + container.id = 'idfoto-capture-prompt'; + + // Common styles + const baseStyles = [ + 'font-family: "SF Mono", "Fira Code", "Cascadia Code", monospace', + 'font-size: 13px', + 'color: #c9d1d9', + 'background: #161b22', + 'z-index: 2147483647', + 'box-sizing: border-box', + 'line-height: 1.4', + ]; + + if (style === 'bar') { + container.style.cssText = [ + ...baseStyles, + 'position: fixed', + 'top: 0', + 'left: 0', + 'right: 0', + 'padding: 10px 16px', + 'display: flex', + 'align-items: center', + 'gap: 12px', + 'border-bottom: 1px solid #30363d', + 'box-shadow: 0 2px 8px rgba(0,0,0,0.4)', + 'transform: translateY(-100%)', + 'transition: transform 0.3s ease', + ].join('; '); + } else { + container.style.cssText = [ + ...baseStyles, + 'position: fixed', + 'bottom: 16px', + 'right: 16px', + 'padding: 12px 16px', + 'border-radius: 4px', + 'border: 1px solid #30363d', + 'box-shadow: 0 4px 12px rgba(0,0,0,0.4)', + 'max-width: 360px', + 'opacity: 0', + 'transition: opacity 0.3s ease', + ].join('; '); + } + + const actionLabel = action === 'update' ? 'Update' : 'Save'; + const displayUser = username ? ` (${username})` : ''; + + container.innerHTML = ` + + ${actionLabel} login for ${escapeForHtml(hostname)}${escapeForHtml(displayUser)}? + + + + + `; + + document.body.appendChild(container); + + // Animate in + requestAnimationFrame(() => { + if (style === 'bar') { + container.style.transform = 'translateY(0)'; + } else { + container.style.opacity = '1'; + } + }); + + // Auto-dismiss for toast + let autoDismissTimer: ReturnType | null = null; + if (style === 'toast') { + autoDismissTimer = setTimeout(() => removeExistingPrompt(), 15000); + } + + const clearAutoDismiss = (): void => { + if (autoDismissTimer) clearTimeout(autoDismissTimer); + }; + + // Save button + container.querySelector('#idfoto-save-btn')?.addEventListener('click', async () => { + clearAutoDismiss(); + + const now = new Date().toISOString(); + if (action === 'update' && entryId) { + await sendMessage({ + type: 'update_entry', + id: entryId, + entry: { + name: hostname, + url, + username, + password, + created_at: now, + updated_at: now, + }, + }); + } else { + await sendMessage({ + type: 'add_entry', + entry: { + name: hostname, + url, + username, + password, + created_at: now, + updated_at: now, + }, + }); + } + + // Show confirmation + const span = container.querySelector('span'); + if (span) span.textContent = '\u2713 Saved'; + const saveBtn = container.querySelector('#idfoto-save-btn') as HTMLElement | null; + const neverBtn = container.querySelector('#idfoto-never-btn') as HTMLElement | null; + if (saveBtn) saveBtn.style.display = 'none'; + if (neverBtn) neverBtn.style.display = 'none'; + setTimeout(() => removeExistingPrompt(), 1500); + }); + + // Never button + container.querySelector('#idfoto-never-btn')?.addEventListener('click', async () => { + clearAutoDismiss(); + await sendMessage({ type: 'blacklist_site', hostname }); + removeExistingPrompt(); + }); + + // Close button + container.querySelector('#idfoto-close-btn')?.addEventListener('click', () => { + clearAutoDismiss(); + removeExistingPrompt(); + }); +} + +function escapeForHtml(str: string): string { + const div = document.createElement('div'); + div.textContent = str; + return div.innerHTML; +} + +// --- Form hooking --- + +export function hookForms(): void { + const passwordFields = document.querySelectorAll('input[type="password"]'); + + for (const pwField of passwordFields) { + if (pwField.offsetWidth < 20 || pwField.offsetHeight < 10) continue; + + const form = pwField.closest('form'); + + if (form && !hookedForms.has(form)) { + hookedForms.add(form); + form.addEventListener('submit', () => { + onFormSubmit(pwField); + }); + } + + // Hook submit buttons (for forms that submit via JS click handlers) + const scope = form ?? pwField.parentElement; + if (!scope) continue; + + const buttons = scope.querySelectorAll( + 'button[type="submit"], input[type="submit"], button:not([type])', + ); + for (const btn of buttons) { + if (hookedButtons.has(btn)) continue; + hookedButtons.add(btn); + btn.addEventListener('click', () => { + onFormSubmit(pwField); + }); + } + } +} diff --git a/extension/src/content/detector.ts b/extension/src/content/detector.ts index 1790953..f369f0e 100644 --- a/extension/src/content/detector.ts +++ b/extension/src/content/detector.ts @@ -6,6 +6,7 @@ import { setupFillListener } from './fill'; import { injectFieldIcons } from './icon'; +import { hookForms } from './capture'; /// Find password fields on the page and detect their associated username inputs. function detectLoginForms(): Array<{ password: HTMLInputElement; username: HTMLInputElement | null }> { @@ -80,6 +81,7 @@ function scan(): void { for (const { password, username } of forms) { injectFieldIcons(password, username); } + hookForms(); } // --- Initialization ---