/** * extension/content/content.js — PassKeeper content script. * * 1. Detects login forms → notifies background (badge count). * 2. Injects a PassKeeper icon button OUTSIDE the DOM (position:fixed, tracked * to the field via scroll/resize) into username AND password fields. * This avoids breaking site layouts (flex/grid parents, React-controlled inputs). * 3. Clicking the icon OR focusing a decorated field shows a suggestion dropdown. * 4. "More options…" shows a second panel with vault/generator actions. * 5. Listens for DO_AUTOFILL from the popup → fills fields. * 6. Watches form submissions → shows save-credentials banner. */ (() => { "use strict"; const PK_ATTR = "data-pk-decorated"; const PK_BTN_CLASS = "__pk_btn__"; const PK_DROPDOWN_ID = "__pk_dropdown__"; const VAULT_URL = "https://pwkeeper.ngodanguyen.tech/vault"; let _bannerEl = null; let _hasNotifiedForm = false; let _formObserver = null; let _matchingItems = []; // Map from field element → its fixed-position icon button element const _fieldBtnMap = new WeakMap(); // ── Helpers ────────────────────────────────────────────────────────────────── function escHtml(str) { return String(str ?? "") .replace(/&/g, "&") .replace(//g, ">"); } /** * More robust visibility check than offsetParent (which fails for * position:fixed elements and some modern layouts). */ function isVisible(el) { if (!el || !el.getBoundingClientRect) return false; if (el.disabled) return false; const rect = el.getBoundingClientRect(); if (rect.width === 0 && rect.height === 0) return false; const style = window.getComputedStyle(el); if ( style.display === "none" || style.visibility === "hidden" || style.opacity === "0" ) return false; return true; } /** * Returns a debounced version of `fn` that waits `ms` milliseconds after * the last call before firing. Used to avoid re-rendering the dropdown on * every keystroke. */ function _debounce(fn, ms) { var timer; return function () { var args = arguments; var ctx = this; clearTimeout(timer); timer = setTimeout(function () { fn.apply(ctx, args); }, ms); }; } function visiblePasswordFields() { return Array.from( document.querySelectorAll('input[type="password"]'), ).filter((el) => isVisible(el) && !el.disabled); } /** * Returns true only if the input field carries signals suggesting it * collects a credential (username / email / phone) — not a generic * text field such as a search box, full-name field, or address field. * * Scoring precedence: * 1. autocomplete="username"|"email"|"tel" → definite YES * 2. Non-credential autocomplete value → definite NO * 3. name / id / placeholder / aria-label contain a credential keyword → YES * 4. Otherwise → NO (do not decorate) */ function _isLikelyUsernameField(el) { const CRED_HINTS = /user|email|mail|login|phone|tel|mobile|account/i; const ac = (el.getAttribute("autocomplete") || "").toLowerCase().trim(); // Strongest positive signal. if (["username", "email", "tel"].includes(ac)) return true; // Definite negative signals (Chrome's autocomplete token set). const NON_CRED_AC = /^(name|given-name|family-name|additional-name|honorific-prefix|honorific-suffix|organization|street-address|address-line[123]|address-level[1234]|country|country-name|postal-code|cc-|transaction-|language|bday|sex|url|photo|search|new-password|current-password|one-time-code|off)$/i; if (ac && NON_CRED_AC.test(ac)) return false; // Check name, id, placeholder, and aria-label for credential keywords. const attrs = [ el.getAttribute("name") || "", el.getAttribute("id") || "", el.getAttribute("placeholder") || "", el.getAttribute("aria-label") || "", ].join(" "); return CRED_HINTS.test(attrs); } function findUsernameField(pwField) { // Helper: accept email/tel inputs unconditionally; text inputs only when // they look like a genuine credential field. function isCredentialType(el) { if (el.type === "email" || el.type === "tel") return true; if (el.type === "text") return _isLikelyUsernameField(el); return false; } // 1. Walk backwards through all inputs in DOM order. 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 (!isVisible(el) || el.disabled) continue; if (isCredentialType(el)) return el; } // 2. Fallback: search within the same form / ancestor container. // Prefer email inputs first, then scored text/tel inputs. const container = pwField.closest("form") || pwField.closest('[role="form"]') || pwField.parentElement; if (container) { const emailCandidate = container.querySelector( 'input[type="email"]:not([disabled])', ); if (emailCandidate && isVisible(emailCandidate)) return emailCandidate; const textTelInputs = Array.from( container.querySelectorAll( 'input[type="text"]:not([disabled]), input[type="tel"]:not([disabled])', ), ); const scored = textTelInputs.filter( (el) => isVisible(el) && _isLikelyUsernameField(el), ); if (scored.length) return scored[0]; } return null; } // ── Framework-compatible fill ───────────────────────────────────────────────── function fillField(el, value) { const nativeSet = Object.getOwnPropertyDescriptor( HTMLInputElement.prototype, "value", )?.set; if (nativeSet) nativeSet.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 #c0392b"; setTimeout(() => { el.style.outline = ""; }, 1500); }); } // ── Icon button (fixed-position, outside the DOM tree of the field) ─────────── /** * Position the icon button over the right edge of `field` using fixed coords. * This never touches the field's parent, so it can't break any layout. */ function positionBtn(btn, field) { const rect = field.getBoundingClientRect(); if (rect.width === 0) { btn.style.display = "none"; return; } btn.style.display = "flex"; btn.style.top = rect.top + rect.height / 2 - 13 + "px"; btn.style.left = rect.right - 30 + "px"; } // createIconBtn is defined in the Field decoration section below. // ── Suggestion dropdown ─────────────────────────────────────────────────────── function removeDropdown() { const el = document.getElementById(PK_DROPDOWN_ID); if (el) el.remove(); } /** * Build the dropdown anchored below `anchorField`. * Uses _matchingItems which is kept fresh via storage.onChanged listener. * `panel` is either 'credentials' (main list) or 'more' (options menu). */ async function showDropdown(anchorField, pwField, filterText, panel) { removeDropdown(); // Use module-level _matchingItems (kept fresh by storage.onChanged). // If still empty, try a direct storage read as last resort. var freshItems = _matchingItems; if (!freshItems.length) { try { var result = await chrome.storage.session.get("vault_items_cs"); var all = (result && result.vault_items_cs) || []; freshItems = _filterForHost(all); if (freshItems.length) _matchingItems = freshItems; } catch (e) {} } const rect = anchorField.getBoundingClientRect(); const dropWidth = Math.max(260, rect.width); const dropdown = document.createElement("div"); dropdown.id = PK_DROPDOWN_ID; Object.assign(dropdown.style, { position: "fixed", top: rect.bottom + 4 + "px", left: rect.left + "px", width: dropWidth + "px", background: "#fff", border: "1px solid #dadce0", borderRadius: "10px", boxShadow: "0 6px 24px rgba(0,0,0,0.18)", zIndex: "2147483647", fontFamily: "-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif", fontSize: "13px", overflow: "hidden", }); if (panel === "more") { buildMorePanel(dropdown, anchorField, pwField, freshItems, filterText); } else { buildCredentialsPanel( dropdown, anchorField, pwField, freshItems, filterText, ); } document.body.appendChild(dropdown); // Reposition on scroll/resize so it stays under the field. function reposition() { const r = anchorField.getBoundingClientRect(); dropdown.style.top = r.bottom + 4 + "px"; dropdown.style.left = r.left + "px"; } window.addEventListener("scroll", reposition, { passive: true, capture: true, }); window.addEventListener("resize", reposition, { passive: true }); // Close on outside mousedown or keyboard navigation. function onOutside(e) { const btn = _fieldBtnMap.get(anchorField); if ( dropdown.contains(e.target) || e.target === anchorField || (btn && btn.contains(e.target)) ) return; removeDropdown(); document.removeEventListener("mousedown", onOutside, true); document.removeEventListener("keydown", onKeydown, true); } // Keyboard navigation: Arrow keys move focus between rows; Enter selects; Escape closes. function onKeydown(e) { if (e.key === "Escape") { removeDropdown(); document.removeEventListener("mousedown", onOutside, true); document.removeEventListener("keydown", onKeydown, true); return; } if (e.key !== "ArrowDown" && e.key !== "ArrowUp" && e.key !== "Enter") return; // Only navigate credential rows (divs with data-pk-row attribute). var rows = Array.from(dropdown.querySelectorAll("[data-pk-row]")); if (!rows.length) return; e.preventDefault(); // prevent the field from scrolling the page if (e.key === "Enter") { var focused = dropdown.querySelector("[data-pk-row].pk-row-focused"); if (focused && focused._pkFill) focused._pkFill(); return; } var currentIdx = rows.findIndex(function (r) { return r.classList.contains("pk-row-focused"); }); var nextIdx; if (e.key === "ArrowDown") { nextIdx = currentIdx < rows.length - 1 ? currentIdx + 1 : 0; } else { nextIdx = currentIdx > 0 ? currentIdx - 1 : rows.length - 1; } rows.forEach(function (r) { r.classList.remove("pk-row-focused"); r.style.background = ""; }); rows[nextIdx].classList.add("pk-row-focused"); rows[nextIdx].style.background = "#e8f0fe"; rows[nextIdx].scrollIntoView({ block: "nearest" }); } setTimeout(function () { document.addEventListener("mousedown", onOutside, true); document.addEventListener("keydown", onKeydown, true); }, 0); } // ── Credentials panel (main list) ──────────────────────────────────────────── function buildCredentialsPanel( dropdown, anchorField, pwField, items, filterText, ) { 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; const usernameField = anchorField.type === "password" ? findUsernameField(anchorField) : anchorField; if (filtered.length === 0) { // No saved passwords — show a minimal "no items" row + More options. const empty = document.createElement("div"); Object.assign(empty.style, { padding: "12px 14px", color: "#5f6368", fontSize: "12px", }); empty.textContent = q ? "No matches found." : "No saved passwords for this site."; dropdown.appendChild(empty); } else { filtered.forEach(function (item) { const row = document.createElement("div"); row.setAttribute("data-pk-row", "1"); // enables keyboard navigation Object.assign(row.style, { display: "flex", alignItems: "center", gap: "10px", padding: "10px 14px", cursor: "pointer", transition: "background 0.1s", }); row.onmouseenter = function () { if (!row.classList.contains("pk-row-focused")) row.style.background = "#f1f3f4"; }; row.onmouseleave = function () { if (!row.classList.contains("pk-row-focused")) row.style.background = ""; }; // Derive display hostname. 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 — filled dark circle like the screenshot. var avatar = document.createElement("div"); Object.assign(avatar.style, { width: "34px", height: "34px", borderRadius: "50%", background: "#1a1a2e", display: "flex", alignItems: "center", justifyContent: "center", flexShrink: "0", }); avatar.innerHTML = '' + '' + '' + ""; // Text. var text = document.createElement("div"); text.style.cssText = "flex:1;min-width:0;"; text.innerHTML = '
' + site + "
" + (username ? '
' + username + "
" : ""); // Edit pencil. var editBtn = document.createElement("button"); Object.assign(editBtn.style, { background: "none", border: "none", cursor: "pointer", padding: "5px", 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); // Shared fill action — used by both mousedown and keyboard Enter. function doFill() { if (usernameField && item.plain && item.plain.username) fillField(usernameField, item.plain.username); if (pwField && item.plain && item.plain.password) fillField(pwField, item.plain.password); // ✓ Filled flash: replace row content briefly before closing. row.innerHTML = '
' + '' + '' + "Filled
"; row.style.background = "#f0fdf4"; setTimeout(function () { removeDropdown(); if (pwField && anchorField !== pwField) pwField.focus(); }, 600); } row.addEventListener("mousedown", function (e) { if (e.target === editBtn || editBtn.contains(e.target)) return; e.preventDefault(); doFill(); }); // Expose doFill for the keyboard Enter handler via a custom property. row._pkFill = doFill; dropdown.appendChild(row); }); } // Divider + "More options…" footer — always shown. var divider = document.createElement("div"); divider.style.cssText = "height:1px;background:#e8eaed;"; dropdown.appendChild(divider); var more = document.createElement("div"); Object.assign(more.style, { display: "flex", alignItems: "center", gap: "10px", padding: "10px 14px", cursor: "pointer", color: "#202124", fontSize: "13px", 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(); showDropdown(anchorField, pwField, filterText, "more"); }); dropdown.appendChild(more); } // ── More options panel ──────────────────────────────────────────────────────── function buildMorePanel(dropdown, anchorField, pwField, items, filterText) { // Back header. var backRow = document.createElement("div"); Object.assign(backRow.style, { display: "flex", alignItems: "center", gap: "6px", padding: "10px 14px", cursor: "pointer", color: "#1a73e8", fontSize: "13px", fontWeight: "600", borderBottom: "1px solid #e8eaed", transition: "background 0.1s", }); backRow.onmouseenter = function () { backRow.style.background = "#f1f3f4"; }; backRow.onmouseleave = function () { backRow.style.background = ""; }; backRow.innerHTML = '' + '' + " Back"; backRow.addEventListener("mousedown", function (e) { e.preventDefault(); showDropdown(anchorField, pwField, filterText, "credentials"); }); dropdown.appendChild(backRow); // Menu items matching the screenshot. var menuItems = [ { icon: '', label: "Report a problem", action: function () { chrome.runtime .sendMessage({ type: "OPEN_VAULT" }) .catch(function () {}); removeDropdown(); }, }, { icon: '', label: "Generate a password", chevron: true, action: function () { chrome.runtime .sendMessage({ type: "OPEN_GENERATOR" }) .catch(function () {}); removeDropdown(); }, }, { icon: '', label: "Open my vault", action: function () { chrome.runtime .sendMessage({ type: "OPEN_VAULT" }) .catch(function () {}); removeDropdown(); }, }, ]; menuItems.forEach(function (item) { var row = document.createElement("div"); Object.assign(row.style, { display: "flex", alignItems: "center", gap: "12px", padding: "11px 14px", cursor: "pointer", color: "#202124", fontSize: "13px", transition: "background 0.1s", borderBottom: "1px solid #f3f4f6", }); row.onmouseenter = function () { row.style.background = "#f1f3f4"; }; row.onmouseleave = function () { row.style.background = ""; }; var iconWrap = document.createElement("div"); iconWrap.style.cssText = "width:18px;height:18px;display:flex;align-items:center;justify-content:center;flex-shrink:0;"; iconWrap.innerHTML = '' + item.icon + ""; var label = document.createElement("span"); label.style.cssText = "flex:1;"; label.textContent = item.label; row.appendChild(iconWrap); row.appendChild(label); if (item.chevron) { var chev = document.createElement("div"); chev.innerHTML = '' + '' + ""; row.appendChild(chev); } else { var extIcon = document.createElement("div"); extIcon.innerHTML = '' + '' + '' + ""; row.appendChild(extIcon); } row.addEventListener("mousedown", function (e) { e.preventDefault(); item.action(); }); dropdown.appendChild(row); }); } // ── Field decoration ────────────────────────────────────────────────────────── // Map from field element → AbortController so we can cancel its listeners on re-decoration. const _fieldAbortMap = new WeakMap(); function decorateField(field, pwField) { if (field.getAttribute(PK_ATTR)) return; field.setAttribute(PK_ATTR, "1"); // Cancel any previous listeners on this field. const prevAC = _fieldAbortMap.get(field); if (prevAC) prevAC.abort(); const ac = new AbortController(); _fieldAbortMap.set(field, ac); const sig = ac.signal; createIconBtn(field, pwField, sig); } function createIconBtn(field, pwField, abortSignal) { const btn = document.createElement("button"); btn.type = "button"; btn.className = PK_BTN_CLASS; btn.title = "PassKeeper autofill"; btn.setAttribute("aria-label", "Autofill with PassKeeper"); btn.style.cssText = [ "position:fixed", "width:26px", "height:26px", "background:#c0392b", "border:none", "border-radius:5px", "cursor:pointer", "display:flex", "align-items:center", "justify-content:center", "z-index:2147483646", "padding:0", "box-shadow:0 1px 4px rgba(0,0,0,0.3)", "transition:background 0.15s", ].join(";"); btn.innerHTML = '' + '' + '' + '' + ""; btn.addEventListener("mouseenter", function () { btn.style.background = "#a93226"; }); btn.addEventListener("mouseleave", function () { btn.style.background = "#c0392b"; }); positionBtn(btn, field); document.body.appendChild(btn); _fieldBtnMap.set(field, btn); // Remove the button when the AbortController fires (re-decoration). abortSignal.addEventListener("abort", function () { btn.remove(); _fieldBtnMap.delete(field); }); // Keep button tracked as page scrolls/resizes. function reposition() { if (document.body.contains(btn)) positionBtn(btn, field); } window.addEventListener("scroll", reposition, { passive: true, signal: abortSignal, }); window.addEventListener("resize", reposition, { passive: true, signal: abortSignal, }); // ── All event handlers read _matchingItems at call time, never from closure ── // Show dropdown on focus — reads vault_items fresh from storage each time. field.addEventListener( "focus", function () { showDropdown(field, pwField, field.value, "credentials"); }, { signal: abortSignal }, ); // Re-filter as user types — debounced to avoid rebuilding the dropdown // on every single keystroke (noticeable on large vaults or slow machines). var _debouncedShow = _debounce(function () { showDropdown(field, pwField, field.value, "credentials"); }, 150); field.addEventListener("input", _debouncedShow, { signal: abortSignal }); // Dim button when field loses focus and no dropdown is open. field.addEventListener( "blur", function () { setTimeout(function () { if (!document.getElementById(PK_DROPDOWN_ID)) btn.style.opacity = "0.4"; }, 150); }, { signal: abortSignal }, ); field.addEventListener( "focus", function () { btn.style.opacity = "1"; }, { signal: abortSignal }, ); // Icon click: toggle dropdown. btn.addEventListener("mousedown", function (e) { e.preventDefault(); e.stopPropagation(); if (document.getElementById(PK_DROPDOWN_ID)) { removeDropdown(); } else { showDropdown(field, pwField, field.value, "credentials"); } }); return btn; } /** * Decorate all visible password (and paired username) fields with the PassKeeper * icon button. * * @param {Array|null} knownItems When the caller already holds the correct * filtered item list (e.g. from a storage.onChanged newValue or a VAULT_UPDATED * message payload), pass it here to skip the redundant storage read. * Pass nothing/undefined to let this function read storage itself. */ async function decorateFields(knownItems) { if (knownItems != null) { // Caller supplied pre-filtered items — trust them, skip the storage read. _matchingItems = knownItems; console.log( "[PassKeeper] decorateFields (inline): host=" + location.hostname.replace(/^www\./, "") + ", matched=" + _matchingItems.length, ); } else { // Read from chrome.storage.session — memory-only, cleared on browser close. // Decrypted vault data must never be written to persistent (local) storage. var all = []; try { var result = await chrome.storage.session.get("vault_items_cs"); all = (result && result.vault_items_cs) || []; } catch (e) {} _matchingItems = _filterForHost(all); console.log( "[PassKeeper] decorateFields (storage): host=" + location.hostname.replace(/^www\./, "") + ", matched=" + _matchingItems.length + " of " + all.length + " items", ); } // Decorate every visible password field and its paired username field. visiblePasswordFields().forEach(function (pwField) { var usernameField = findUsernameField(pwField); if (usernameField) decorateField(usernameField, pwField); decorateField(pwField, pwField); }); } // ── Vault item helpers ──────────────────────────────────────────────────────── /** * Normalise a stored URL string so it is always parseable by `new URL()`. * Handles bare domains ("github.com"), protocol-relative ("//github.com"), * and fully-formed URLs ("https://github.com") identically. */ function _normaliseUrl(raw) { if (!raw) return null; var s = raw.trim(); if (/^https?:\/\//i.test(s)) return s; // already has a scheme if (s.startsWith("//")) return "https:" + s; // protocol-relative return "https://" + s; // bare domain or path } function _filterForHost(items) { var host = location.hostname.replace(/^www\./, ""); return (items || []).filter(function (item) { if (item.item_type !== "password" || !(item.plain && item.plain.url)) return false; try { var normalised = _normaliseUrl(item.plain.url); if (!normalised) return false; var h = new URL(normalised).hostname.replace(/^www\./, ""); // Match exact domain or any subdomain relationship. return h === host || h.endsWith("." + host) || host.endsWith("." + h); } catch (e) { console.warn( "[PassKeeper] _filterForHost: could not parse URL:", item.plain.url, e.message, ); return false; } }); } // ── 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 all = []; try { var result = await chrome.storage.session.get("vault_items_cs"); all = (result && result.vault_items_cs) || []; } catch (e) { return "new"; } if (!all.length) return "new"; var siteItems = _filterForHost(all); 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"; } // ── Save blocklist ──────────────────────────────────────────────────────────── const BLOCKLIST_KEY = "save_blocklist"; async function isBlocked(hostname) { try { var result = await chrome.storage.local.get(BLOCKLIST_KEY); var list = (result && result[BLOCKLIST_KEY]) || []; return list.indexOf(hostname) !== -1; } catch (e) { return false; } } async function addToBlocklist(hostname) { try { var result = await chrome.storage.local.get(BLOCKLIST_KEY); var list = (result && result[BLOCKLIST_KEY]) || []; if (list.indexOf(hostname) === -1) { list.push(hostname); await chrome.storage.local.set({ [BLOCKLIST_KEY]: list }); console.log("[PassKeeper] Added to save blocklist:", hostname); } } catch (e) {} } // ── 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?"; // Default site name: prefer page title (trimmed to 60 chars), fall back to hostname. var defaultSiteName = (document.title || "").trim().slice(0, 60) || location.hostname; banner.innerHTML = '
' + '' + '' + escHtml(title) + "" + '' + "
" + '
' + '' + '' + "
" + '

' + '' + user + ' on ' + site + "" + "

" + '
' + '' + '' + "
" + '"; document.body.appendChild(banner); _bannerEl = banner; var dismiss = function () { 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", function () { var siteName = (banner.querySelector("#__pk_site_name__").value || "").trim() || location.hostname; console.log( "[PassKeeper] User chose to save credentials for", location.hostname, "— site name:", siteName, ); chrome.runtime .sendMessage({ type: "SAVE_CREDENTIALS", data: { url: location.href, siteName: siteName, username: username, password: password, }, }) .catch(function () {}); dismiss(); }); banner .querySelector("#__pk_never__") .addEventListener("click", function () { addToBlocklist(location.hostname); dismiss(); }); } // ── Form submission watch ───────────────────────────────────────────────────── function watchSubmissions() { document.addEventListener( "submit", async function (e) { var form = e.target; var pwField = form.querySelector( 'input[type="password"]:not([disabled])', ); if (!pwField || !pwField.value) return; var userField = findUsernameField(pwField) || form.querySelector('input[type="email"]:not([disabled])') || form.querySelector('input[type="text"]:not([disabled])'); var username = (userField && userField.value && userField.value.trim()) || ""; var password = pwField.value; if (!username || !password) return; removeDropdown(); // Check blocklist before doing anything else. if (await isBlocked(location.hostname)) { console.log( "[PassKeeper] Site is blocklisted, skipping save banner:", location.hostname, ); return; } var credentialState = await classifyCredentials(username, password); console.log( "[PassKeeper] Credential state for", location.hostname, "\u2192", credentialState, ); if (credentialState === "same") return; setTimeout(function () { showSaveBanner(username, password, credentialState); }, 500); }, true, ); } // ── Message listener ────────────────────────────────────────────────────────── chrome.runtime.onMessage.addListener(function (msg, _sender, sendResponse) { if (msg.type === "DO_AUTOFILL") { doAutofill(msg.username, msg.password); sendResponse({ ok: true }); } if (msg.type === "VAULT_UPDATED") { // Items arrive in the message payload. The source of truth is // chrome.storage.session (vault_items_cs), which was already written by // the popup before this message was sent. var allItems = msg.vault_items || []; var matched = allItems.length ? _filterForHost(allItems) : null; if (matched !== null) { _matchingItems = matched; console.log( "[PassKeeper] VAULT_UPDATED (message): matched=" + _matchingItems.length + " of " + allItems.length, ); } // Re-decorate, passing pre-filtered items so decorateFields skips the // storage read when the message payload was non-empty. document.querySelectorAll("[" + PK_ATTR + "]").forEach(function (el) { var ac = _fieldAbortMap.get(el); if (ac) ac.abort(); el.removeAttribute(PK_ATTR); }); document.querySelectorAll("." + PK_BTN_CLASS).forEach(function (el) { el.remove(); }); removeDropdown(); decorateFields(matched); } return false; }); // ── Init ────────────────────────────────────────────────────────────────────── function init() { notifyFormDetected(); decorateFields(); watchSubmissions(); // React instantly when the popup writes fresh vault data to session storage. // This fires in the same tick as the write — no message delivery required. chrome.storage.onChanged.addListener(function (changes, area) { if (area === "session" && changes.vault_items_cs) { var allItems = changes.vault_items_cs.newValue || []; var matched = _filterForHost(allItems); _matchingItems = matched; console.log( "[PassKeeper] storage.onChanged: matched=" + matched.length + " of " + allItems.length + " items", ); // Re-decorate, passing the already-filtered list so decorateFields // skips a redundant storage.session.get() call. document.querySelectorAll("[" + PK_ATTR + "]").forEach(function (el) { var ac = _fieldAbortMap.get(el); if (ac) ac.abort(); el.removeAttribute(PK_ATTR); }); document.querySelectorAll("." + PK_BTN_CLASS).forEach(function (el) { el.remove(); }); removeDropdown(); decorateFields(matched); } }); _formObserver = new MutationObserver(function (mutations) { // Ignore mutations caused by the extension's own injected elements // (dropdown, icon buttons, save banner) to prevent re-decoration loops // on SPAs that react to every DOM change. var ownMutation = mutations.every(function (m) { return Array.from(m.addedNodes) .concat(Array.from(m.removedNodes)) .every(function (node) { if (!node || node.nodeType !== 1) return true; var cls = node.className || ""; var id = node.id || ""; return ( cls.indexOf("__pk") !== -1 || id.indexOf("__pk") !== -1 || (node.querySelector && (node.querySelector("." + PK_BTN_CLASS) || node.querySelector("#" + PK_DROPDOWN_ID))) ); }); }); if (ownMutation) return; _hasNotifiedForm = false; notifyFormDetected(); decorateFields(); }); _formObserver.observe(document.body, { childList: true, subtree: true }); } if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", init); } else { init(); } })();