From bb000e0affc5aa006cc87ed401a41a11cff16f76 Mon Sep 17 00:00:00 2001 From: Nguyen Ngo Date: Mon, 20 Apr 2026 08:15:49 -0400 Subject: [PATCH] 04/20 Update extension UI and functions: add item, form filler --- extension/background.js | 27 +- extension/content/content.js | 712 +++++++++++++++++++++++++---------- extension/popup/popup.css | 38 +- extension/popup/popup.html | 6 + extension/popup/popup.js | 101 ++++- 5 files changed, 672 insertions(+), 212 deletions(-) diff --git a/extension/background.js b/extension/background.js index 8d0dc02..6c3a7f1 100644 --- a/extension/background.js +++ b/extension/background.js @@ -83,6 +83,16 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { return false; } + // Content script requests navigating the popup to the generator view. + if (msg.type === "OPEN_GENERATOR") { + chrome.storage.session.set({ popup_nav: "generator" }); + chrome.action.openPopup().catch(() => { + // openPopup() requires user gesture in some Chrome versions — fallback is a no-op. + }); + 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") { @@ -90,9 +100,24 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { return false; } - // Popup signals vault cache was refreshed → re-check all tab badges. + // Popup signals vault cache was refreshed → re-check badges AND forward + // the decrypted items directly to all content scripts (avoids storage read). if (msg.type === "VAULT_UPDATED") { refreshAllBadges(); + const payload = { + type: "VAULT_UPDATED", + vault_items: msg.vault_items || [], + }; + chrome.tabs.query({}, function (tabs) { + tabs.forEach(function (tab) { + if ( + tab.url && + (tab.url.startsWith("http://") || tab.url.startsWith("https://")) + ) { + chrome.tabs.sendMessage(tab.id, payload).catch(function () {}); + } + }); + }); sendResponse({ ok: true }); return false; } diff --git a/extension/content/content.js b/extension/content/content.js index 5c505e0..96e9b3f 100644 --- a/extension/content/content.js +++ b/extension/content/content.js @@ -2,23 +2,28 @@ * 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. + * 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_autofill_btn__"; + 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 = []; // cached matching vault items for the current page + let _matchingItems = []; + // Map from field element → its fixed-position icon button element + const _fieldBtnMap = new WeakMap(); // ── Helpers ────────────────────────────────────────────────────────────────── @@ -29,31 +34,62 @@ .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) => el.offsetParent !== null && !el.disabled); + ).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 (!el.offsetParent || el.disabled) continue; + 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 setter = Object.getOwnPropertyDescriptor( + const nativeSet = Object.getOwnPropertyDescriptor( HTMLInputElement.prototype, "value", )?.set; - if (setter) setter.call(el, value); + if (nativeSet) nativeSet.call(el, value); else el.value = value; el.dispatchEvent(new Event("input", { bubbles: true })); el.dispatchEvent(new Event("change", { bubbles: true })); @@ -66,34 +102,142 @@ 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"; + 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 existing = document.getElementById(PK_DROPDOWN_ID); - if (existing) existing.remove(); + const el = document.getElementById(PK_DROPDOWN_ID); + if (el) el.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 + * 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). */ - function showDropdown(anchorField, pwField, items, filterText) { + async function showDropdown(anchorField, pwField, filterText, panel) { removeDropdown(); - // Filter by what the user has already typed. + // 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) { @@ -105,47 +249,22 @@ }) : 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. + // 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 = "No saved passwords for this site."; + empty.textContent = q + ? "No matches found." + : "No saved passwords for this site."; dropdown.appendChild(empty); } else { filtered.forEach(function (item) { @@ -154,7 +273,7 @@ display: "flex", alignItems: "center", gap: "10px", - padding: "9px 12px", + padding: "10px 14px", cursor: "pointer", transition: "background 0.1s", }); @@ -165,7 +284,7 @@ row.style.background = ""; }; - // Derive display hostname from stored URL. + // Derive display hostname. var siteHost = item.name; if (item.plain && item.plain.url) { try { @@ -176,13 +295,13 @@ var username = escHtml((item.plain && item.plain.username) || ""); var site = escHtml(siteHost); - // Lock icon avatar. + // Lock icon avatar — filled dark circle like the screenshot. var avatar = document.createElement("div"); Object.assign(avatar.style, { - width: "32px", - height: "32px", + width: "34px", + height: "34px", borderRadius: "50%", - background: "#e8eaed", + background: "#1a1a2e", display: "flex", alignItems: "center", justifyContent: "center", @@ -190,30 +309,30 @@ }); avatar.innerHTML = '' + - '' + - '' + + '' + + '' + ""; - // Text block: site name + username. + // Text. var text = document.createElement("div"); text.style.cssText = "flex:1;min-width:0;"; text.innerHTML = - '
' + + '
' + site + "
" + (username - ? '
' + + ? '
' + username + "
" : ""); - // Edit (pencil) icon. + // Edit pencil. var editBtn = document.createElement("button"); Object.assign(editBtn.style, { background: "none", border: "none", cursor: "pointer", - padding: "4px", + padding: "5px", color: "#1a73e8", display: "flex", alignItems: "center", @@ -222,9 +341,9 @@ }); editBtn.title = "Edit in PassKeeper"; editBtn.innerHTML = - '' + - '' + - '' + + '' + + '' + + '' + ""; editBtn.addEventListener("mousedown", function (e) { e.preventDefault(); @@ -239,7 +358,6 @@ 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(); @@ -257,157 +375,339 @@ 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); + // 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, { + 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: "8px", - padding: "9px 12px", + gap: "12px", + padding: "11px 14px", cursor: "pointer", - color: "#1a73e8", - fontSize: "12px", - fontWeight: "500", + color: "#202124", + fontSize: "13px", transition: "background 0.1s", + borderBottom: "1px solid #f3f4f6", }); - more.onmouseenter = function () { - more.style.background = "#f1f3f4"; + row.onmouseenter = function () { + row.style.background = "#f1f3f4"; }; - more.onmouseleave = function () { - more.style.background = ""; + row.onmouseleave = function () { + row.style.background = ""; }; - more.innerHTML = - '' + - '' + - '' + - '' + - " More options\u2026"; - more.addEventListener("mousedown", function (e) { + + 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(); - chrome.runtime - .sendMessage({ type: "OPEN_VAULT" }) - .catch(function () {}); - removeDropdown(); + item.action(); }); - 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); + dropdown.appendChild(row); + }); } // ── 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 + // 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"); - // 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); + // 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; - // Prevent typed text from sliding under the icon. - field.style.paddingRight = "34px"; + createIconBtn(field, pwField, sig); + } - // PassKeeper icon button inside the right edge of the field. - var btn = document.createElement("button"); + function createIconBtn(field, pwField, abortSignal) { + const btn = document.createElement("button"); btn.type = "button"; btn.className = PK_BTN_CLASS; - btn.title = "Autofill with PassKeeper"; + btn.title = "PassKeeper autofill"; btn.setAttribute("aria-label", "Autofill with PassKeeper"); btn.style.cssText = [ - "position:absolute", - "right:6px", - "top:50%", - "transform:translateY(-50%)", - "background:none", + "position:fixed", + "width:26px", + "height:26px", + "background:#c0392b", "border:none", + "border-radius:5px", "cursor:pointer", - "padding:3px", "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 = - '' + - '' + - '' + - '' + + '' + + '' + + '' + + '' + ""; - wrap.appendChild(btn); - - // Show dropdown when the field gains focus. - field.addEventListener("focus", function () { - showDropdown(field, pwField, items, field.value); + 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, items, field.value); - }); + field.addEventListener( + "input", + function () { + showDropdown(field, pwField, field.value, "credentials"); + }, + { signal: abortSignal }, + ); - // Icon button toggles the dropdown. + // 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(); - var existing = document.getElementById(PK_DROPDOWN_ID); - if (existing) { + if (document.getElementById(PK_DROPDOWN_ID)) { removeDropdown(); } else { - showDropdown(field, pwField, items, field.value); + showDropdown(field, pwField, field.value, "credentials"); } }); + + return btn; } async function decorateFields() { - // Get cached vault items from session storage. - var result; + // Read from chrome.storage.local — reliable across all Chrome versions and + // does not depend on message delivery from the service worker. + var all = []; 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 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\./, ""); - _matchingItems = vault_items.filter(function (item) { + return (items || []).filter(function (item) { if (item.item_type !== "password" || !(item.plain && item.plain.url)) return false; try { @@ -417,13 +717,6 @@ 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 ──────────────────────────────────────────────────────────── @@ -440,27 +733,16 @@ // ── Duplicate detection ─────────────────────────────────────────────────────── async function classifyCredentials(username, password) { - var result; + var all = []; try { - result = await chrome.storage.session.get("vault_items"); + var result = await chrome.storage.local.get("vault_items_cs"); + all = (result && result.vault_items_cs) || []; } 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 (!all.length) return "new"; + var siteItems = _filterForHost(all); if (!siteItems.length) return "new"; var exactMatch = siteItems.some(function (item) { return ( @@ -506,7 +788,7 @@ banner.innerHTML = '
' + - '' + + '' + '' + escHtml(title) + "" + @@ -520,7 +802,7 @@ "" + "

" + '
' + - '' + + '' + '' + "
"; @@ -575,7 +857,6 @@ var username = (userField && userField.value && userField.value.trim()) || ""; var password = pwField.value; - if (!username || !password) return; removeDropdown(); @@ -587,7 +868,6 @@ "\u2192", credentialState, ); - if (credentialState === "same") return; setTimeout(function () { @@ -606,7 +886,22 @@ 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) { @@ -625,6 +920,33 @@ 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(); diff --git a/extension/popup/popup.css b/extension/popup/popup.css index 96ccad4..4fb9394 100644 --- a/extension/popup/popup.css +++ b/extension/popup/popup.css @@ -432,9 +432,11 @@ body { inset: 0; background: rgba(0, 0, 0, 0.45); display: flex; - align-items: center; + align-items: flex-start; /* anchor to top so card is never cut off */ justify-content: center; z-index: 9999; + overflow-y: auto; /* allow scrolling if popup height is small */ + padding: 16px 14px; /* breathing room from top/bottom edges */ } .save-overlay.hidden { display: none; @@ -442,24 +444,27 @@ body { .save-modal { background: #fff; border-radius: 12px; - padding: 20px 18px 16px; - width: 272px; + padding: 18px 16px 14px; + width: 100%; /* fill the overlay width minus its padding */ + max-width: 288px; box-shadow: 0 12px 40px rgba(0, 0, 0, 0.22); + flex-shrink: 0; /* never crush the card */ } .save-modal-header { display: flex; align-items: center; gap: 8px; - font-size: 14px; + font-size: 13px; font-weight: 700; color: #1a1a2e; - margin-bottom: 6px; + margin-bottom: 5px; } .save-modal-sub { font-size: 11px; color: #6b7280; - margin-bottom: 12px; + margin-bottom: 10px; word-break: break-all; + line-height: 1.4; } .save-modal-actions { display: flex; @@ -473,6 +478,27 @@ body { margin-top: 0; } +/* Folder select inside the save modal */ +.save-folder-select { + width: 100%; + padding: 7px 10px; + border: 1px solid #ddd; + border-radius: 6px; + font-size: 13px; + color: #1a1a2e; + background: #fff; + outline: none; + cursor: pointer; + transition: + border-color 0.15s, + box-shadow 0.15s; + appearance: auto; +} +.save-folder-select:focus { + border-color: #c0392b; + box-shadow: 0 0 0 2px rgba(192, 57, 43, 0.12); +} + /* ── Generator view ──────────────────────────────────────────────── */ .gen-suggestion { background: #e8f5f0; diff --git a/extension/popup/popup.html b/extension/popup/popup.html index 02d9b0b..6bac1fb 100644 --- a/extension/popup/popup.html +++ b/extension/popup/popup.html @@ -313,6 +313,12 @@ placeholder="username or email" />
+
+ + +
diff --git a/extension/popup/popup.js b/extension/popup/popup.js index 34cff4a..dffc1d2 100644 --- a/extension/popup/popup.js +++ b/extension/popup/popup.js @@ -341,7 +341,11 @@ async function signOut() { } } catch {} await chrome.storage.session.clear(); - await chrome.storage.local.remove(["refresh_token", "enc_key_salt"]); + await chrome.storage.local.remove([ + "refresh_token", + "enc_key_salt", + "vault_items_cs", + ]); _vaultKey = null; _items = []; showView("login"); @@ -411,8 +415,27 @@ async function fetchAndDecryptVault() { }), ); + // Write to session for the popup's own use (badge, rendering). await chrome.storage.session.set({ vault_items: _items }); - chrome.runtime.sendMessage({ type: "VAULT_UPDATED" }).catch(() => {}); + + // Write a lightweight copy to local storage — this is what content scripts + // read, since chrome.storage.local works reliably across all Chrome versions + // and does not require message delivery from the background service worker. + const itemsForContentScript = _items.map((item) => ({ + id: item.id, + name: item.name, + item_type: item.item_type, + plain: item.plain, + })); + await chrome.storage.local.set({ vault_items_cs: itemsForContentScript }); + + // Notify background to refresh badges and forward to content scripts. + chrome.runtime + .sendMessage({ + type: "VAULT_UPDATED", + vault_items: itemsForContentScript, + }) + .catch(() => {}); } catch (err) { console.error("fetchAndDecryptVault:", err); } finally { @@ -581,6 +604,30 @@ function initTabs() { // ── Save-prompt modal ───────────────────────────────────────────────────────── +/** + * Populate the folder