/** * 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; } function visiblePasswordFields() { return Array.from( document.querySelectorAll('input[type="password"]'), ).filter((el) => isVisible(el) && !el.disabled); } function findUsernameField(pwField) { // 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 (["email", "text", "tel"].includes(el.type)) return el; } // 2. Fallback: search within the same form/ancestor container. const container = pwField.closest("form") || pwField.closest('[role="form"]') || pwField.parentElement; if (container) { const candidate = container.querySelector( 'input[type="email"]:not([disabled]), input[type="text"]:not([disabled]), input[type="tel"]:not([disabled])', ); if (candidate && isVisible(candidate)) return candidate; } 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.local.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 Escape. 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", 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); } // ── 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"); Object.assign(row.style, { display: "flex", alignItems: "center", gap: "10px", padding: "10px 14px", cursor: "pointer", transition: "background 0.1s", }); row.onmouseenter = function () { row.style.background = "#f1f3f4"; }; row.onmouseleave = function () { 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); 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); }); } // 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. field.addEventListener( "input", function () { showDropdown(field, pwField, field.value, "credentials"); }, { 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; } async function decorateFields() { // Read from chrome.storage.local — reliable across all Chrome versions and // does not depend on message delivery from the service worker. var all = []; try { var result = await chrome.storage.local.get("vault_items_cs"); all = (result && result.vault_items_cs) || []; } catch (e) {} _matchingItems = _filterForHost(all); console.log( "[PassKeeper] decorateFields: 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 ──────────────────────────────────────────────────────── 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 h = new URL(item.plain.url).hostname.replace(/^www\./, ""); return h === host || h.endsWith("." + host) || host.endsWith("." + h); } catch (e) { 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.local.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"; } // ── 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 = '
' + '' + '' + 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 () { 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: username, password: password, }, }) .catch(function () {}); 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(); 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 may arrive in the message payload (best-effort), but the source // of truth is now chrome.storage.local which was already written by the popup. var allItems = msg.vault_items || []; if (allItems.length) { _matchingItems = _filterForHost(allItems); console.log( "[PassKeeper] VAULT_UPDATED (message): matched=" + _matchingItems.length + " of " + allItems.length, ); } // Always re-decorate (also re-reads storage if message had no items). 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(); } return false; }); // ── Init ────────────────────────────────────────────────────────────────────── function init() { notifyFormDetected(); decorateFields(); watchSubmissions(); // React instantly when the popup writes fresh vault data to local storage. // This fires in the same tick as the write — no message delivery required. chrome.storage.onChanged.addListener(function (changes, area) { if (area === "local" && changes.vault_items_cs) { var allItems = changes.vault_items_cs.newValue || []; _matchingItems = _filterForHost(allItems); console.log( "[PassKeeper] storage.onChanged: matched=" + _matchingItems.length + " of " + allItems.length + " items", ); // Re-decorate all fields with the fresh items. 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(); } }); _formObserver = new MutationObserver(function () { _hasNotifiedForm = false; notifyFormDetected(); decorateFields(); }); _formObserver.observe(document.body, { childList: true, subtree: true }); } if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", init); } else { init(); } })();