Newer
Older
gnexus-creds-extension / src / content.js
/**
 * Content script for login form detection, autofill, and save prompt.
 */

let secretsForDomain = [];
let shownCards = new WeakSet();
let autofillContainer = null;

// --- Messaging helper ---

function send(type, payload) {
  return new Promise((resolve, reject) => {
    chrome.runtime.sendMessage({ type, payload }, (response) => {
      if (chrome.runtime.lastError) {
        reject(new Error(chrome.runtime.lastError.message));
        return;
      }
      if (!response || !response.ok) {
        reject(new Error(response?.error || "Unknown error"));
        return;
      }
      resolve(response.data);
    });
  });
}

// --- Debounce helper ---

function debounce(fn, ms) {
  let timer;
  return (...args) => {
    clearTimeout(timer);
    timer = setTimeout(() => fn(...args), ms);
  };
}

// --- Domain matching ---

async function fetchSecretsForDomain() {
  try {
    const domain = location.hostname;
    secretsForDomain = await send("GET_SECRETS_FOR_DOMAIN", { domain });
  } catch {
    secretsForDomain = [];
  }
}

// --- Form detection ---

function findLoginForms() {
  const forms = [];
  const inputs = document.querySelectorAll('input[type="password"]');
  for (const passwordInput of inputs) {
    if (passwordInput.dataset.gnexusCreds === "handled") continue;
    passwordInput.dataset.gnexusCreds = "handled";

    const { form, usernameInput } = findFormContext(passwordInput);
    forms.push({ form, usernameInput, passwordInput });
  }
  return forms;
}

function findFormContext(passwordInput) {
  const form = passwordInput.closest("form");
  if (form) {
    return { form, usernameInput: findUsernameInput(form, passwordInput) };
  }
  // Fallback for SPA inputs without a <form> tag:
  // look within a parent container (section/article/div) up to 3 levels up
  let container = passwordInput.parentElement;
  let depth = 0;
  while (container && depth < 3) {
    const user = findUsernameInput(container, passwordInput);
    if (user) return { form: container, usernameInput: user };
    container = container.parentElement;
    depth++;
  }
  return { form: null, usernameInput: null };
}

function findUsernameInput(scope, passwordInput) {
  const candidates = scope.querySelectorAll('input[type="text"], input[type="email"], input:not([type])');
  for (const input of candidates) {
    if (input === passwordInput) continue;
    const name = (input.name || "").toLowerCase();
    const id = (input.id || "").toLowerCase();
    const placeholder = (input.placeholder || "").toLowerCase();
    const autocomp = (input.autocomplete || "").toLowerCase();
    if (
      autocomp.includes("username") ||
      autocomp.includes("email") ||
      name.includes("user") ||
      name.includes("login") ||
      name.includes("email") ||
      id.includes("user") ||
      id.includes("login") ||
      id.includes("email") ||
      placeholder.includes("user") ||
      placeholder.includes("login") ||
      placeholder.includes("email")
    ) {
      return input;
    }
  }
  // Fallback: first text/email input before the password
  const all = Array.from(scope.querySelectorAll('input[type="text"], input[type="email"], input:not([type])'));
  const idx = all.indexOf(passwordInput);
  if (idx > 0) return all[idx - 1];
  return all[0] || null;
}

// --- Autofill card ---

function getAutofillContainer() {
  if (autofillContainer) return autofillContainer;
  autofillContainer = document.createElement("div");
  autofillContainer.className = "gnexus-creds-autofill-container";
  document.body.appendChild(autofillContainer);
  return autofillContainer;
}

function removeAutofillCard(card, key) {
  card.classList.remove("gnexus-creds-autofill-card-visible");
  card.classList.add("gnexus-creds-autofill-card-exit");
  setTimeout(() => {
    card.remove();
    shownCards.delete(key);
    if (autofillContainer && !autofillContainer.children.length) {
      autofillContainer.remove();
      autofillContainer = null;
    }
  }, 280);
}

function showAutofillCard(form, usernameInput, passwordInput, secret) {
  const key = form || passwordInput;
  if (!key || shownCards.has(key)) return;
  shownCards.add(key);

  const container = getAutofillContainer();
  const card = document.createElement("div");
  card.className = "gnexus-creds-autofill-card";
  card.innerHTML = `
    <div class="gnexus-creds-autofill-title">${escapeHtml(secret.title)}</div>
    <div class="gnexus-creds-autofill-actions">
      <button class="gnexus-creds-autofill-btn gnexus-creds-autofill-btn-primary" data-action="use">Use</button>
      <button class="gnexus-creds-autofill-btn gnexus-creds-autofill-btn-secondary" data-action="dismiss">Dismiss</button>
    </div>
  `;

  container.appendChild(card);
  requestAnimationFrame(() => {
    card.classList.add("gnexus-creds-autofill-card-visible");
  });

  card.querySelector('[data-action="use"]').addEventListener("click", async () => {
    try {
      const revealed = await send("REVEAL_SECRET", { id: secret.id });
      const fields = revealed.fields || [];
      const userField = fields.find((f) => /user|login|email/i.test(f.name));
      const passField = fields.find((f) => /pass|password|пароль/i.test(f.name));
      if (userField && usernameInput) {
        usernameInput.value = userField.value || "";
        triggerInputEvents(usernameInput);
      }
      if (passField && passwordInput) {
        passwordInput.value = passField.value || "";
        triggerInputEvents(passwordInput);
      }
    } catch (err) {
      console.error("[gnexus-creds] autofill failed:", err.message);
    }
    removeAutofillCard(card, key);
  });

  card.querySelector('[data-action="dismiss"]').addEventListener("click", () => {
    removeAutofillCard(card, key);
  });
}

function triggerInputEvents(input) {
  input.dispatchEvent(new Event("input", { bubbles: true }));
  input.dispatchEvent(new Event("change", { bubbles: true }));
}

// --- Save prompt (persistent via background) ---

function isAlreadySaved(domain, username) {
  if (!secretsForDomain.length) return false;
  if (username) {
    return secretsForDomain.some((s) => (s.title || "").includes(username));
  }
  return secretsForDomain.some((s) => (s.source || "").includes(domain) || (s.title || "").includes(domain));
}

function notifyPendingSave(username, password) {
  const domain = location.hostname;
  if (isAlreadySaved(domain, username)) return;
  const title = `${domain} — ${username || "account"}`;
  chrome.runtime.sendMessage(
    {
      type: "PENDING_SAVE",
      payload: {
        title,
        source: domain,
        fields: [
          { name: "username", value: username || "", encrypted: true, masked: false },
          { name: "password", value: password || "", encrypted: true, masked: true },
        ],
        category: "web",
        tags: ["autofill", "browser-extension", "credentials"],
        allow_ui: true,
        allow_rest_api: true,
      },
    },
    () => {
      // Ignore port-closed errors when the page navigates away
      if (chrome.runtime.lastError) {
        /* ignore */
      }
    }
  );
}

// --- Intercept form submit ---

function attachSubmitInterceptor(form, usernameInput, passwordInput) {
  if (form) {
    form.addEventListener("submit", () => {
      const username = usernameInput?.value?.trim() || "";
      const password = passwordInput?.value?.trim() || "";
      if (!password) return;
      notifyPendingSave(username, password);
    }, true);
  } else {
    // No <form> tag: intercept Enter key on password input
    // and clicks on nearby submit-like buttons
    passwordInput.addEventListener("keydown", (e) => {
      if (e.key !== "Enter") return;
      const username = usernameInput?.value?.trim() || "";
      const password = passwordInput?.value?.trim() || "";
      if (!password) return;
      notifyPendingSave(username, password);
    });

    // Try to find a submit button near the password field
    let container = passwordInput.parentElement;
    let depth = 0;
    while (container && depth < 3) {
      const btn = container.querySelector('button[type="submit"], input[type="submit"], button:not([type])');
      if (btn) {
        btn.addEventListener("click", () => {
          const username = usernameInput?.value?.trim() || "";
          const password = passwordInput?.value?.trim() || "";
          if (!password) return;
          notifyPendingSave(username, password);
        });
        break;
      }
      container = container.parentElement;
      depth++;
    }
  }
}

// --- Main flow ---

async function scanPage() {
  await fetchSecretsForDomain();
  const forms = findLoginForms();
  for (const { form, usernameInput, passwordInput } of forms) {
    // Autofill card
    if (secretsForDomain.length > 0 && usernameInput) {
      const bestMatch = secretsForDomain[0];
      showAutofillCard(form, usernameInput, passwordInput, bestMatch);
    }
    // Save interceptor
    attachSubmitInterceptor(form, usernameInput, passwordInput);
  }
}

const debouncedScan = debounce(scanPage, 300);

// Observe DOM changes for SPA-like navigation — filter for password inputs
const observer = new MutationObserver((mutations) => {
  let hasPassword = false;
  for (const m of mutations) {
    for (const node of m.addedNodes) {
      if (node.nodeType !== Node.ELEMENT_NODE) continue;
      if (node.matches?.('input[type="password"]') || node.querySelector?.('input[type="password"]')) {
        hasPassword = true;
        break;
      }
    }
    if (hasPassword) break;
  }
  if (hasPassword) debouncedScan();
});
observer.observe(document.documentElement, { childList: true, subtree: true });

// Initial scan
if (document.readyState === "loading") {
  document.addEventListener("DOMContentLoaded", scanPage);
} else {
  scanPage();
}

function escapeHtml(text) {
  if (text == null) return "";
  const div = document.createElement("div");
  div.textContent = String(text);
  return div.innerHTML;
}