ccffed5200ab

fix: Add identity search, MRU sort, and username-aware fallback (v3.6.147)

time
2026-06-03 00:45 UTC · 4 months ago
machine
koh2
agent
GPT 5.4 Mini
parent
70b8eecdabd3
8 files manifest.firefox.json+1 −1manifest.json+1 −1package-lock.json+2 −2package.json+1 −1src/content/identity-helper.test.ts+53 −0src/content/identity-helper.ts+58 −0src/content/index.ts+12 −2src/content/inject.ts+191 −17
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.146",5+  "version": "3.6.147",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.146",4+  "version": "3.6.147",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.146",3+  "version": "3.6.147",44   "lockfileVersion": 3,55   "requires": true,66   "packages": {77     "": {88       "name": "peach",9-      "version": "3.6.146",9+      "version": "3.6.147",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.146",3+  "version": "3.6.147",44   "description": "Zero-knowledge password manager",55   "type": "module",66   "scripts": {

view file

src/content/identity-helper.test.ts+53 −0
@@ -0,0 +1,53 @@1+import { describe, expect, it } from "vitest";2+3+import {4+  filterSavedIdentities,5+  identityFilterLabel,6+  resolveCustomIdentityKind,7+  sortSavedIdentities,8+} from "./identity-helper";9+10+const identities = [11+  { value: "[email protected]", type: "email" as const, lastUsed: 10, useCount: 2 },12+  { value: "beta", type: "username" as const, lastUsed: 30, useCount: 1 },13+  { value: "[email protected]", type: "email" as const, lastUsed: 30, useCount: 4 },14+  { value: "delta", type: "username" as const, lastUsed: 5, useCount: 9 },15+];16+17+describe("identity-helper", () => {18+  it("sorts identities by most recently used first", () => {19+    expect(sortSavedIdentities(identities).map((identity) => identity.value)).toEqual([20+      "[email protected]",21+      "beta",22+      "[email protected]",23+      "delta",24+    ]);25+  });26+27+  it("filters identities by type and search text", () => {28+    expect(filterSavedIdentities(identities, "alpha", "both").map((identity) => identity.value)).toEqual([29+      "[email protected]",30+    ]);31+    expect(filterSavedIdentities(identities, "", "email").map((identity) => identity.value)).toEqual([32+      "[email protected]",33+      "[email protected]",34+    ]);35+    expect(filterSavedIdentities(identities, "", "username").map((identity) => identity.value)).toEqual([36+      "beta",37+      "delta",38+    ]);39+  });40+41+  it("treats no-match custom identity as username unless the field or text says email", () => {42+    expect(resolveCustomIdentityKind("june", "both", "username")).toBe("username");43+    expect(resolveCustomIdentityKind("[email protected]", "both", "username")).toBe("email");44+    expect(resolveCustomIdentityKind("june", "email", "username")).toBe("email");45+    expect(resolveCustomIdentityKind("june", "username", "email")).toBe("username");46+  });47+48+  it("labels the filter modes clearly", () => {49+    expect(identityFilterLabel("both")).toBe("Both");50+    expect(identityFilterLabel("email")).toBe("Emails");51+    expect(identityFilterLabel("username")).toBe("Usernames");52+  });53+});

view file

src/content/identity-helper.ts+58 −0
@@ -0,0 +1,58 @@1+import type { SavedIdentity } from "../shared/types";2+3+export type IdentityFilterMode = "both" | "email" | "username";4+export type IdentityKind = "email" | "username";5+6+function compareSavedIdentity(a: SavedIdentity, b: SavedIdentity): number {7+  return (8+    b.lastUsed - a.lastUsed ||9+    b.useCount - a.useCount ||10+    a.value.localeCompare(b.value) ||11+    a.type.localeCompare(b.type)12+  );13+}14+15+function matchesQuery(identity: SavedIdentity, query: string): boolean {16+  const q = query.trim().toLowerCase();17+  if (!q) return true;18+  return [identity.value, identity.exampleDomain ?? "", identity.type]19+    .join(" ")20+    .toLowerCase()21+    .includes(q);22+}23+24+export function sortSavedIdentities(identities: SavedIdentity[]): SavedIdentity[] {25+  return [...identities].sort(compareSavedIdentity);26+}27+28+export function filterSavedIdentities(29+  identities: SavedIdentity[],30+  query: string,31+  mode: IdentityFilterMode,32+): SavedIdentity[] {33+  const typeFiltered = identities.filter((identity) => {34+    if (mode === "email") return identity.type === "email";35+    if (mode === "username") return identity.type === "username";36+    return true;37+  });38+39+  return sortSavedIdentities(typeFiltered).filter((identity) => matchesQuery(identity, query));40+}41+42+export function resolveCustomIdentityKind(43+  query: string,44+  mode: IdentityFilterMode,45+  fallback: IdentityKind,46+): IdentityKind {47+  if (mode === "email") return "email";48+  if (mode === "username") return "username";49+  const trimmed = query.trim();50+  if (!trimmed) return fallback;51+  return trimmed.includes("@") ? "email" : fallback;52+}53+54+export function identityFilterLabel(mode: IdentityFilterMode): string {55+  if (mode === "email") return "Emails";56+  if (mode === "username") return "Usernames";57+  return "Both";58+}

view file

src/content/index.ts+12 −2
@@ -432,7 +432,8 @@432432   passwordEl: HTMLInputElement | null,433433   domainEntries: SelectorEntry[],434434   searchableEntries: SelectorEntry[],435-  domain: string435+  domain: string,436+  identityKind: "email" | "username"436437 ): Promise<void> {437438   // No saved entries for this site → signup/new-account mode.438439   // Fetch vault identities for the identity-first surface and locate@@ -489,6 +490,7 @@489490       domain,490491       searchableEntries,491492       isSignup,493+      identityKind,492494       savedIdentities,493495       onFillFields: (username, password) => {494496         const form = (passwordEl ?? usernameEl)?.closest("form") as HTMLFormElement | null ?? null;@@ -692,7 +694,15 @@692694     const usernameEl = targets.usernameEl;693695     const passwordEl = targets.passwordEl;694696 695-    await openAutofillSelector(field, usernameEl, passwordEl, entries, allEntries, domain);697+    await openAutofillSelector(698+      field,699+      usernameEl,700+      passwordEl,701+      entries,702+      allEntries,703+      domain,704+      autofillFieldType === "email" ? "email" : "username",705+    );696706   } finally {697707     pendingInteractions.delete(field);698708   }

view file

src/content/inject.ts+191 −17
@@ -12,6 +12,13 @@1212 import { ACCENT_COLORS, type PasswordPolicy, type SavedIdentity } from "../shared/types";1313 import { generatePassword } from "../shared/generate";1414 import { detectPasswordPolicy } from "./policy";15+import {16+  filterSavedIdentities,17+  identityFilterLabel,18+  resolveCustomIdentityKind,19+  type IdentityFilterMode,20+  type IdentityKind,21+} from "./identity-helper";1522 1623 let accentHex = "#E8956D";1724 let popupTheme: "dark" | "light" = "dark";@@ -49,6 +56,11 @@4956   /** True when shown on a signup form — changes header label and entry-click behaviour */5057   isSignup?: boolean;5158   /**59+   * Identity kind the current signup field is expecting. Used to avoid60+   * assuming email when the user is actually filling a username.61+   */62+  identityKind?: IdentityKind;63+  /**5264    * Derived identity list (emails then usernames) for signup mode (State 10).5365    * When present, renderIdentityHelper() is used instead of renderList().5466    */@@ -1002,6 +1014,50 @@10021014     color: var(--peach-text); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;10031015   }10041016   .peach-identity-meta { font-size: 10px; color: var(--peach-text-muted); flex-shrink: 0; }1017+  .identity-search-wrap {1018+    display: flex; flex-direction: column; gap: 6px;1019+  }1020+  .identity-mode-toggle {1021+    display: inline-flex; gap: 4px; flex-wrap: wrap;1022+  }1023+  .identity-mode-btn {1024+    border: 1px solid var(--peach-input-border);1025+    background: var(--peach-input-bg);1026+    color: var(--peach-text-dim);1027+    border-radius: 999px;1028+    padding: 4px 9px;1029+    cursor: pointer;1030+    font-family: "IBM Plex Mono", monospace;1031+    font-size: 9px;1032+    letter-spacing: 0.03em;1033+    transition: background 100ms ease, border-color 100ms ease, color 100ms ease, transform 80ms ease;1034+  }1035+  .identity-mode-btn:hover {1036+    background: color-mix(in srgb, var(--peach-text) 6%, var(--peach-input-bg));1037+    color: var(--peach-text);1038+  }1039+  .identity-mode-btn.is-active {1040+    color: var(--peach-accent);1041+    border-color: color-mix(in srgb, var(--peach-accent) 30%, var(--peach-input-border));1042+    background: color-mix(in srgb, var(--peach-accent) 10%, var(--peach-input-bg));1043+  }1044+  .identity-mode-btn:active { transform: translateY(1px); }1045+  .identity-custom-row {1046+    display: flex; flex-direction: column; gap: 2px;1047+    padding: 9px 12px; cursor: pointer; border-radius: 8px;1048+    transition: background 100ms ease;1049+  }1050+  .identity-custom-row:hover { background: color-mix(in srgb, var(--peach-text) 6%, transparent); }1051+  .identity-custom-row-title {1052+    color: var(--peach-text); font-size: 12px; font-family: Inter, sans-serif;1053+  }1054+  .identity-custom-row-sub {1055+    color: var(--peach-text-muted); font-size: 10px; font-family: "IBM Plex Mono", monospace;1056+  }1057+  .identity-empty {1058+    padding: 10px 12px 2px; color: var(--peach-text-dim);1059+    font-size: 10px; font-family: "IBM Plex Mono", monospace;1060+  }10051061   .identity-alt-row {10061062     display: flex; align-items: center; padding: 8px 12px; cursor: pointer;10071063     border-radius: 8px; transition: background 100ms ease;@@ -1307,8 +1363,9 @@13071363 13081364   let query = "";13091365   let focusedIndex = -1;1310-  let currentMode: "list" | "create" | "success" | "silver-platter" = "list";1366+  let currentMode: "list" | "create" | "success" | "silver-platter" | "identity-helper" = "list";13111367   let searchInput: HTMLInputElement | null = null;1368+  let identityMode: IdentityFilterMode = "both";13121369   // Tracks the silver-platter entry the user pivoted away from, so the13131370   // search/list header can offer a one-click way back to it. null when the13141371   // popup never showed a single-match platter (e.g. multi-match list view).@@ -1394,22 +1451,112 @@13941451   }13951452 13961453   function renderIdentityHelper() {1454+    currentMode = "identity-helper";13971455     container.innerHTML = "";1398-    const identities = options.savedIdentities ?? [];1456+    focusedIndex = -1;1457+    searchInput = null;13991458 1459+    const fallbackKind: IdentityKind = options.identityKind ?? "username";1460+    const identities = filterSavedIdentities(options.savedIdentities ?? [], query, identityMode);1461+    const typedQuery = query.trim();1462+    const customKind = resolveCustomIdentityKind(typedQuery, identityMode, fallbackKind);1463+1464+    function applyIdentity(identity: string) {1465+      options.onFillFields?.(identity, "");1466+      renderGenerator(identity);1467+    }1468+1469+    function applyCustomIdentity(identity: string) {1470+      const trimmed = identity.trim();1471+      if (!trimmed) return;1472+      applyIdentity(trimmed);1473+    }1474+14001475     // Header: "NEW ACCOUNT" + domain name14011476     const hdr = container.appendChild(document.createElement("div"));14021477     hdr.className = "domain-header";1403-    const eyebrow = hdr.appendChild(document.createElement("div"));1478+    const headText = hdr.appendChild(document.createElement("div"));1479+    headText.className = "domain-header-text";1480+    const eyebrow = headText.appendChild(document.createElement("div"));14041481     eyebrow.className = "domain-eyebrow";14051482     eyebrow.textContent = "NEW ACCOUNT";1406-    const domName = hdr.appendChild(document.createElement("div"));1483+    const domName = headText.appendChild(document.createElement("div"));14071484     domName.className = "domain-name";14081485     domName.textContent = options.domain ?? location.hostname.replace(/^www\./, "");14091486 1487+    const actions = hdr.appendChild(document.createElement("div"));1488+    actions.className = "identity-mode-toggle";1489+    for (const mode of ["both", "email", "username"] as const) {1490+      const btn = actions.appendChild(document.createElement("button"));1491+      btn.type = "button";1492+      btn.className = `identity-mode-btn${identityMode === mode ? " is-active" : ""}`;1493+      btn.textContent = identityFilterLabel(mode);1494+      btn.setAttribute("aria-pressed", String(identityMode === mode));1495+      btn.addEventListener("click", () => {1496+        identityMode = mode;1497+        renderIdentityHelper();1498+      });1499+    }1500+1501+    const searchWrap = container.appendChild(document.createElement("div"));1502+    searchWrap.className = "search-wrap identity-search-wrap";1503+    const search = searchWrap.appendChild(document.createElement("input"));1504+    search.className = "search";1505+    search.placeholder = "search identities…";1506+    search.value = query;1507+    search.addEventListener("input", () => {1508+      query = search.value;1509+      renderIdentityHelper();1510+      searchInput?.focus();1511+      searchInput?.setSelectionRange(query.length, query.length);1512+    });1513+    search.addEventListener("keydown", (e) => {1514+      if (e.key === "Escape") {1515+        e.preventDefault();1516+        dismiss(true);1517+        return;1518+      }1519+      if (e.key !== "Enter") return;1520+      const exact = identities.find((id) => id.value.toLowerCase() === typedQuery.toLowerCase());1521+      if (exact) {1522+        e.preventDefault();1523+        applyIdentity(exact.value);1524+        return;1525+      }1526+      if (!typedQuery || identities.length > 0) return;1527+      e.preventDefault();1528+      applyCustomIdentity(typedQuery);1529+    });1530+    const searchTools = searchWrap.appendChild(document.createElement("div"));1531+    searchTools.className = "identity-mode-toggle";1532+    const sortHint = searchTools.appendChild(document.createElement("div"));1533+    sortHint.className = "identity-empty";1534+    sortHint.style.padding = "0";1535+    sortHint.textContent = "sorted by recent use";1536+    searchInput = search;1537+    const customLabel = identityMode === "both"1538+      ? customKind1539+      : identityMode;1540+14101541     // Identity rows14111542     const list = container.appendChild(document.createElement("div"));14121543     list.className = "identity-list";1544+    if (typedQuery && identities.length === 0) {1545+      const empty = list.appendChild(document.createElement("div"));1546+      empty.className = "identity-empty";1547+      empty.textContent = identityMode === "both"1548+        ? `No match for “${typedQuery}”.`1549+        : `No ${identityFilterLabel(identityMode).toLowerCase()} match for “${typedQuery}”.`;1550+      const custom = list.appendChild(document.createElement("div"));1551+      custom.className = "identity-custom-row";1552+      const customTitle = custom.appendChild(document.createElement("div"));1553+      customTitle.className = "identity-custom-row-title";1554+      customTitle.textContent = `Use “${typedQuery}” as a new ${customLabel}`;1555+      const customSub = custom.appendChild(document.createElement("div"));1556+      customSub.className = "identity-custom-row-sub";1557+      customSub.textContent = "Press Enter or click to continue.";1558+      custom.addEventListener("click", () => applyCustomIdentity(typedQuery));1559+    }14131560     for (const id of identities) {14141561       const row = list.appendChild(document.createElement("div"));14151562       row.className = "peach-identity-row";@@ -1421,8 +1568,7 @@14211568       meta.className = "peach-identity-meta";14221569       meta.textContent = `used ${id.useCount}×`;14231570       row.addEventListener("click", () => {1424-        options.onFillFields?.(id.value, "");1425-        renderGenerator(id.value);1571+        applyIdentity(id.value);14261572       });14271573     }14281574 @@ -1431,38 +1577,39 @@14311577       container.appendChild(Object.assign(document.createElement("div"), { className: "divider" }));14321578     }14331579 1434-    // "Use a different email…" expandable row1580+    // "Use a different email/username…" expandable row14351581     const altRow = container.appendChild(document.createElement("div"));14361582     altRow.className = "identity-alt-row";1437-    altRow.textContent = "+ Use a different email…";1583+    altRow.textContent = `+ Use a different ${customLabel}…`;14381584 14391585     const altInputWrap = container.appendChild(document.createElement("div"));14401586     altInputWrap.className = "identity-alt-input-wrap";14411587     const altInput = altInputWrap.appendChild(document.createElement("input"));14421588     altInput.className = "identity-alt-input";1443-    altInput.type = "email";1444-    altInput.placeholder = "[email protected]";1589+    altInput.type = "text";1590+    altInput.placeholder = customLabel === "email" ? "[email protected]" : "your username";14451591     altInput.setAttribute("autocomplete", "off");14461592     const altConfirm = altInputWrap.appendChild(document.createElement("button"));14471593     altConfirm.className = "identity-alt-confirm";14481594     altConfirm.type = "button";14491595     altConfirm.textContent = "✓";14501596 1451-    function fillCustomEmail() {1597+    function fillCustomIdentity() {14521598       const v = altInput.value.trim();14531599       if (!v) return;1454-      options.onFillFields?.(v, "");1455-      renderGenerator(v);1600+      applyCustomIdentity(v);14561601     }14571602 14581603     altRow.addEventListener("click", () => {14591604       altRow.style.display = "none";14601605       altInputWrap.classList.add("open");1606+      altInput.value = typedQuery || altInput.value;14611607       altInput.focus();1608+      altInput.setSelectionRange(altInput.value.length, altInput.value.length);14621609     });1463-    altConfirm.addEventListener("click", fillCustomEmail);1610+    altConfirm.addEventListener("click", fillCustomIdentity);14641611     altInput.addEventListener("keydown", (e) => {1465-      if (e.key === "Enter") { e.preventDefault(); fillCustomEmail(); }1612+      if (e.key === "Enter") { e.preventDefault(); fillCustomIdentity(); }14661613     });14671614 14681615     // Escape hatch: user already has a vault entry for this site but it@@ -1480,6 +1627,11 @@14801627         if (e.key === "Enter" || e.key === " ") { e.preventDefault(); renderList(); }14811628       });14821629     }1630+1631+    requestAnimationFrame(() => {1632+      search.focus();1633+      search.setSelectionRange(query.length, query.length);1634+    });14831635   }14841636 14851637   function renderGenerator(filledEmail: string) {@@ -2650,13 +2802,14 @@26502802       if (e.key === "Escape") { e.preventDefault(); dismiss(true); }26512803       return;26522804     }2653-    if (currentMode === "create" || currentMode === "success" || currentMode === "silver-platter") {2805+    if (currentMode === "create" || currentMode === "success" || currentMode === "silver-platter" || currentMode === "identity-helper") {26542806       if (e.key === "Escape") { e.preventDefault(); dismiss(true); }26552807       return;26562808     }26572809     const allEntryEls = container.querySelectorAll(".entry:not(.empty-state)");26582810     const count = allEntryEls.length;2659-    if (document.activeElement === searchInput) {2811+    const activeElement = shadow.activeElement ?? document.activeElement;2812+    if (activeElement === searchInput) {26602813       if (e.key === "Escape" || e.key === "Tab") { e.preventDefault(); dismiss(true); return; }26612814       if (count === 0) return;26622815       if (!["ArrowDown", "ArrowUp", "Enter"].includes(e.key)) return;@@ -2699,6 +2852,27 @@26992852   };27002853   document.addEventListener("keydown", silverPlatterTypeHandler, true);27012854   cleanupHooks.push(() => document.removeEventListener("keydown", silverPlatterTypeHandler, true));2855+2856+  const identityHelperTypeHandler = (e: KeyboardEvent) => {2857+    if (currentMode !== "identity-helper") return;2858+    if (e.ctrlKey || e.metaKey || e.altKey) return;2859+    const active = (shadow.activeElement ?? document.activeElement) as Element | null;2860+    if (active === searchInput) return;2861+    if (2862+      active instanceof HTMLInputElement ||2863+      active instanceof HTMLTextAreaElement ||2864+      (active instanceof HTMLElement && active.isContentEditable)2865+    ) {2866+      return;2867+    }2868+    if (e.key.length !== 1) return;2869+    e.preventDefault();2870+    e.stopImmediatePropagation();2871+    query = e.key;2872+    renderIdentityHelper();2873+  };2874+  document.addEventListener("keydown", identityHelperTypeHandler, true);2875+  cleanupHooks.push(() => document.removeEventListener("keydown", identityHelperTypeHandler, true));27022876 27032877   (modalAncestor ?? document.body).appendChild(host);27042878 

view file