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
↓ peach-browser-3401db8c.tar.gzatlas at this save
files changed7 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 −3manifest.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": {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",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",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": {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();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: stringsrc/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)) {