diff --git a/extension/background.js b/extension/background.js index 076e5f6..8d0dc02 100644 --- a/extension/background.js +++ b/extension/background.js @@ -76,6 +76,13 @@ chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { // ── Message handler ────────────────────────────────────────────────────────── chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { + // Content script requests opening the vault tab (from suggestion dropdown). + if (msg.type === "OPEN_VAULT") { + chrome.tabs.create({ url: "https://pwkeeper.ngodanguyen.tech/vault" }); + sendResponse({ ok: true }); + return false; + } + // No-op ping from popup — keeps the service worker alive while the browser // is open so chrome.storage.session is not wiped between popup openings. if (msg.type === "KEEPALIVE") { diff --git a/extension/content/content.js b/extension/content/content.js index 5285407..5c505e0 100644 --- a/extension/content/content.js +++ b/extension/content/content.js @@ -2,8 +2,9 @@ * 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. + * 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. */ @@ -12,10 +13,12 @@ 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 ────────────────────────────────────────────────────────────────── @@ -72,151 +75,355 @@ }); } - // ── Inline autofill button ──────────────────────────────────────────────────── + // ── Suggestion dropdown ─────────────────────────────────────────────────────── - /** - * 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); - }); - }); + function removeDropdown() { + const existing = document.getElementById(PK_DROPDOWN_ID); + if (existing) existing.remove(); } - // Dropdown showing matching credentials - function toggleDropdown(btn, pwField, items) { - // Remove any existing dropdown - const existing = document.getElementById("__pk_dropdown__"); - if (existing) { - existing.remove(); - return; - } + /** + * 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(); - const usernameField = findUsernameField(pwField); + // 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__"; - 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(";"); + dropdown.id = PK_DROPDOWN_ID; - // 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"; + // Anchor below the field, same left edge, at least 260 px wide. + const rect = anchorField.getBoundingClientRect(); + const dropWidth = Math.max(260, rect.width); - // 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); + 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", + }); - if (!items.length) { + // 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"); - empty.style.cssText = - "padding:14px 12px;color:#9ca3af;font-size:12px;text-align:center;"; - empty.textContent = "No matching credentials"; + Object.assign(empty.style, { + padding: "12px 14px", + color: "#5f6368", + fontSize: "12px", + }); + empty.textContent = "No saved passwords for this site."; dropdown.appendChild(empty); } else { - items.forEach((item) => { + filtered.forEach(function (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"; + 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 = () => { + row.onmouseleave = function () { row.style.background = ""; }; - const username = escHtml(item.plain?.username || ""); - const name = escHtml(item.name); + // 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) {} + } - row.innerHTML = ` -
🔑
-
-
${name}
- ${username ? `
${username}
` : ""} -
`; + var username = escHtml((item.plain && item.plain.username) || ""); + var site = escHtml(siteHost); - row.addEventListener("click", () => { - if (usernameField && item.plain?.username) + // 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 = + '
' + + site + + "
" + + (username + ? '
' + + 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 (item.plain?.password) fillField(pwField, item.plain.password); - dropdown.remove(); + 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 click - const close = (e) => { - if (!dropdown.contains(e.target) && e.target !== btn) { - dropdown.remove(); - document.removeEventListener("click", close, true); + // 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); } - }; - setTimeout(() => document.addEventListener("click", close, true), 0); + } + 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 ──────────────────────────────────────────────────────────── @@ -225,92 +432,52 @@ 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); + chrome.runtime + .sendMessage({ type: "FORMS_DETECTED" }) + .catch(function () {}); } // ── 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; + var result; try { - ({ vault_items } = await chrome.storage.session.get("vault_items")); - } catch { - // If we can't read storage (e.g. extension context invalidated), show banner. + result = await chrome.storage.session.get("vault_items"); + } catch (e) { return "new"; } - if (!vault_items?.length) return "new"; + var vault_items = result && result.vault_items; + if (!vault_items || !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; + 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 { - const h = new URL(item.plain.url).hostname.replace(/^www\./, ""); - return h === host || h.endsWith(`.${host}`) || host.endsWith(`.${h}`); - } catch { + 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"; - - // 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"; + var exactMatch = siteItems.some(function (item) { + return ( + item.plain && + item.plain.username === username && + item.plain.password === password + ); + }); + return exactMatch ? "same" : "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"); + var banner = document.createElement("div"); banner.id = "__pk_save_banner__"; Object.assign(banner.style, { position: "fixed", @@ -330,32 +497,37 @@ minWidth: "240px", }); - const site = escHtml(location.hostname); - const user = escHtml(username); - const title = + 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} -

-
- - -
`; + 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 = () => { + var dismiss = function () { if (_bannerEl === banner) { banner.remove(); _bannerEl = null; @@ -363,7 +535,7 @@ }; banner.querySelector("#__pk_close__").addEventListener("click", dismiss); banner.querySelector("#__pk_skip__").addEventListener("click", dismiss); - banner.querySelector("#__pk_save__").addEventListener("click", () => { + banner.querySelector("#__pk_save__").addEventListener("click", function () { console.log( "[PassKeeper] User chose to save credentials for", location.hostname, @@ -374,11 +546,11 @@ data: { url: location.href, siteName: document.title || location.hostname, - username, - password, + username: username, + password: password, }, }) - .catch(() => {}); + .catch(function () {}); dismiss(); }); } @@ -388,41 +560,39 @@ function watchSubmissions() { document.addEventListener( "submit", - async (e) => { - const form = e.target; - const pwField = form.querySelector( + async function (e) { + var form = e.target; + var pwField = form.querySelector( 'input[type="password"]:not([disabled])', ); - if (!pwField?.value) return; + if (!pwField || !pwField.value) return; - const userField = - findUsernameField(pwField) ?? - form.querySelector('input[type="email"]:not([disabled])') ?? + var 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; + var username = + (userField && userField.value && userField.value.trim()) || ""; + var password = pwField.value; if (!username || !password) return; - // Run duplicate check before showing the banner. - const credentialState = await classifyCredentials(username, password); + removeDropdown(); + + var credentialState = await classifyCredentials(username, password); console.log( "[PassKeeper] Credential state for", location.hostname, - "→", + "\u2192", credentialState, ); - if (credentialState === "same") { - // Credentials unchanged — silently skip. - return; - } + if (credentialState === "same") return; - setTimeout( - () => showSaveBanner(username, password, credentialState), - 500, - ); + setTimeout(function () { + showSaveBanner(username, password, credentialState); + }, 500); }, true, ); @@ -430,19 +600,19 @@ // ── Message listener ────────────────────────────────────────────────────────── - chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { + 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") { - // 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()); + document.querySelectorAll("[" + PK_ATTR + "]").forEach(function (el) { + el.removeAttribute(PK_ATTR); + }); + document.querySelectorAll("." + PK_BTN_CLASS).forEach(function (el) { + el.remove(); + }); + removeDropdown(); decorateFields(); } return false; @@ -455,7 +625,7 @@ decorateFields(); watchSubmissions(); - _formObserver = new MutationObserver(() => { + _formObserver = new MutationObserver(function () { _hasNotifiedForm = false; notifyFormDetected(); decorateFields();