3401db8ce65a

feat: TOTP popup on multi-digit OTP boxes — no icon, fill all boxes at once (v3.6.104)

time
2026-05-28 15:59 UTC · 5 months ago
machine
koh2
parent
601ee3c2c1ab
7 files manifest.firefox.json+1 −1manifest.json+1 −1package-lock.json+2 −2package.json+1 −1src/content/detect.ts+24 −0src/content/fill.ts+11 −0src/content/index.ts+16 −3
manifest.firefox.json+1 −1
@@ -2,7 +2,7 @@22   "_comment_audio_capture": "Firefox does not honor the Chrome 'audioCapture' optional_permission — getUserMedia in WebExtensions is gated solely by the runtime permission prompt. Microphone access for chirp pairing works from the popup under the existing '<all_urls>' host permission; no manifest entry is required or accepted here.",33   "manifest_version": 3,44   "name": "Peach Password Manager",5-  "version": "3.6.103",5+  "version": "3.6.104",66   "description": "Zero-knowledge password manager",77   "browser_specific_settings": {88     "gecko": {

view file

manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "3.6.103",4+  "version": "3.6.104",55   "description": "Zero-knowledge password manager",66   "action": {77     "default_popup": "src/popup/index.html",

view file

package-lock.json+2 −2
@@ -1,12 +1,12 @@11 {22   "name": "peach",3-  "version": "3.6.103",3+  "version": "3.6.104",44   "lockfileVersion": 3,55   "requires": true,66   "packages": {77     "": {88       "name": "peach",9-      "version": "3.6.103",9+      "version": "3.6.104",1010       "dependencies": {1111         "@bokuweb/zstd-wasm": "^0.0.27",1212         "@stablelib/blake2s": "^2.0.1",

view file

package.json+1 −1
@@ -1,6 +1,6 @@11 {22   "name": "peach",3-  "version": "3.6.103",3+  "version": "3.6.104",44   "description": "Zero-knowledge password manager",55   "type": "module",66   "scripts": {

view file

src/content/detect.ts+24 −0
@@ -250,6 +250,30 @@250250   return fields;251251 }252252 253+// Returns the ordered list of single-character inputs that form a multi-digit254+// OTP entry group, or null if `el` is not part of one. Anchors the search on255+// the nearest <form>; falls back to document.body for formless SPA pages.256+// Groups of fewer than 4 or more than 10 boxes are ignored — those ranges257+// are outside every practical OTP code length.258+export function findOTPDigitBoxGroup(el: HTMLInputElement): HTMLInputElement[] | null {259+  if (el.maxLength !== 1) return null;260+  const t = el.type.toLowerCase();261+  if (!["text", "number", "tel", ""].includes(t)) return null;262+  if (!isVisible(el)) return null;263+264+  const searchRoot: Element = el.closest("form") ?? document.body;265+  const digitBoxes = Array.from(searchRoot.querySelectorAll<HTMLInputElement>("input")).filter(input => {266+    if (input.maxLength !== 1) return false;267+    if (!isVisible(input)) return false;268+    const it = input.type.toLowerCase();269+    return ["text", "number", "tel", ""].includes(it);270+  });271+272+  if (digitBoxes.length < 4 || digitBoxes.length > 10) return null;273+  if (!digitBoxes.includes(el)) return null;274+  return digitBoxes;275+}276+253277 export function isVisible(el: HTMLElement): boolean {254278   const s = getComputedStyle(el);255279   const r = el.getBoundingClientRect();

view file

src/content/fill.ts+11 −0
@@ -38,6 +38,17 @@3838   }3939 }4040 41+// Distributes a TOTP code character-by-character into an ordered array of42+// single-character input boxes (the individual digit entry pattern used by43+// some 2FA flows). Triggers native input+change events on each box so44+// React/Vue controlled inputs update correctly.45+export function fillOTPDigitBoxes(boxes: HTMLInputElement[], code: string): void {46+  const digits = code.replace(/\s/g, "").split("");47+  boxes.forEach((box, i) => {48+    if (i < digits.length) setNativeValue(box, digits[i]!);49+  });50+}51+4152 export function fillTOTPCode(4253   totpEl: HTMLInputElement | null,4354   code: string

view file

src/content/index.ts+16 −3
@@ -1,6 +1,7 @@11 import {22   detectCardForm,33   detectLoginFields,4+  findOTPDigitBoxGroup,45   getDeepActiveInput,56   getPageDomain,67   isAnyCredentialForm,@@ -24,7 +25,7 @@2425 } from "./inject";2526 import { detectBrowser } from "../shared/browser";2627 import { FIREFOX_USE_CONTENT_SCRIPT_PROXY } from "../shared/passkey-flags";27-import { fillCardForm, fillFields, fillTOTPCode, setNativeValue } from "./fill";28+import { fillCardForm, fillFields, fillOTPDigitBoxes, fillTOTPCode, setNativeValue } from "./fill";2829 import { setupSaveDetection } from "./save";2930 import { contentMLDetector } from "./content-ml-detector";3031 import { detectPasswordPolicy } from "./policy";@@ -494,7 +495,7 @@494495   );495496 }496497 497-async function openTOTPSelector(anchorField: HTMLInputElement): Promise<void> {498+async function openTOTPSelector(anchorField: HTMLInputElement, otpDigitBoxes?: HTMLInputElement[]): Promise<void> {498499   if (!canOpen()) return;499500   if (currentDismiss && currentAnchorField === anchorField) return;500501   if (anchorField !== lastDismissedField) lastDismissedField = null;@@ -542,7 +543,11 @@542543         const entry = entryResponse?.ok ? entryResponse.data : undefined;543544         if (!entry?.totp) return;544545         const { code } = generateTOTP(entry.totp);545-        setNativeValue(anchorField, code);546+        if (otpDigitBoxes && otpDigitBoxes.length > 1) {547+          fillOTPDigitBoxes(otpDigitBoxes, code);548+        } else {549+          setNativeValue(anchorField, code);550+        }546551         recordFillSuccess(anchorField);547552         showToast("2FA code filled");548553       } finally {@@ -597,6 +602,14 @@597602   pendingInteractions.add(field);598603 599604   try {605+    // Multi-digit OTP entry: individual maxLength=1 boxes get no icon (too small)606+    // but the TOTP selector opens on focus and fills every box at once.607+    const otpGroup = findOTPDigitBoxGroup(field);608+    if (otpGroup) {609+      await openTOTPSelector(otpGroup[0]!, otpGroup);610+      return;611+    }612+600613     if (isTOTPField(field)) {601614       const formFields = detectLoginFields();602615       if (!isLoginForm(formFields) && !isSignupForm(formFields)) {

view file