Files
PassKeeper/extension/content/content.js
T

472 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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.
* 3. Listens for DO_AUTOFILL from the popup → fills fields.
* 4. Watches form submissions → shows save-credentials banner.
*/
(() => {
"use strict";
const PK_ATTR = "data-pk-decorated";
const PK_BTN_CLASS = "__pk_autofill_btn__";
let _bannerEl = null;
let _hasNotifiedForm = false;
let _formObserver = null;
// ── Helpers ──────────────────────────────────────────────────────────────────
function escHtml(str) {
return String(str ?? "")
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
function visiblePasswordFields() {
return Array.from(
document.querySelectorAll('input[type="password"]'),
).filter((el) => el.offsetParent !== null && !el.disabled);
}
function findUsernameField(pwField) {
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 (["email", "text", "tel"].includes(el.type)) return el;
}
return null;
}
// ── Framework-compatible fill ─────────────────────────────────────────────────
function fillField(el, value) {
const setter = Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype,
"value",
)?.set;
if (setter) setter.call(el, value);
else el.value = value;
el.dispatchEvent(new Event("input", { bubbles: true }));
el.dispatchEvent(new Event("change", { bubbles: true }));
}
function doAutofill(username, password) {
const pwFields = visiblePasswordFields();
if (!pwFields.length) return;
const pwField = pwFields[0];
const usernameField = findUsernameField(pwField);
if (usernameField && username) fillField(usernameField, username);
if (password) fillField(pwField, password);
[usernameField, pwField].filter(Boolean).forEach((el) => {
el.style.outline = "2px solid #1a73e8";
setTimeout(() => {
el.style.outline = "";
}, 1500);
});
}
// ── Inline autofill button ────────────────────────────────────────────────────
/**
* 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 = `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="3" y="9" width="18" height="12" rx="2" stroke="#1a73e8" stroke-width="1.8"/>
<path d="M8 9V6a4 4 0 018 0v3" stroke="#1a73e8" stroke-width="1.8" stroke-linecap="round"/>
<circle cx="12" cy="15" r="1.5" fill="#1a73e8"/>
</svg>`;
wrap.appendChild(btn);
btn.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
toggleDropdown(btn, pwField, matchingItems);
});
});
}
// Dropdown showing matching credentials
function toggleDropdown(btn, pwField, items) {
// Remove any existing dropdown
const existing = document.getElementById("__pk_dropdown__");
if (existing) {
existing.remove();
return;
}
const usernameField = findUsernameField(pwField);
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(";");
// 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";
// 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 = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none"><rect x="3" y="9" width="18" height="12" rx="2" stroke="#1a73e8" stroke-width="2"/><path d="M8 9V6a4 4 0 018 0v3" stroke="#1a73e8" stroke-width="2" stroke-linecap="round"/></svg><span style="font-weight:600;color:#1a1a2e;font-size:12px;">PassKeeper</span>`;
dropdown.appendChild(header);
if (!items.length) {
const empty = document.createElement("div");
empty.style.cssText =
"padding:14px 12px;color:#9ca3af;font-size:12px;text-align:center;";
empty.textContent = "No matching credentials";
dropdown.appendChild(empty);
} else {
items.forEach((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";
};
row.onmouseleave = () => {
row.style.background = "";
};
const username = escHtml(item.plain?.username || "");
const name = escHtml(item.name);
row.innerHTML = `
<div style="width:30px;height:30px;border-radius:7px;background:#1e2d5a;display:flex;align-items:center;justify-content:center;font-size:14px;flex-shrink:0;">🔑</div>
<div style="flex:1;min-width:0;">
<div style="font-weight:600;color:#111827;font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${name}</div>
${username ? `<div style="font-size:11px;color:#6b7280;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${username}</div>` : ""}
</div>`;
row.addEventListener("click", () => {
if (usernameField && item.plain?.username)
fillField(usernameField, item.plain.username);
if (item.plain?.password) fillField(pwField, item.plain.password);
dropdown.remove();
});
dropdown.appendChild(row);
});
}
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);
}
};
setTimeout(() => document.addEventListener("click", close, true), 0);
}
// ── Form detection ────────────────────────────────────────────────────────────
function notifyFormDetected() {
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);
}
// ── 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;
try {
({ vault_items } = await chrome.storage.session.get("vault_items"));
} catch {
// If we can't read storage (e.g. extension context invalidated), show banner.
return "new";
}
if (!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;
try {
const h = new URL(item.plain.url).hostname.replace(/^www\./, "");
return h === host || h.endsWith(`.${host}`) || host.endsWith(`.${h}`);
} catch {
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";
}
// ── 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");
banner.id = "__pk_save_banner__";
Object.assign(banner.style, {
position: "fixed",
top: "12px",
right: "12px",
zIndex: "2147483647",
background: "#ffffff",
border: "1px solid #e2e8f0",
borderRadius: "10px",
boxShadow: "0 8px 30px rgba(0,0,0,0.15)",
padding: "14px 16px 12px",
fontFamily:
"-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif",
fontSize: "13px",
color: "#1a1a2e",
maxWidth: "300px",
minWidth: "240px",
});
const site = escHtml(location.hostname);
const user = escHtml(username);
const title =
credentialState === "updated"
? "Update in PassKeeper?"
: "Save to PassKeeper?";
banner.innerHTML = `
<div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none"><rect x="3" y="9" width="18" height="12" rx="2" stroke="#1a73e8" stroke-width="1.8"/><path d="M8 9V6a4 4 0 018 0v3" stroke="#1a73e8" stroke-width="1.8" stroke-linecap="round"/></svg>
<strong style="flex:1;font-size:13px;color:#111827;">${escHtml(title)}</strong>
<button id="__pk_close__" style="background:none;border:none;cursor:pointer;font-size:18px;color:#9ca3af;line-height:1;padding:0;">×</button>
</div>
<p style="color:#6b7280;font-size:12px;margin-bottom:10px;">
<strong style="color:#111827;">${user}</strong> on <strong style="color:#111827;">${site}</strong>
</p>
<div style="display:flex;gap:8px;">
<button id="__pk_save__" style="flex:1;padding:7px 0;background:#1a1a2e;color:#fff;border:none;border-radius:6px;cursor:pointer;font-size:12px;font-weight:600;">Save</button>
<button id="__pk_skip__" style="flex:1;padding:7px 0;background:transparent;color:#374151;border:1px solid #d1d5db;border-radius:6px;cursor:pointer;font-size:12px;">Not now</button>
</div>`;
document.body.appendChild(banner);
_bannerEl = banner;
// No setTimeout — banner stays until the user makes an explicit choice.
const dismiss = () => {
if (_bannerEl === banner) {
banner.remove();
_bannerEl = null;
}
};
banner.querySelector("#__pk_close__").addEventListener("click", dismiss);
banner.querySelector("#__pk_skip__").addEventListener("click", dismiss);
banner.querySelector("#__pk_save__").addEventListener("click", () => {
console.log(
"[PassKeeper] User chose to save credentials for",
location.hostname,
);
chrome.runtime
.sendMessage({
type: "SAVE_CREDENTIALS",
data: {
url: location.href,
siteName: document.title || location.hostname,
username,
password,
},
})
.catch(() => {});
dismiss();
});
}
// ── Form submission watch ─────────────────────────────────────────────────────
function watchSubmissions() {
document.addEventListener(
"submit",
async (e) => {
const form = e.target;
const pwField = form.querySelector(
'input[type="password"]:not([disabled])',
);
if (!pwField?.value) return;
const 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;
if (!username || !password) return;
// Run duplicate check before showing the banner.
const credentialState = await classifyCredentials(username, password);
console.log(
"[PassKeeper] Credential state for",
location.hostname,
"→",
credentialState,
);
if (credentialState === "same") {
// Credentials unchanged — silently skip.
return;
}
setTimeout(
() => showSaveBanner(username, password, credentialState),
500,
);
},
true,
);
}
// ── Message listener ──────────────────────────────────────────────────────────
chrome.runtime.onMessage.addListener((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());
decorateFields();
}
return false;
});
// ── Init ──────────────────────────────────────────────────────────────────────
function init() {
notifyFormDetected();
decorateFields();
watchSubmissions();
_formObserver = new MutationObserver(() => {
_hasNotifiedForm = false;
notifyFormDetected();
decorateFields();
});
_formObserver.observe(document.body, { childList: true, subtree: true });
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();