repos
/ orchard main

orchard

mirror

Every site I host, in one repo, along with the Cloudflare Tunnel and Caddy that front them. It's all Go, Vite, and SQLite, and it runs on a desktop at home with nothing listening on an inbound port.

blogbuncaddycloudflare-tunneldockergogolanghomelabhtml-templatemonorepoself-hostedseosqlitestatic-sitetypstuptime-monitoringviteweb-analytics

3.8 KB · 124 lines · JavaScript Raw History
  1// Six boxes for the six digit code, which is what every code entry worth using
  2// does now: one character per field, focus follows typing, and it submits itself
  3// the moment the last one is filled.
  4//
  5// Progressive enhancement rather than markup. The template renders an ordinary
  6// single field and hides the boxes, so a browser with no JavaScript still has a
  7// working sign in, and this swaps them over on load.
  8
  9const LEN = 6;
 10
 11export function otp() {
 12  const form = document.querySelector("[data-otp-form]");
 13  if (!form) return;
 14
 15  const wrap = form.querySelector("[data-otp]");
 16  const fallback = form.querySelector("[data-otp-fallback]");
 17  if (!wrap || !fallback) return;
 18
 19  const boxes = Array.from(wrap.querySelectorAll("input"));
 20  if (boxes.length !== LEN) return;
 21
 22  // type=hidden rather than hidden=true, so a `required` field the browser
 23  // cannot see never blocks the submit with a validation message pointing at
 24  // nothing.
 25  fallback.type = "hidden";
 26  fallback.required = false;
 27  wrap.hidden = false;
 28
 29  const button = form.querySelector("[type=submit]");
 30  const label = button ? button.textContent : "";
 31  let submitted = false;
 32
 33  const value = () => boxes.map((b) => b.value).join("");
 34
 35  function maybeSubmit() {
 36    if (submitted || value().length !== LEN) return;
 37    fallback.value = value();
 38    for (const b of boxes) b.blur();
 39    form.requestSubmit();
 40  }
 41
 42  // The sixth digit submits on its own, but the request takes long enough that
 43  // people reach for the button anyway, and the second POST carries a code the
 44  // first one already burned, so they get an error for signing in correctly.
 45  form.addEventListener("submit", (e) => {
 46    if (submitted) {
 47      e.preventDefault();
 48      return;
 49    }
 50    submitted = true;
 51    if (button) {
 52      button.disabled = true;
 53      button.textContent = "Signing in…";
 54    }
 55  });
 56
 57  // Coming back to this page from the history cache restores it exactly as it
 58  // was left, disabled button and all, so a second attempt would have nothing
 59  // to press.
 60  window.addEventListener("pageshow", (e) => {
 61    if (!e.persisted) return;
 62    submitted = false;
 63    if (button) {
 64      button.disabled = false;
 65      button.textContent = label;
 66    }
 67  });
 68
 69  function fill(from, digits) {
 70    let i = from;
 71    for (const d of digits) {
 72      if (i >= LEN) break;
 73      boxes[i].value = d;
 74      i++;
 75    }
 76    // Land on the first empty box, or the last one when the code is complete.
 77    const next = boxes.findIndex((b) => b.value === "");
 78    boxes[next === -1 ? LEN - 1 : next].focus();
 79    maybeSubmit();
 80  }
 81
 82  boxes.forEach((box, i) => {
 83    box.addEventListener("input", () => {
 84      // A phone's autofill drops the whole code into the first box, so this is
 85      // the paste path as much as the typing one.
 86      const digits = box.value.replace(/\D/g, "");
 87      box.value = "";
 88      if (!digits) return;
 89      fill(i, digits);
 90    });
 91
 92    box.addEventListener("keydown", (e) => {
 93      if (e.key === "Backspace" && box.value === "" && i > 0) {
 94        e.preventDefault();
 95        boxes[i - 1].value = "";
 96        boxes[i - 1].focus();
 97        return;
 98      }
 99      if (e.key === "ArrowLeft" && i > 0) {
100        e.preventDefault();
101        boxes[i - 1].focus();
102      }
103      if (e.key === "ArrowRight" && i < LEN - 1) {
104        e.preventDefault();
105        boxes[i + 1].focus();
106      }
107    });
108
109    box.addEventListener("paste", (e) => {
110      e.preventDefault();
111      const digits = (e.clipboardData || window.clipboardData)
112        .getData("text")
113        .replace(/\D/g, "");
114      if (digits) fill(i, digits);
115    });
116
117    // Selecting the contents means typing over a digit replaces it rather than
118    // being ignored, since maxlength is already reached.
119    box.addEventListener("focus", () => box.select());
120  });
121
122  boxes[0].focus();
123}