/// 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. /// /// The prompt renders inside a closed Shadow DOM so the host page cannot /// read overlay contents via document.querySelector or rewrite them via /// insertAdjacentHTML. All caller-supplied strings (hostname, username) /// are applied via textContent, never innerHTML. import type { Request, Response } from '../shared/messages'; import type { DeviceSettings } from '../shared/types'; import { createShadowHost, type ShadowSurface } from './shadow'; // --- State --- const hookedForms = new WeakSet(); const hookedButtons = new WeakSet(); let currentPrompt: ShadowSurface | null = null; // --- 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); // Note: `url` is NOT sent — router derives origin from sender.tab.url. const resp = await sendMessage({ type: 'check_credential', 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. Content scripts have direct // chrome.storage.local access (manifest grants "storage"), so we don't // need to round-trip through the SW for this — which also avoids the // router's content→popup-only rejection for 'get_settings'. const stored = await chrome.storage.local.get('relicarioSettings'); const settings: DeviceSettings = (stored.relicarioSettings as DeviceSettings) ?? { captureEnabled: true, captureStyle: 'bar' }; showPrompt(settings.captureStyle, data.action, username, password); } // --- Prompt UI --- function removeExistingPrompt(): void { if (currentPrompt) { currentPrompt.destroy(); currentPrompt = null; } } function showPrompt( style: 'bar' | 'toast', action: string, username: string, password: string, ): void { removeExistingPrompt(); const hostname = (() => { try { return new URL(window.location.href).hostname; } catch { return window.location.href; } })(); const surface = createShadowHost(); currentPrompt = surface; const { host, root } = surface; // Position the host on the page; all further styling lives inside the // shadow root so the page's CSS can't reach us. const baseHostStyles = 'z-index: 2147483647; position: fixed;'; if (style === 'bar') { host.style.cssText = `${baseHostStyles} top:0; left:0; right:0;`; } else { host.style.cssText = `${baseHostStyles} bottom:16px; right:16px;`; } // --- Build prompt DOM via createElement / textContent only --- const container = document.createElement('div'); const containerBase = [ 'font-family: "SF Mono", "Fira Code", "Cascadia Code", monospace', 'font-size: 13px', 'color: #c9d1d9', 'background: #161b22', 'box-sizing: border-box', 'line-height: 1.4', ]; if (style === 'bar') { container.style.cssText = [ ...containerBase, '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 = [ ...containerBase, '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'; // Message span: " login for ()?" const msgSpan = document.createElement('span'); msgSpan.style.cssText = 'flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;'; msgSpan.appendChild(document.createTextNode(`${actionLabel} login for `)); const hostStrong = document.createElement('strong'); hostStrong.style.color = '#d2ab43'; hostStrong.textContent = hostname; msgSpan.appendChild(hostStrong); if (username) { msgSpan.appendChild(document.createTextNode(` (${username})`)); } msgSpan.appendChild(document.createTextNode('?')); const saveBtn = document.createElement('button'); saveBtn.textContent = actionLabel; saveBtn.style.cssText = [ 'background:#7c5719', 'color:#fff', 'border:none', 'padding:5px 14px', 'border-radius:3px', 'cursor:pointer', 'font-family:inherit', 'font-size:12px', 'white-space:nowrap', ].join('; '); const neverBtn = document.createElement('button'); neverBtn.textContent = 'Never'; neverBtn.style.cssText = [ 'background:transparent', 'color:#8b949e', 'border:1px solid #30363d', 'padding:5px 10px', 'border-radius:3px', 'cursor:pointer', 'font-family:inherit', 'font-size:12px', 'white-space:nowrap', ].join('; '); const closeBtn = document.createElement('button'); closeBtn.textContent = '✕'; closeBtn.style.cssText = [ 'background:transparent', 'color:#8b949e', 'border:none', 'cursor:pointer', 'font-size:16px', 'padding:2px 6px', 'font-family:inherit', 'line-height:1', ].join('; '); container.append(msgSpan, saveBtn, neverBtn, closeBtn); root.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 — single content-callable message; the SW figures out // whether this is an add or an update (and enforces origin-binding). saveBtn.addEventListener('click', async () => { clearAutoDismiss(); const resp = await sendMessage({ type: 'capture_save_login', username, password }); if (!resp.ok) { msgSpan.textContent = `✗ ${resp.error}`; return; } msgSpan.textContent = '✓ Saved'; saveBtn.style.display = 'none'; neverBtn.style.display = 'none'; setTimeout(() => removeExistingPrompt(), 1500); }); // Never button: router derives hostname from sender.tab.url (no `hostname` field) neverBtn.addEventListener('click', async () => { clearAutoDismiss(); await sendMessage({ type: 'blacklist_site' }); removeExistingPrompt(); }); // Close button closeBtn.addEventListener('click', () => { clearAutoDismiss(); removeExistingPrompt(); }); } // --- 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); }); } } }