Files
PassKeeper/extension/content/content.js
T
2026-04-16 08:27:07 -04:00

346 lines
14 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,'&amp;').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);
}
// ── Auto-save banner ──────────────────────────────────────────────────────────
function showSaveBanner(username, password) {
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);
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;">Save to PassKeeper?</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;
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', () => {
chrome.runtime.sendMessage({
type: 'SAVE_CREDENTIALS',
data: { url: location.href, siteName: document.title || location.hostname, username, password },
}).catch(() => {});
dismiss();
});
setTimeout(dismiss, 20_000);
}
// ── Form submission watch ─────────────────────────────────────────────────────
function watchSubmissions() {
document.addEventListener('submit', 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) {
setTimeout(() => showSaveBanner(username, password), 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();
}
})();