f262c65e6083

support email-first, password-step, and all multi-step login flows (v3.6.102)

time
2026-05-28 02:52 UTC · 5 months ago
machine
koh2
parent
2b15820d0bc0
6 files manifest.firefox.json+10 −4manifest.json+7 −3package-lock.json+2 −2package.json+1 −1src/content/detect.ts+52 −0src/content/index.ts+18 −18
manifest.firefox.json+10 −4
@@ -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.101",5+  "version": "3.6.102",66   "description": "Zero-knowledge password manager",77   "browser_specific_settings": {88     "gecko": {@@ -26,7 +26,9 @@2626     }2727   },2828   "background": {29-    "scripts": ["src/background/entry.ts"],29+    "scripts": [30+      "src/background/entry.ts"31+    ],3032     "type": "module"3133   },3234   "content_scripts": [@@ -102,8 +104,12 @@102104   },103105   "web_accessible_resources": [104106     {105-      "resources": ["ggwave/*"],106-      "matches": ["<all_urls>"]107+      "resources": [108+        "ggwave/*"109+      ],110+      "matches": [111+        "<all_urls>"112+      ]107113     }108114   ],109115   "icons": {

view file

manifest.json+7 −3
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "3.6.101",4+  "version": "3.6.102",55   "description": "Zero-knowledge password manager",66   "action": {77     "default_popup": "src/popup/index.html",@@ -74,8 +74,12 @@7474   },7575   "web_accessible_resources": [7676     {77-      "resources": ["ggwave/*"],78-      "matches": ["<all_urls>"]77+      "resources": [78+        "ggwave/*"79+      ],80+      "matches": [81+        "<all_urls>"82+      ]7983     }8084   ],8185   "icons": {

view file

package-lock.json+2 −2
@@ -1,12 +1,12 @@11 {22   "name": "peach",3-  "version": "3.6.100",3+  "version": "3.6.102",44   "lockfileVersion": 3,55   "requires": true,66   "packages": {77     "": {88       "name": "peach",9-      "version": "3.6.100",9+      "version": "3.6.102",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.101",3+  "version": "3.6.102",44   "description": "Zero-knowledge password manager",55   "type": "module",66   "scripts": {

view file

src/content/detect.ts+52 −0
@@ -104,6 +104,58 @@104104   );105105 }106106 107+// AUTH_SIGNALS_RE: submit buttons / form actions / headings that indicate auth intent.108+// ANTI_AUTH_RE: signals that contradict auth (newsletter, registration with name fields, etc.).109+const AUTH_SIGNALS_RE = /sign.?(?:in|up)|log.?in|continue|next\b|proceed|let.?me.?in|get started|verify|unlock|access/i;110+const ANTI_AUTH_RE = /subscri|newsletter|register|join\b|contact|comment/i;111+112+// Email-first / identifier-first step: user enters email or username on this page;113+// the password prompt comes on the next step. Requires at least one auth-intent114+// signal (URL path, form action, or submit button text) to avoid false-positives115+// on newsletter and search forms that also have a single email field.116+export function isEmailFirstForm(fields: DetectedField[]): boolean {117+  if (!fields.some(f => f.type === "username" || f.type === "email")) return false;118+  if (fields.some(f => f.type === "password")) return false;119+120+  // Strong URL signal: /login, /signin, /auth, /sso, /account, etc.121+  if (/\/(login|signin|sign-in|logon|auth|sso|connect|account(?:\/|$))\b/i.test(location.pathname)) return true;122+123+  // Check submit buttons for auth intent.124+  const buttons = Array.from(document.querySelectorAll<HTMLElement>(125+    'button[type="submit"], input[type="submit"], button:not([type="button"]):not([type="reset"])'126+  ));127+  const hasAuthButton = buttons.some(b => {128+    const t = (b instanceof HTMLInputElement ? b.value : b.textContent) ?? "";129+    return AUTH_SIGNALS_RE.test(t) && !ANTI_AUTH_RE.test(t);130+  });131+  if (!hasAuthButton) return false;132+133+  // Reject if the same form has name/phone/address fields — that's registration, not login.134+  const identifierEl = fields.find(f => f.type === "username" || f.type === "email")!.el;135+  const form = identifierEl.closest("form") ?? document.body;136+  const hasRegistrationFields = Array.from(form.querySelectorAll<HTMLInputElement>("input")).some(el => {137+    if (!["", "text", "tel", "number"].includes(el.type.toLowerCase())) return false;138+    if (!isVisible(el)) return false;139+    return /\b(first.?name|last.?name|fname|lname|given|family|phone|mobile|company|organization|address)\b/i140+      .test(el.name + el.id + el.placeholder);141+  });142+  return !hasRegistrationFields;143+}144+145+// Password-step: the email/username was entered on a previous page; this page146+// shows only a single password field. Also catches sites that render username147+// and password on separate screens without autocomplete hints.148+export function isPasswordStepForm(fields: DetectedField[]): boolean {149+  const passwords = fields.filter(f => f.type === "password");150+  return passwords.length === 1 && !fields.some(f => f.type === "username" || f.type === "email");151+}152+153+// Single entry point used by every gate in index.ts. Covers all four patterns:154+// traditional login, signup, email-first step, and password-only step.155+export function isAnyCredentialForm(fields: DetectedField[]): boolean {156+  return isLoginForm(fields) || isSignupForm(fields) || isEmailFirstForm(fields) || isPasswordStepForm(fields);157+}158+107159 export function isLoginForm(fields: DetectedField[]): boolean {108160   const passwords = fields.filter((f) => f.type === "password");109161   // Two or more password fields means signup/change-password, not login

view file

src/content/index.ts+18 −18
@@ -3,6 +3,8 @@33   detectLoginFields,44   getDeepActiveInput,55   getPageDomain,6+  isAnyCredentialForm,7+  isEmailFirstForm,68   isLoginForm,79   isSignupForm,810   isTOTPField,@@ -302,12 +304,12 @@302304 async function handleAutofill(303305   _field: HTMLInputElement,304306   usernameEl: HTMLInputElement | null,305-  passwordEl: HTMLInputElement,307+  passwordEl: HTMLInputElement | null,306308   entry: VaultEntry307309 ): Promise<HTMLInputElement | null> {308310   if (!entry.username && !entry.password) return null;309311   const pageDomain = getPageDomain();310-  const form = passwordEl.closest("form") as HTMLFormElement | null;312+  const form = (passwordEl ?? usernameEl)?.closest("form") as HTMLFormElement | null ?? null;311313   fillFields(usernameEl, passwordEl, entry.username ?? "", entry.password ?? "", form, pageDomain);312314   let filledTOTPField: HTMLInputElement | null = null;313315 @@ -337,7 +339,7 @@337339 338340   if (entry.totp) {339341     const { code } = generateTOTP(entry.totp);340-    const totpField = findSameLoginTOTPField(passwordEl);342+    const totpField = passwordEl ? findSameLoginTOTPField(passwordEl) : null;341343     if (fillTOTPCode(totpField, code)) {342344       filledTOTPField = totpField;343345     }@@ -420,7 +422,7 @@420422 async function openAutofillSelector(421423   anchorField: HTMLInputElement,422424   usernameEl: HTMLInputElement | null,423-  passwordEl: HTMLInputElement,425+  passwordEl: HTMLInputElement | null,424426   domainEntries: SelectorEntry[],425427   searchableEntries: SelectorEntry[],426428   domain: string@@ -438,7 +440,7 @@438440       if (idResp?.ok && Array.isArray(idResp.data)) savedIdentities = idResp.data;439441     } catch { /* silent fallback */ }440442 441-    const form = passwordEl.closest("form") ?? document.body;443+    const form = (passwordEl ?? usernameEl)?.closest("form") ?? document.body;442444     const allPwInputs = Array.from(form.querySelectorAll<HTMLInputElement>("input[type='password']"));443445     confirmEl = allPwInputs.find(444446       (el) => el !== passwordEl && /confirm|repeat|verify/i.test(el.name + el.id + el.placeholder)@@ -482,7 +484,7 @@482484       isSignup,483485       savedIdentities,484486       onFillFields: (username, password) => {485-        const form = passwordEl.closest("form") as HTMLFormElement | null;487+        const form = (passwordEl ?? usernameEl)?.closest("form") as HTMLFormElement | null ?? null;486488         fillFields(usernameEl, passwordEl, username, password, form, getPageDomain());487489         if (password && confirmEl) setNativeValue(confirmEl, password);488490         recordFillSuccess(anchorField, usernameEl, passwordEl);@@ -629,9 +631,7 @@629631     }630632 631633     let fields = detectLoginFields();632-    const loginForm = isLoginForm(fields);633-    const signupForm = isSignupForm(fields);634-    if (!loginForm && !signupForm) {634+    if (!isAnyCredentialForm(fields)) {635635       return;636636     }637637 @@ -667,8 +667,9 @@667667     const userField = fields.find((f) => f.type === "username" || f.type === "email");668668     if (userField) usernameEl = userField.el;669669     const pwField = fields.find((f) => f.type === "password");670-    if (!pwField) return;671-    const passwordEl = pwField.el;670+    // For email-first flows there is no password field on this step — that is expected.671+    if (!pwField && !isEmailFirstForm(fields)) return;672+    const passwordEl = pwField?.el ?? null;672673 673674     await openAutofillSelector(field, usernameEl, passwordEl, entries, allEntries, domain);674675   } finally {@@ -693,9 +694,9 @@693694 ): void {694695   if (processedInputs.has(el)) return;695696   if (!resolveAutofillFieldType(el, detectedType)) return;696-  // Only inject icon when a real login/signup form is present on the page697+  // Only inject icon when a credential form is present on the page697698   const fields = contextFields ?? detectLoginFields();698-  if (!isLoginForm(fields) && !isSignupForm(fields)) return;699+  if (!isAnyCredentialForm(fields)) return;699700 700701   const cleanup = injectFieldIcon(el, () => {701702     currentDismiss?.();@@ -712,8 +713,7 @@712713 713714 const debouncedReattach = debounce(() => {714715   const allFields = detectLoginFields();715-  // Don't inject anything if there's no real login/signup form on this page716-  if (!isLoginForm(allFields) && !isSignupForm(allFields)) return;716+  if (!isAnyCredentialForm(allFields)) return;717717   const newFields = allFields.filter(718718     (f) => AUTOFILL_FIELD_TYPES.has(f.type) && !processedInputs.has(f.el)719719   );@@ -737,7 +737,7 @@737737 738738 function initialScan(aggressionLevel = 0, isFinalPass = false) {739739   const allFields = detectLoginFields();740-  const hasKnownForm = isLoginForm(allFields) || isSignupForm(allFields);740+  const hasKnownForm = isAnyCredentialForm(allFields);741741 742742   if (aggressionLevel === 0 && !hasKnownForm) return;743743 @@ -779,7 +779,7 @@779779 780780 function hasLoginOrSignupForm(): boolean {781781   const fields = detectLoginFields();782-  return isLoginForm(fields) || isSignupForm(fields);782+  return isAnyCredentialForm(fields);783783 }784784 785785 function injectTOTPFieldIcon(field: HTMLInputElement): void {@@ -1004,7 +1004,7 @@10041004 10051005     const activeInput = getDeepActiveInput();10061006     const fields = detectLoginFields();1007-    const hasForm = isLoginForm(fields) || isSignupForm(fields);1007+    const hasForm = isAnyCredentialForm(fields);10081008 10091009     if (hasForm) {10101010       // Prefer the currently focused field if it's a login field, otherwise

view file