/** * extension/content/content.js — PassKeeper content script. * * 1. Detects login forms → notifies background (badge count). * 2. Injects a small autofill button next to password fields when vault * has matching credentials for the current site. * 3. Listens for DO_AUTOFILL from the popup → fills fields. * 4. Watches form submissions → shows save-credentials banner. */ (() => { "use strict"; const PK_ATTR = "data-pk-decorated"; const PK_BTN_CLASS = "__pk_autofill_btn__"; let _bannerEl = null; let _hasNotifiedForm = false; let _formObserver = null; // ── Helpers ────────────────────────────────────────────────────────────────── function escHtml(str) { return String(str ?? "") .replace(/&/g, "&") .replace(//g, ">"); } function visiblePasswordFields() { return Array.from( document.querySelectorAll('input[type="password"]'), ).filter((el) => el.offsetParent !== null && !el.disabled); } function findUsernameField(pwField) { const all = Array.from(document.querySelectorAll("input")); const idx = all.indexOf(pwField); for (let i = idx - 1; i >= 0; i--) { const el = all[i]; if (!el.offsetParent || el.disabled) continue; if (["email", "text", "tel"].includes(el.type)) return el; } return null; } // ── Framework-compatible fill ───────────────────────────────────────────────── function fillField(el, value) { const setter = Object.getOwnPropertyDescriptor( HTMLInputElement.prototype, "value", )?.set; if (setter) setter.call(el, value); else el.value = value; el.dispatchEvent(new Event("input", { bubbles: true })); el.dispatchEvent(new Event("change", { bubbles: true })); } function doAutofill(username, password) { const pwFields = visiblePasswordFields(); if (!pwFields.length) return; const pwField = pwFields[0]; const usernameField = findUsernameField(pwField); if (usernameField && username) fillField(usernameField, username); if (password) fillField(pwField, password); [usernameField, pwField].filter(Boolean).forEach((el) => { el.style.outline = "2px solid #1a73e8"; setTimeout(() => { el.style.outline = ""; }, 1500); }); } // ── Inline autofill button ──────────────────────────────────────────────────── /** * Inject a small PassKeeper icon button just inside the right edge of each * password field. Clicking it opens a tiny dropdown listing matching items. */ function injectAutofillButtons(matchingItems) { visiblePasswordFields().forEach((pwField) => { if (pwField.getAttribute(PK_ATTR)) return; // already decorated pwField.setAttribute(PK_ATTR, "1"); // Wrap the field if it isn't already positioned const wrap = document.createElement("div"); wrap.style.cssText = "position:relative;display:inline-block;width:100%;"; pwField.parentNode.insertBefore(wrap, pwField); wrap.appendChild(pwField); // Add right-side padding so text doesn't overlap the button pwField.style.paddingRight = "32px"; // The icon button const btn = document.createElement("button"); btn.type = "button"; btn.className = PK_BTN_CLASS; btn.title = "Autofill with PassKeeper"; btn.setAttribute("aria-label", "Autofill with PassKeeper"); btn.style.cssText = [ "position:absolute", "right:6px", "top:50%", "transform:translateY(-50%)", "background:none", "border:none", "cursor:pointer", "padding:3px", "display:flex", "align-items:center", "justify-content:center", "z-index:2147483646", ].join(";"); btn.innerHTML = ` `; wrap.appendChild(btn); btn.addEventListener("click", (e) => { e.preventDefault(); e.stopPropagation(); toggleDropdown(btn, pwField, matchingItems); }); }); } // Dropdown showing matching credentials function toggleDropdown(btn, pwField, items) { // Remove any existing dropdown const existing = document.getElementById("__pk_dropdown__"); if (existing) { existing.remove(); return; } const usernameField = findUsernameField(pwField); const dropdown = document.createElement("div"); dropdown.id = "__pk_dropdown__"; dropdown.style.cssText = [ "position:fixed", "background:#fff", "border:1px solid #e2e8f0", "border-radius:10px", "box-shadow:0 8px 30px rgba(0,0,0,0.15)", "z-index:2147483647", "min-width:240px", "max-width:300px", 'font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif', "font-size:13px", "overflow:hidden", ].join(";"); // Position below the button const rect = btn.getBoundingClientRect(); dropdown.style.top = rect.bottom + 6 + "px"; dropdown.style.left = Math.max(8, rect.right - 260) + "px"; // Header const header = document.createElement("div"); header.style.cssText = "padding:9px 12px 8px;border-bottom:1px solid #f3f4f6;display:flex;align-items:center;gap:6px;"; header.innerHTML = `PassKeeper`; dropdown.appendChild(header); if (!items.length) { const empty = document.createElement("div"); empty.style.cssText = "padding:14px 12px;color:#9ca3af;font-size:12px;text-align:center;"; empty.textContent = "No matching credentials"; dropdown.appendChild(empty); } else { items.forEach((item) => { const row = document.createElement("div"); row.style.cssText = "display:flex;align-items:center;gap:9px;padding:9px 12px;cursor:pointer;transition:background 0.1s;"; row.onmouseenter = () => { row.style.background = "#f9fafb"; }; row.onmouseleave = () => { row.style.background = ""; }; const username = escHtml(item.plain?.username || ""); const name = escHtml(item.name); row.innerHTML = `
🔑
${name}
${username ? `
${username}
` : ""}
`; row.addEventListener("click", () => { if (usernameField && item.plain?.username) fillField(usernameField, item.plain.username); if (item.plain?.password) fillField(pwField, item.plain.password); dropdown.remove(); }); dropdown.appendChild(row); }); } document.body.appendChild(dropdown); // Close on outside click const close = (e) => { if (!dropdown.contains(e.target) && e.target !== btn) { dropdown.remove(); document.removeEventListener("click", close, true); } }; setTimeout(() => document.addEventListener("click", close, true), 0); } // ── Form detection ──────────────────────────────────────────────────────────── function notifyFormDetected() { if (_hasNotifiedForm) return; if (!visiblePasswordFields().length) return; _hasNotifiedForm = true; chrome.runtime.sendMessage({ type: "FORMS_DETECTED" }).catch(() => {}); } async function decorateFields() { if (!visiblePasswordFields().length) return; // Get cached vault items from session storage const { vault_items } = await chrome.storage.session .get("vault_items") .catch(() => ({})); if (!vault_items?.length) return; const host = location.hostname.replace(/^www\./, ""); const matching = vault_items.filter((item) => { if (item.item_type !== "password" || !item.plain?.url) return false; try { const h = new URL(item.plain.url).hostname.replace(/^www\./, ""); return h === host || h.endsWith(`.${host}`) || host.endsWith(`.${h}`); } catch { return false; } }); injectAutofillButtons(matching); } // ── Duplicate detection ─────────────────────────────────────────────────────── /** * Checks the cached vault to determine whether the submitted credentials are * new or have been updated since last saved. * * Returns: * 'new' — no item found for this site; definitely show the banner. * 'updated' — item exists but username/password differs; show update banner. * 'same' — credentials are identical to a stored item; suppress the banner. */ async function classifyCredentials(username, password) { let vault_items; try { ({ vault_items } = await chrome.storage.session.get("vault_items")); } catch { // If we can't read storage (e.g. extension context invalidated), show banner. return "new"; } if (!vault_items?.length) return "new"; const host = location.hostname.replace(/^www\./, ""); const siteItems = vault_items.filter((item) => { if (item.item_type !== "password" || !item.plain?.url) return false; try { const h = new URL(item.plain.url).hostname.replace(/^www\./, ""); return h === host || h.endsWith(`.${host}`) || host.endsWith(`.${h}`); } catch { return false; } }); if (!siteItems.length) return "new"; // Check for exact match (same username AND same password). const exactMatch = siteItems.some( (item) => item.plain?.username === username && item.plain?.password === password, ); if (exactMatch) return "same"; // Credentials differ → treat as updated. return "updated"; } // ── Auto-save banner ────────────────────────────────────────────────────────── /** * Shows the save/update banner. The banner stays visible until the user * explicitly clicks "Save" or "Not now" — there is NO auto-dismiss timeout. * * @param {string} username * @param {string} password * @param {'new'|'updated'} credentialState — controls the title copy. */ function showSaveBanner(username, password, credentialState) { if (_bannerEl) _bannerEl.remove(); const banner = document.createElement("div"); banner.id = "__pk_save_banner__"; Object.assign(banner.style, { position: "fixed", top: "12px", right: "12px", zIndex: "2147483647", background: "#ffffff", border: "1px solid #e2e8f0", borderRadius: "10px", boxShadow: "0 8px 30px rgba(0,0,0,0.15)", padding: "14px 16px 12px", fontFamily: "-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif", fontSize: "13px", color: "#1a1a2e", maxWidth: "300px", minWidth: "240px", }); const site = escHtml(location.hostname); const user = escHtml(username); const title = credentialState === "updated" ? "Update in PassKeeper?" : "Save to PassKeeper?"; banner.innerHTML = `
${escHtml(title)}

${user} on ${site}

`; document.body.appendChild(banner); _bannerEl = banner; // No setTimeout — banner stays until the user makes an explicit choice. const dismiss = () => { if (_bannerEl === banner) { banner.remove(); _bannerEl = null; } }; banner.querySelector("#__pk_close__").addEventListener("click", dismiss); banner.querySelector("#__pk_skip__").addEventListener("click", dismiss); banner.querySelector("#__pk_save__").addEventListener("click", () => { console.log( "[PassKeeper] User chose to save credentials for", location.hostname, ); chrome.runtime .sendMessage({ type: "SAVE_CREDENTIALS", data: { url: location.href, siteName: document.title || location.hostname, username, password, }, }) .catch(() => {}); dismiss(); }); } // ── Form submission watch ───────────────────────────────────────────────────── function watchSubmissions() { document.addEventListener( "submit", async (e) => { const form = e.target; const pwField = form.querySelector( 'input[type="password"]:not([disabled])', ); if (!pwField?.value) return; const userField = findUsernameField(pwField) ?? form.querySelector('input[type="email"]:not([disabled])') ?? form.querySelector('input[type="text"]:not([disabled])'); const username = userField?.value?.trim() || ""; const password = pwField.value; if (!username || !password) return; // Run duplicate check before showing the banner. const credentialState = await classifyCredentials(username, password); console.log( "[PassKeeper] Credential state for", location.hostname, "→", credentialState, ); if (credentialState === "same") { // Credentials unchanged — silently skip. return; } setTimeout( () => showSaveBanner(username, password, credentialState), 500, ); }, true, ); } // ── Message listener ────────────────────────────────────────────────────────── chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { if (msg.type === "DO_AUTOFILL") { doAutofill(msg.username, msg.password); sendResponse({ ok: true }); } if (msg.type === "VAULT_UPDATED") { // Re-decorate fields with fresh data document .querySelectorAll(`[${PK_ATTR}]`) .forEach((el) => el.removeAttribute(PK_ATTR)); document .querySelectorAll(`.${PK_BTN_CLASS}`) .forEach((el) => el.remove()); decorateFields(); } return false; }); // ── Init ────────────────────────────────────────────────────────────────────── function init() { notifyFormDetected(); decorateFields(); watchSubmissions(); _formObserver = new MutationObserver(() => { _hasNotifiedForm = false; notifyFormDetected(); decorateFields(); }); _formObserver.observe(document.body, { childList: true, subtree: true }); } if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", init); } else { init(); } })();