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
↓ peach-browser-ccffed52.tar.gzatlas at this save
files changed8 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 −17manifest.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": {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",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",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": {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+});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+}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 }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