import { extractDomain } from "../shared/domain";
export interface DetectedField {
type: "username" | "password" | "email";
el: HTMLInputElement;
}
// Conservative: "name" alone is too broad (matches "tag-name", "first-name", etc.).
// No \b anchors — modern sites use camelCase like "userId", "loginEmail" where
// the trigger token is contiguous with other word chars and would not match
// at a word boundary.
const USERNAME_PATTERNS = /(user(name|id|login|email)?|e?mail(box)?|login(email|username)?|account|identifier|signin|sign.?in|credential)/i;
// Personal name fields (display name, first/last name) — not credential fields.
// Applied to prevent the fallback from picking up name inputs on signup forms.
const NAME_FIELD_PATTERNS = /\b(first[\s_-]?name|last[\s_-]?name|full[\s_-]?name|display[\s_-]?name|real[\s_-]?name|given[\s_-]?name|family[\s_-]?name|middle[\s_-]?name)\b|^(name|fname|lname|fullname|displayname|realname)$/i;
// Input types that are never text-entry fields and must never get autofill
// icons. submit/button/reset/image are interactive controls — putting the
// icon over them blocks the click. The rest are structurally incapable of
// holding a username/password.
const SKIP_INPUT_TYPES = new Set([
"submit", "button", "reset", "image",
"checkbox", "radio", "hidden", "file",
"range", "color", "date", "time",
"datetime-local", "month", "week",
]);
// Some sites render submit buttons as with no type attribute (defaults to "text")
// and a value like "Login" or "Submit". Catch those before injecting icons.
function looksLikeSubmitButton(input: HTMLInputElement): boolean {
if (input.type === "password") return false;
const val = (input.value || input.getAttribute("value") || "").toLowerCase().trim();
if (!val) return false;
return ["submit", "login", "sign in", "log in", "continue", "next", "go"].some(w => val === w || val.startsWith(w + " "));
}
function findLabelText(field: HTMLInputElement): string {
const ariaLabel = field.getAttribute("aria-label") ?? "";
if (ariaLabel) return ariaLabel.toLowerCase();
const id = field.id;
if (id) {
// PCH-01-025: ids can contain CSS-special chars (quotes, brackets, leading
// digits). Escape, and defensively swallow a malformed-selector throw so a
// single weird id can't abort field detection for the whole page.
try {
const label = document.querySelector(`label[for="${CSS.escape(id)}"]`);
if (label) return label.textContent?.toLowerCase() ?? "";
} catch {
/* malformed id — skip label-by-id lookup, continue field detection */
}
}
const wrappingLabel = field.closest("label");
if (wrappingLabel?.textContent) return wrappingLabel.textContent.toLowerCase();
let node: Element | null = field;
for (let depth = 0; node && depth < 3; depth++) {
const previous = node.previousElementSibling;
const text = previous?.textContent?.trim().toLowerCase() ?? "";
if (text && text.length <= 80 && !previous?.querySelector("input, select, textarea, button")) {
return text;
}
node = node.parentElement;
}
return "";
}
export function querySelectorAllDeep(
root: Document | ShadowRoot | Element,
selector: string
): T[] {
const results: T[] = [];
const walk = (node: Document | ShadowRoot | Element) => {
if (node instanceof Element) {
results.push(...(node.querySelectorAll(selector)));
const shadowRoot = node.shadowRoot;
if (shadowRoot) {
shadowRoot.querySelectorAll("*").forEach(walk);
}
}
};
root.querySelectorAll("*").forEach(walk);
return results;
}
export function getDeepActiveInput(): HTMLInputElement | null {
let current: Document | Element | null = document.activeElement;
while (current) {
if (current instanceof HTMLInputElement) return current;
if (current instanceof Element && current.shadowRoot) {
current = current.shadowRoot.activeElement;
} else {
break;
}
}
return null;
}
// True for a single-character OTP digit box. Reads the maxlength ATTRIBUTE, not
// the .maxLength property: for type="number"/"tel" inputs (the usual way to get
// a numeric keypad for 2FA codes) the maxlength attribute is IGNORED — browsers
// report .maxLength as a large default (524288) even when the author wrote
// maxlength="1". Reading the attribute reliably captures the real intent, so a
// numeric 2FA box group is recognised as split-OTP instead of each box being
// injected with its own autofill icon.
function isSingleDigitBox(el: HTMLInputElement): boolean {
return el.getAttribute("maxlength") === "1";
}
export function isTOTPField(input: HTMLInputElement): boolean {
if (!isVisible(input)) return false;
// Single-character inputs are individual OTP digit boxes — treat the form's
// full-code input (if any) as the TOTP field, not each individual digit box.
if (isSingleDigitBox(input)) return false;
const autocomplete = input.autocomplete.toLowerCase();
const fieldText = `${input.name} ${input.id} ${input.placeholder}`.toLowerCase();
if (
autocomplete === "one-time-code" ||
/\b(otp|totp|2fa|mfa)\b/.test(fieldText) ||
/\b(verification|authenticator|security)\s+code\b/.test(fieldText)
) {
return true;
}
const labelText = findLabelText(input);
return (
/\b(otp|totp|2fa|mfa)\b/.test(labelText) ||
/\b(verification|authenticator|security)\s+code\b/.test(labelText)
);
}
// AUTH_SIGNALS_RE: submit buttons / form actions / headings that indicate auth intent.
// ANTI_AUTH_RE: signals that contradict auth (newsletter, registration with name fields, etc.).
const AUTH_SIGNALS_RE = /sign.?(?:in|up)|log.?in|continue|next\b|proceed|let.?me.?in|get started|verify|unlock|access/i;
const ANTI_AUTH_RE = /subscri|newsletter|register|join\b|contact|comment/i;
// Email-first / identifier-first step: user enters email or username on this page;
// the password prompt comes on the next step. Requires at least one auth-intent
// signal (URL path, form action, or submit button text) to avoid false-positives
// on newsletter and search forms that also have a single email field.
export function isEmailFirstForm(fields: DetectedField[]): boolean {
if (!fields.some(f => f.type === "username" || f.type === "email")) return false;
if (fields.some(f => f.type === "password")) return false;
// Strong URL signal: /login, /signin, /auth, /sso, /account, etc.
if (/\/(login|signin|sign-in|logon|auth|sso|connect|account(?:\/|$))\b/i.test(location.pathname)) return true;
// Check submit buttons for auth intent.
const buttons = Array.from(document.querySelectorAll(
'button[type="submit"], input[type="submit"], button:not([type="button"]):not([type="reset"])'
));
const hasAuthButton = buttons.some(b => {
const t = (b instanceof HTMLInputElement ? b.value : b.textContent) ?? "";
return AUTH_SIGNALS_RE.test(t) && !ANTI_AUTH_RE.test(t);
});
if (!hasAuthButton) return false;
// Reject if the same form has name/phone/address fields — that's registration, not login.
const identifierEl = fields.find(f => f.type === "username" || f.type === "email")!.el;
const form = identifierEl.closest("form") ?? document.body;
const hasRegistrationFields = Array.from(form.querySelectorAll("input")).some(el => {
if (!["", "text", "tel", "number"].includes(el.type.toLowerCase())) return false;
if (!isVisible(el)) return false;
return /\b(first.?name|last.?name|fname|lname|given|family|phone|mobile|company|organization|address)\b/i
.test(el.name + el.id + el.placeholder);
});
return !hasRegistrationFields;
}
// Password-step: the email/username was entered on a previous page; this page
// shows only a single password field. Also catches sites that render username
// and password on separate screens without autocomplete hints.
export function isPasswordStepForm(fields: DetectedField[]): boolean {
const passwords = fields.filter(f => f.type === "password");
return passwords.length === 1 && !fields.some(f => f.type === "username" || f.type === "email");
}
// Single entry point used by every gate in index.ts. Covers all four patterns:
// traditional login, signup, email-first step, and password-only step.
export function isAnyCredentialForm(fields: DetectedField[]): boolean {
return isLoginForm(fields) || isSignupForm(fields) || isEmailFirstForm(fields) || isPasswordStepForm(fields);
}
export function isLoginForm(fields: DetectedField[]): boolean {
const passwords = fields.filter((f) => f.type === "password");
// Two or more password fields means signup/change-password, not login
if (passwords.length >= 2) return false;
const hasPassword = passwords.length > 0;
const hasUsernameOrEmail = fields.some((f) => f.type === "username" || f.type === "email");
if (hasPassword && hasUsernameOrEmail) return true;
if (passwords.length === 1 && passwords[0].el.autocomplete === "current-password") {
return true;
}
return false;
}
export function isSignupForm(fields: DetectedField[]): boolean {
const passwords = fields.filter((f) => f.type === "password");
if (passwords.length >= 2) return true;
if (passwords.some((f) => f.el.autocomplete === "new-password")) return true;
if (
passwords.some((f) => {
const id = (f.el.id + f.el.name).toLowerCase();
return /new|confirm/i.test(id);
})
) {
return true;
}
return false;
}
export function detectLoginFields(root: Document | ShadowRoot = document): DetectedField[] {
const inputs = Array.from(querySelectorAllDeep(root, "input"));
const fields: DetectedField[] = [];
for (const el of inputs) {
if (!isVisible(el)) continue;
const type = el.type.toLowerCase();
// Never treat buttons, checkboxes, hidden fields, etc. as autofill targets
if (SKIP_INPUT_TYPES.has(type)) continue;
// Single-character inputs are individual OTP digit boxes — never credential fields
if (isSingleDigitBox(el)) continue;
// Catch with no type attribute whose value text identifies it as a submit button
if (looksLikeSubmitButton(el)) continue;
const ac = el.autocomplete;
const name = (el.name + el.id + el.placeholder).toLowerCase();
if (ac === "current-password" || ac === "new-password" || type === "password") {
fields.push({ type: "password", el });
continue;
}
if (ac === "username") {
fields.push({ type: "username", el });
continue;
}
// Explicit email autocomplete or type — classified as "email" so interaction.ts
// can track it separately from the username field on signup forms.
if (ac === "email" || type === "email") {
fields.push({ type: "email", el });
continue;
}
if (USERNAME_PATTERNS.test(name)) {
fields.push({ type: "username", el });
continue;
}
const labelText = findLabelText(el);
if (!fields.some((f) => f.el === el) && USERNAME_PATTERNS.test(labelText)) {
fields.push({ type: "username", el });
}
}
// Fallback: if we have a password field but no username/email, look for
// the first visible text input that appears before the password in DOM order.
// This catches sites with unconventional field names (e.g. name="identifier").
const hasUsername = fields.some((f) => f.type === "username" || f.type === "email");
const pwField = fields.find((f) => f.type === "password");
if (!hasUsername && pwField) {
const allInputs = Array.from(inputs);
const pwIndex = allInputs.indexOf(pwField.el);
for (let i = 0; i < pwIndex; i++) {
const candidate = allInputs[i];
const candidateType = candidate.type.toLowerCase();
const candidateHint = (candidate.name + " " + candidate.id + " " + candidate.placeholder).toLowerCase();
const candidateLabel = findLabelText(candidate);
if (
(candidateType === "text" || candidateType === "email" || candidateType === "") &&
isVisible(candidate) &&
!SKIP_INPUT_TYPES.has(candidateType) &&
!fields.some((f) => f.el === candidate) &&
!NAME_FIELD_PATTERNS.test(candidateHint + " " + candidateLabel)
) {
fields.push({ type: "username", el: candidate });
break; // only take the first one
}
}
}
return fields;
}
// Returns the ordered list of single-character inputs that form a multi-digit
// OTP entry group, or null if `el` is not part of one. Anchors the search on
// the nearest