Zyxel Router: Bitwarden Autofill Fix

Fixes Bitwarden autofill on Zyxel router login pages (EX7710-B0, EX5601-T0, and similar).

// ==UserScript==
// @name         Zyxel Router: Bitwarden Autofill Fix
// @namespace    io.zyxel.autofill
// @version      1.0.2
// @description  Fixes Bitwarden autofill on Zyxel router login pages (EX7710-B0, EX5601-T0, and similar). 
//               Prevents the router UI from deleting the password field and ensures the correct value is submitted.
// @match        http://192.168.1.1/*
// @match        https://192.168.1.1/*
// @run-at       document-idle
// @noframes
// @grant        none
// @license      GPL-2.0-only; https://www.gnu.org/licenses/old-licenses/gpl-2.0.txt
// ==/UserScript==

(function () {
  'use strict';

  // CSS selectors for important elements on Zyxel login page
  const SEL = {
    user: '#username',
    passCandidates: '#userpassword, .maskPassword, .unmaskPassword',
    passMask: '.maskPassword',
    passUnmask: '.unmaskPassword',
    eye: '#userpassword_maskCheck', // toggle mask/unmask checkbox
    loginBtn: '#loginBtn',
    form: 'form.form-login'
  };

  // Tracks latest captured username and password
  const state = { user: '', pw: '' };

  // Helper shortcuts
  const $ = (s, root = document) => root.querySelector(s);
  const $$ = (s, root = document) => Array.from(root.querySelectorAll(s));
  const fire = (el, type, opts = {}) =>
    el && el.dispatchEvent(new Event(type, { bubbles: true, cancelable: true, ...opts }));
  const keyup = (el) =>
    el && el.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, cancelable: true }));

  // Sets element value and fires events so the page detects the change
  const setVal = (el, v) => {
    if (!el || el.value === v) return;
    el.value = v;
    fire(el, 'input');
    keyup(el);
    fire(el, 'change');
  };

  // Returns all candidate password fields currently in DOM
  const passInputs = () =>
    $$(SEL.passCandidates).filter((el) => el instanceof HTMLInputElement);

  // Makes sure the unmasked password field is active instead of masked
  const preferUnmasked = () => {
    const eye = $(SEL.eye);
    const m = $(SEL.passMask);
    const u = $(SEL.passUnmask);
    if (!eye || !m || !u) return;

    const maskedVisible = m.style.display !== 'none';
    const unmaskedVisible = u.style.display !== 'none';
    if (maskedVisible && !unmaskedVisible) eye.click(); // simulate clicking eye icon
  };

  // Capture value from username/password once and mark element to avoid duplicate binding
  const hookCaptureOnce = (el, isUser) => {
    if (!el || el.__zyCap) return;
    el.__zyCap = true;

    const update = () => {
      const v = el.value || '';
      if (!v) return;
      if (isUser) state.user = v; else state.pw = v;
    };

    el.addEventListener('input', update, true);
    el.addEventListener('change', update, true);
    update();
  };

  // Attach capture hooks to username and all password fields
  const wireCapture = () => {
    hookCaptureOnce($(SEL.user), true);
    passInputs().forEach((el) => hookCaptureOnce(el, false));
  };

  // Reapply captured values into fields (Bitwarden fix)
  const applyOnce = () => {
    const uEl = $(SEL.user);
    if (state.user && uEl) setVal(uEl, state.user);
    if (state.pw) passInputs().forEach((el) => setVal(el, state.pw));
  };

  // Ensure values are re-applied just before submit (button click, Enter key, or form submit)
  const installPreSubmit = () => {
    const pre = () => {
      preferUnmasked();
      applyOnce();
    };

    // Clicks on login button
    document.addEventListener('pointerdown', (e) => {
      const btn = e.target.closest && e.target.closest(SEL.loginBtn);
      if (btn) pre();
    }, true);
    document.addEventListener('mousedown', (e) => {
      const btn = e.target.closest && e.target.closest(SEL.loginBtn);
      if (btn) pre();
    }, true);

    // Pressing Enter on form
    document.addEventListener('keydown', (e) => {
      if (e.key !== 'Enter') return;
      if ($(SEL.form)) pre();
    }, true);

    // Submitting the form
    document.addEventListener('submit', (e) => {
      if (e.target.matches && e.target.matches(SEL.form)) pre();
    }, true);
  };

  // Continuously re-assert values against Zyxel's aggressive DOM rewrites
  const installGlobalCapture = () => {
    // Keeps applying values for a duration after an event
    const armKeepAlive = (() => {
      let rafId = null;
      let until = 0;
      const tick = () => {
        applyOnce();
        if (performance.now() < until) {
          rafId = requestAnimationFrame(tick);
        } else {
          rafId = null;
        }
      };
      return (ms) => {
        until = performance.now() + ms;
        if (!rafId) rafId = requestAnimationFrame(tick);
      };
    })();

    // Capture user typing into fields
    document.addEventListener('input', (e) => {
      const t = e.target;
      if (!(t instanceof HTMLInputElement)) return;
      if (t.matches(SEL.user) && t.value) {
        state.user = t.value;
        armKeepAlive(5000);
      }
      if (t.matches(SEL.passCandidates) && t.value) {
        state.pw = t.value;
        armKeepAlive(5000);
      }
    }, true);

    // Capture change events too
    document.addEventListener('change', (e) => {
      const t = e.target;
      if (!(t instanceof HTMLInputElement)) return;
      if (t.matches(SEL.user) && t.value) state.user = t.value;
      if (t.matches(SEL.passCandidates) && t.value) state.pw = t.value;
    }, true);

    // Watch DOM mutations (Zyxel rebuilds inputs often)
    const mo = new MutationObserver(() => {
      preferUnmasked();
      wireCapture();
      if (state.user || state.pw) armKeepAlive(1500);
    });
    mo.observe(document.documentElement, { childList: true, subtree: true });
  };

  // Entry point: prepare fields and start listeners
  const init = () => {
    preferUnmasked();
    wireCapture();
    installGlobalCapture();
    installPreSubmit();
  };

  // Initialize when DOM is ready
  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', init, { once: true });
  } else {
    init();
  }
})();