/** * extension/content/content.js — PassKeeper content script. * * 1. Detects login forms → notifies background (badge count). * 2. Injects a PassKeeper icon button into username AND password fields. * Focusing either field (or clicking the icon) shows a suggestion dropdown * anchored below the field, matching the browser-native autofill style. * 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__"; const PK_DROPDOWN_ID = "__pk_dropdown__"; let _bannerEl = null; let _hasNotifiedForm = false; let _formObserver = null; let _matchingItems = []; // cached matching vault items for the current page // ── 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); }); } // ── Suggestion dropdown ─────────────────────────────────────────────────────── function removeDropdown() { const existing = document.getElementById(PK_DROPDOWN_ID); if (existing) existing.remove(); } /** * Build and show the suggestion dropdown anchored directly below anchorField. * * @param {HTMLInputElement} anchorField - field the dropdown is anchored to * @param {HTMLInputElement} pwField - associated password field to fill * @param {object[]} items - matching vault items for this site * @param {string} filterText - current field value used to filter */ function showDropdown(anchorField, pwField, items, filterText) { removeDropdown(); // Filter by what the user has already typed. const q = (filterText || "").trim().toLowerCase(); const filtered = q ? items.filter(function (item) { return ( ((item.plain && item.plain.username) || "") .toLowerCase() .includes(q) || item.name.toLowerCase().includes(q) ); }) : items; // Don't show an empty dropdown while the user is actively typing and nothing matches. if (!filtered.length && q) return; const dropdown = document.createElement("div"); dropdown.id = PK_DROPDOWN_ID; // Anchor below the field, same left edge, at least 260 px wide. const rect = anchorField.getBoundingClientRect(); const dropWidth = Math.max(260, rect.width); Object.assign(dropdown.style, { position: "fixed", top: rect.bottom + 2 + "px", left: rect.left + "px", width: dropWidth + "px", background: "#fff", border: "1px solid #dadce0", borderRadius: "8px", boxShadow: "0 4px 20px rgba(0,0,0,0.18)", zIndex: "2147483647", fontFamily: "-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif", fontSize: "13px", overflow: "hidden", }); // The username field to fill. const usernameField = anchorField.type === "password" ? findUsernameField(anchorField) : anchorField; if (filtered.length === 0) { // No items at all for this site. const empty = document.createElement("div"); Object.assign(empty.style, { padding: "12px 14px", color: "#5f6368", fontSize: "12px", }); empty.textContent = "No saved passwords for this site."; dropdown.appendChild(empty); } else { filtered.forEach(function (item) { const row = document.createElement("div"); Object.assign(row.style, { display: "flex", alignItems: "center", gap: "10px", padding: "9px 12px", cursor: "pointer", transition: "background 0.1s", }); row.onmouseenter = function () { row.style.background = "#f1f3f4"; }; row.onmouseleave = function () { row.style.background = ""; }; // Derive display hostname from stored URL. var siteHost = item.name; if (item.plain && item.plain.url) { try { siteHost = new URL(item.plain.url).hostname.replace(/^www\./, ""); } catch (e) {} } var username = escHtml((item.plain && item.plain.username) || ""); var site = escHtml(siteHost); // Lock icon avatar. var avatar = document.createElement("div"); Object.assign(avatar.style, { width: "32px", height: "32px", borderRadius: "50%", background: "#e8eaed", display: "flex", alignItems: "center", justifyContent: "center", flexShrink: "0", }); avatar.innerHTML = '"; // Text block: site name + username. var text = document.createElement("div"); text.style.cssText = "flex:1;min-width:0;"; text.innerHTML = '
" + (username ? '" : ""); // Edit (pencil) icon. var editBtn = document.createElement("button"); Object.assign(editBtn.style, { background: "none", border: "none", cursor: "pointer", padding: "4px", color: "#1a73e8", display: "flex", alignItems: "center", flexShrink: "0", borderRadius: "4px", }); editBtn.title = "Edit in PassKeeper"; editBtn.innerHTML = '"; editBtn.addEventListener("mousedown", function (e) { e.preventDefault(); e.stopPropagation(); chrome.runtime .sendMessage({ type: "OPEN_VAULT" }) .catch(function () {}); removeDropdown(); }); row.appendChild(avatar); row.appendChild(text); row.appendChild(editBtn); // Clicking the row fills both fields. row.addEventListener("mousedown", function (e) { if (e.target === editBtn || editBtn.contains(e.target)) return; e.preventDefault(); if (usernameField && item.plain && item.plain.username) fillField(usernameField, item.plain.username); if (pwField && item.plain && item.plain.password) fillField(pwField, item.plain.password); removeDropdown(); if (pwField && anchorField !== pwField) { setTimeout(function () { pwField.focus(); }, 0); } }); dropdown.appendChild(row); }); // "More options…" footer row. var divider = document.createElement("div"); divider.style.cssText = "height:1px;background:#e8eaed;margin:0 12px;"; dropdown.appendChild(divider); var more = document.createElement("div"); Object.assign(more.style, { display: "flex", alignItems: "center", gap: "8px", padding: "9px 12px", cursor: "pointer", color: "#1a73e8", fontSize: "12px", fontWeight: "500", transition: "background 0.1s", }); more.onmouseenter = function () { more.style.background = "#f1f3f4"; }; more.onmouseleave = function () { more.style.background = ""; }; more.innerHTML = ' More options\u2026"; more.addEventListener("mousedown", function (e) { e.preventDefault(); chrome.runtime .sendMessage({ type: "OPEN_VAULT" }) .catch(function () {}); removeDropdown(); }); dropdown.appendChild(more); } document.body.appendChild(dropdown); // Close on outside mousedown or Escape key. function onOutside(e) { if (!dropdown.contains(e.target) && e.target !== anchorField) { removeDropdown(); document.removeEventListener("mousedown", onOutside, true); document.removeEventListener("keydown", onEscape, true); } } function onEscape(e) { if (e.key === "Escape") { removeDropdown(); document.removeEventListener("mousedown", onOutside, true); document.removeEventListener("keydown", onEscape, true); } } setTimeout(function () { document.addEventListener("mousedown", onOutside, true); document.addEventListener("keydown", onEscape, true); }, 0); } // ── Field decoration ────────────────────────────────────────────────────────── /** * Inject the PassKeeper icon button into a field and wire focus/input events. * * @param {HTMLInputElement} field - field to decorate (username OR password) * @param {HTMLInputElement} pwField - associated password field * @param {object[]} items - matching vault items for this site */ function decorateField(field, pwField, items) { if (field.getAttribute(PK_ATTR)) return; // already decorated field.setAttribute(PK_ATTR, "1"); // Wrap the field so the icon button can be positioned absolutely inside it. var wrap = document.createElement("div"); wrap.style.cssText = "position:relative;display:inline-block;width:100%;"; field.parentNode.insertBefore(wrap, field); wrap.appendChild(field); // Prevent typed text from sliding under the icon. field.style.paddingRight = "34px"; // PassKeeper icon button inside the right edge of the field. var 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); // Show dropdown when the field gains focus. field.addEventListener("focus", function () { showDropdown(field, pwField, items, field.value); }); // Re-filter as user types. field.addEventListener("input", function () { showDropdown(field, pwField, items, field.value); }); // Icon button toggles the dropdown. btn.addEventListener("mousedown", function (e) { e.preventDefault(); e.stopPropagation(); var existing = document.getElementById(PK_DROPDOWN_ID); if (existing) { removeDropdown(); } else { showDropdown(field, pwField, items, field.value); } }); } async function decorateFields() { // Get cached vault items from session storage. var result; try { result = await chrome.storage.session.get("vault_items"); } catch (e) { return; } var vault_items = result && result.vault_items; if (!vault_items || !vault_items.length) return; var host = location.hostname.replace(/^www\./, ""); _matchingItems = vault_items.filter(function (item) { if (item.item_type !== "password" || !(item.plain && item.plain.url)) return false; try { var h = new URL(item.plain.url).hostname.replace(/^www\./, ""); return h === host || h.endsWith("." + host) || host.endsWith("." + h); } catch (e) { return false; } }); // Decorate every visible password field and its paired username field. visiblePasswordFields().forEach(function (pwField) { var usernameField = findUsernameField(pwField); if (usernameField) decorateField(usernameField, pwField, _matchingItems); decorateField(pwField, pwField, _matchingItems); }); } // ── Form detection ──────────────────────────────────────────────────────────── function notifyFormDetected() { if (_hasNotifiedForm) return; if (!visiblePasswordFields().length) return; _hasNotifiedForm = true; chrome.runtime .sendMessage({ type: "FORMS_DETECTED" }) .catch(function () {}); } // ── Duplicate detection ─────────────────────────────────────────────────────── async function classifyCredentials(username, password) { var result; try { result = await chrome.storage.session.get("vault_items"); } catch (e) { return "new"; } var vault_items = result && result.vault_items; if (!vault_items || !vault_items.length) return "new"; var host = location.hostname.replace(/^www\./, ""); var siteItems = vault_items.filter(function (item) { if (item.item_type !== "password" || !(item.plain && item.plain.url)) return false; try { var h = new URL(item.plain.url).hostname.replace(/^www\./, ""); return h === host || h.endsWith("." + host) || host.endsWith("." + h); } catch (e) { return false; } }); if (!siteItems.length) return "new"; var exactMatch = siteItems.some(function (item) { return ( item.plain && item.plain.username === username && item.plain.password === password ); }); return exactMatch ? "same" : "updated"; } // ── Auto-save banner ────────────────────────────────────────────────────────── function showSaveBanner(username, password, credentialState) { if (_bannerEl) _bannerEl.remove(); var 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", }); var site = escHtml(location.hostname); var user = escHtml(username); var title = credentialState === "updated" ? "Update in PassKeeper?" : "Save to PassKeeper?"; banner.innerHTML = '' + '' + user + ' on ' + site + "" + "
" + '