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();