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 =
- '