4b42e5fd90d4

State 10 renderIdentityHelper() in inject.ts, openSignupSelector fetches GET_IDENTITIES and wires email→generator→save flow

time
2026-04-27 13:33 UTC · 6 months ago
machine
koh2
parent
5c84f57c81c7
2 files src/content/index.ts+25 −3src/content/inject.ts+307 −4
src/content/index.ts+25 −3
@@ -22,7 +22,7 @@2222 import { detectPasswordPolicy } from "./policy";2323 import type { FieldPrediction } from "../shared/ml";2424 import { generateTOTP } from "../crypto/totp";25-import type { VaultEntry } from "../shared/types";25+import type { SavedIdentity, VaultEntry } from "../shared/types";2626 2727 // WeakMap (not WeakSet) — we keep the per-input cleanup callback as the value2828 // so we can tear down listeners if we ever need to re-process the same field.@@ -458,19 +458,41 @@458458   allEntries: SelectorEntry[],459459   domain: string460460 ): Promise<void> {461+  // Fetch derived identity list (emails then usernames from vault).462+  // Falls back to empty list silently so the popup still opens when the463+  // vault is temporarily unreachable or returns no login entries.464+  let savedIdentities: SavedIdentity[] = [];465+  try {466+    const idResp = await chrome.runtime.sendMessage({ type: "GET_IDENTITIES" }).catch(() => null) as467+      { ok: boolean; data?: SavedIdentity[] } | null;468+    if (idResp?.ok && Array.isArray(idResp.data)) savedIdentities = idResp.data;469+  } catch { /* silent fallback */ }470+471+  // Find the confirm-password field (second password input, or one labelled472+  // confirm/repeat/verify) so "Copy & fill" from the generator can fill it too.473+  const form = passwordEl?.closest("form") ?? document.body;474+  const allPwInputs = Array.from(form.querySelectorAll<HTMLInputElement>("input[type='password']"));475+  const confirmEl = allPwInputs.find(476+    (el) => el !== passwordEl && /confirm|repeat|verify/i.test(el.name + el.id + el.placeholder)477+  ) ?? (allPwInputs.length >= 2 ? allPwInputs.find((el) => el !== passwordEl) ?? null : null);478+461479   currentDismiss?.();462480   currentAnchorField = usernameEl;463481   currentDismiss = showAutofillSelector(464482     usernameEl,465483     domainEntries,466-    () => {}, // identity-click handled via onFillFields484+    () => {},467485     {468486       domain,469487       searchableEntries: allEntries,470488       isSignup: true,489+      savedIdentities,471490       onFillFields: (username, password) => {472491         if (username) setNativeValue(usernameEl, username);473-        if (password && passwordEl) setNativeValue(passwordEl, password);492+        if (password && passwordEl) {493+          setNativeValue(passwordEl, password);494+          if (confirmEl) setNativeValue(confirmEl, password);495+        }474496         recordFillSuccess(usernameEl, passwordEl);475497       },476498       onDismiss: () => { lastDismissedField = usernameEl; startDismissLock(); },

view file

src/content/inject.ts+307 −4
@@ -9,7 +9,7 @@99  * to fill come back across the boundary.1010  */1111 12-import { ACCENT_COLORS, type PasswordPolicy } from "../shared/types";12+import { ACCENT_COLORS, type PasswordPolicy, type SavedIdentity } from "../shared/types";1313 import { generateSmartPassword } from "../shared/generate";1414 import { generateTOTP } from "../crypto/totp";1515 import { detectPasswordPolicy } from "./policy";@@ -47,6 +47,11 @@4747   /** True when shown on a signup form — changes header label and entry-click behaviour */4848   isSignup?: boolean;4949   /**50+   * Derived identity list (emails then usernames) for signup mode (State 10).51+   * When present, renderIdentityHelper() is used instead of renderList().52+   */53+  savedIdentities?: SavedIdentity[];54+  /**5055    * Called after the user picks a saved identity (signup mode only) or5156    * after successfully saving a new entry on a signup form.5257    * Lets the caller fill the visible username + password fields.@@ -700,6 +705,113 @@700705     50%      { box-shadow: 0 2px 28px var(--peach-glow, transparent),701706                             0 0 48px var(--peach-glow, transparent); }702707   }708+709+  /* ── State 10: identity helper ─────────────────────────────── */710+  .identity-list { max-height: 192px; overflow-y: auto; padding: 4px 0; }711+  .peach-identity-row {712+    display: flex; align-items: center; gap: 10px;713+    padding: 9px 12px; cursor: pointer; border-radius: 8px;714+    transition: background 100ms ease;715+  }716+  .peach-identity-row:hover { background: color-mix(in srgb, var(--peach-text) 6%, transparent); }717+  .peach-identity-value {718+    font-family: "IBM Plex Mono", monospace; font-size: 13px;719+    color: var(--peach-text); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;720+  }721+  .peach-identity-meta { font-size: 10px; color: var(--peach-text-muted); flex-shrink: 0; }722+  .identity-alt-row {723+    display: flex; align-items: center; padding: 8px 12px; cursor: pointer;724+    border-radius: 8px; transition: background 100ms ease;725+    color: var(--peach-text-dim); font-size: 12px; font-family: Inter, sans-serif;726+  }727+  .identity-alt-row:hover { background: color-mix(in srgb, var(--peach-text) 6%, transparent); }728+  .identity-alt-input-wrap { display: none; padding: 0 12px 8px; gap: 6px; align-items: center; }729+  .identity-alt-input-wrap.open { display: flex; }730+  .identity-alt-input {731+    flex: 1; height: 32px; padding: 0 10px; border-radius: 7px;732+    border: 1px solid var(--peach-input-border); background: var(--peach-input-bg);733+    color: var(--peach-text); font-family: "IBM Plex Mono", monospace; font-size: 12px;734+    outline: none; transition: border-color 120ms;735+  }736+  .identity-alt-input:focus {737+    border-color: color-mix(in srgb, var(--peach-accent) 40%, transparent);738+    box-shadow: 0 0 0 2px color-mix(in srgb, var(--peach-accent) 10%, transparent);739+  }740+  .identity-alt-confirm {741+    height: 32px; padding: 0 12px; border-radius: 7px; border: none;742+    background: var(--peach-accent); color: #fff; font-size: 12px;743+    cursor: pointer; font-family: Inter, sans-serif; flex-shrink: 0;744+  }745+  /* ── Generator view (after identity picked) ────────────────── */746+  .gen-view { padding: 12px; display: flex; flex-direction: column; gap: 8px; }747+  .gen-confirmed {748+    display: flex; align-items: center; gap: 8px; padding: 7px 10px;749+    background: color-mix(in srgb, var(--peach-accent) 10%, transparent);750+    border-radius: 8px;751+  }752+  .gen-confirmed-check { color: var(--peach-accent); font-size: 13px; font-weight: 600; }753+  .gen-confirmed-val {754+    font-family: "IBM Plex Mono", monospace; font-size: 12px; color: var(--peach-text);755+    overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1;756+  }757+  .gen-label {758+    font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase;759+    color: var(--peach-text-muted); font-family: "IBM Plex Mono", monospace;760+  }761+  .gen-pw-row { display: flex; gap: 6px; align-items: center; }762+  .gen-pw-val {763+    flex: 1; padding: 8px 10px; border-radius: 7px;764+    background: var(--peach-input-bg); border: 1px solid var(--peach-input-border);765+    font-family: "IBM Plex Mono", monospace; font-size: 12px; color: var(--peach-text);766+    white-space: nowrap; overflow: hidden; text-overflow: ellipsis;767+  }768+  .gen-regen-btn {769+    height: 34px; padding: 0 10px; border-radius: 7px; border: none;770+    background: color-mix(in srgb, var(--peach-text) 8%, transparent);771+    color: var(--peach-text-dim); font-size: 11px; cursor: pointer;772+    font-family: "IBM Plex Mono", monospace; transition: background 100ms ease;773+  }774+  .gen-regen-btn:hover { background: color-mix(in srgb, var(--peach-text) 14%, transparent); }775+  .gen-fill-btn {776+    padding: 10px; border-radius: 8px; border: none;777+    background: var(--peach-accent); color: #fff; font-size: 13px; font-weight: 500;778+    cursor: pointer; font-family: Inter, sans-serif; letter-spacing: 0.01em;779+    transition: filter 120ms ease, transform 80ms ease;780+  }781+  .gen-fill-btn:hover { filter: brightness(1.08); transform: translateY(-1px); }782+  .gen-fill-btn:active { transform: translateY(0); }783+  /* ── Save offer (after password filled) ───────────────────── */784+  .save-offer { padding: 12px; display: flex; flex-direction: column; gap: 8px; }785+  .save-offer-title {786+    font-size: 12px; font-weight: 600; color: var(--peach-text); font-family: Inter, sans-serif;787+  }788+  .save-offer-row {789+    display: flex; align-items: center; gap: 8px; padding: 6px 8px;790+    border-radius: 7px; background: var(--peach-input-bg);791+    border: 1px solid var(--peach-input-border);792+  }793+  .save-offer-lbl {794+    font-size: 10px; color: var(--peach-text-muted); width: 54px; flex-shrink: 0;795+    font-family: "IBM Plex Mono", monospace;796+  }797+  .save-offer-val {798+    font-family: "IBM Plex Mono", monospace; font-size: 12px; color: var(--peach-text);799+    overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1;800+  }801+  .save-offer-btns { display: flex; gap: 8px; margin-top: 4px; }802+  .save-offer-save {803+    flex: 1; padding: 9px; border-radius: 8px; border: none;804+    background: var(--peach-accent); color: #fff; font-size: 13px;805+    cursor: pointer; font-family: Inter, sans-serif; transition: filter 120ms ease;806+  }807+  .save-offer-save:hover { filter: brightness(1.08); }808+  .save-offer-skip {809+    flex: 1; padding: 9px; border-radius: 8px; border: none;810+    background: color-mix(in srgb, var(--peach-text) 8%, transparent);811+    color: var(--peach-text-dim); font-size: 13px; cursor: pointer;812+    font-family: Inter, sans-serif; transition: background 100ms ease;813+  }814+  .save-offer-skip:hover { background: color-mix(in srgb, var(--peach-text) 14%, transparent); }703815 `;704816 705817 export function showAutofillSelector(@@ -786,6 +898,195 @@786898     return icon;787899   }788900 901+  function renderIdentityHelper() {902+    container.innerHTML = "";903+    const identities = options.savedIdentities ?? [];904+905+    // Header: "NEW ACCOUNT" + domain name906+    const hdr = container.appendChild(document.createElement("div"));907+    hdr.className = "domain-header";908+    const eyebrow = hdr.appendChild(document.createElement("div"));909+    eyebrow.className = "domain-eyebrow";910+    eyebrow.textContent = "NEW ACCOUNT";911+    const domName = hdr.appendChild(document.createElement("div"));912+    domName.className = "domain-name";913+    domName.textContent = options.domain ?? location.hostname.replace(/^www\./, "");914+915+    // Identity rows916+    const list = container.appendChild(document.createElement("div"));917+    list.className = "identity-list";918+    for (const id of identities) {919+      const row = list.appendChild(document.createElement("div"));920+      row.className = "peach-identity-row";921+      row.setAttribute("tabindex", "-1");922+      const val = row.appendChild(document.createElement("div"));923+      val.className = "peach-identity-value";924+      val.textContent = id.value;925+      const meta = row.appendChild(document.createElement("div"));926+      meta.className = "peach-identity-meta";927+      meta.textContent = `used ${id.useCount}×`;928+      row.addEventListener("click", () => {929+        options.onFillFields?.(id.value, "");930+        renderGenerator(id.value);931+      });932+    }933+934+    // Divider before "Use a different email…"935+    if (identities.length > 0) {936+      container.appendChild(Object.assign(document.createElement("div"), { className: "divider" }));937+    }938+939+    // "Use a different email…" expandable row940+    const altRow = container.appendChild(document.createElement("div"));941+    altRow.className = "identity-alt-row";942+    altRow.textContent = "+ Use a different email…";943+944+    const altInputWrap = container.appendChild(document.createElement("div"));945+    altInputWrap.className = "identity-alt-input-wrap";946+    const altInput = altInputWrap.appendChild(document.createElement("input"));947+    altInput.className = "identity-alt-input";948+    altInput.type = "email";949+    altInput.placeholder = "[email protected]";950+    altInput.setAttribute("autocomplete", "off");951+    const altConfirm = altInputWrap.appendChild(document.createElement("button"));952+    altConfirm.className = "identity-alt-confirm";953+    altConfirm.type = "button";954+    altConfirm.textContent = "✓";955+956+    function fillCustomEmail() {957+      const v = altInput.value.trim();958+      if (!v) return;959+      options.onFillFields?.(v, "");960+      renderGenerator(v);961+    }962+963+    altRow.addEventListener("click", () => {964+      altRow.style.display = "none";965+      altInputWrap.classList.add("open");966+      altInput.focus();967+    });968+    altConfirm.addEventListener("click", fillCustomEmail);969+    altInput.addEventListener("keydown", (e) => {970+      if (e.key === "Enter") { e.preventDefault(); fillCustomEmail(); }971+    });972+  }973+974+  function renderGenerator(filledEmail: string) {975+    container.innerHTML = "";976+    const sitePolicy = detectPasswordPolicy();977+    let pw = generatePasswordForSite(sitePolicy);978+979+    const wrap = container.appendChild(document.createElement("div"));980+    wrap.className = "gen-view";981+982+    // Header: domain + NEW ACCOUNT983+    const hdrRow = wrap.appendChild(document.createElement("div"));984+    hdrRow.className = "domain-header";985+    const eyebrow2 = hdrRow.appendChild(document.createElement("div"));986+    eyebrow2.className = "domain-eyebrow";987+    eyebrow2.textContent = "NEW ACCOUNT";988+    const domName2 = hdrRow.appendChild(document.createElement("div"));989+    domName2.className = "domain-name";990+    domName2.textContent = options.domain ?? location.hostname.replace(/^www\./, "");991+992+    // Confirmed email row993+    const conf = wrap.appendChild(document.createElement("div"));994+    conf.className = "gen-confirmed";995+    const confCheck = conf.appendChild(document.createElement("span"));996+    confCheck.className = "gen-confirmed-check";997+    confCheck.textContent = "✓";998+    const confVal = conf.appendChild(document.createElement("div"));999+    confVal.className = "gen-confirmed-val";1000+    confVal.textContent = filledEmail;1001+1002+    // "Generate a password?" label1003+    const lbl = wrap.appendChild(document.createElement("div"));1004+    lbl.className = "gen-label";1005+    lbl.textContent = "Generate a password";1006+1007+    // Password display + regen1008+    const pwRow = wrap.appendChild(document.createElement("div"));1009+    pwRow.className = "gen-pw-row";1010+    const pwVal = pwRow.appendChild(document.createElement("div"));1011+    pwVal.className = "gen-pw-val";1012+    pwVal.textContent = pw;1013+    const regenBtn = pwRow.appendChild(document.createElement("button"));1014+    regenBtn.className = "gen-regen-btn";1015+    regenBtn.type = "button";1016+    regenBtn.textContent = "⟳ regen";1017+    regenBtn.addEventListener("click", () => {1018+      pw = generatePasswordForSite(sitePolicy);1019+      pwVal.textContent = pw;1020+    });1021+1022+    // "Copy & fill" button1023+    const fillBtn = wrap.appendChild(document.createElement("button"));1024+    fillBtn.className = "gen-fill-btn";1025+    fillBtn.type = "button";1026+    fillBtn.textContent = "Copy & fill";1027+    fillBtn.addEventListener("click", () => {1028+      options.onFillFields?.("", pw);1029+      renderSaveOffer(filledEmail, pw);1030+    });1031+  }1032+1033+  function renderSaveOffer(filledEmail: string, filledPassword: string) {1034+    container.innerHTML = "";1035+    const domain = options.domain ?? location.hostname.replace(/^www\./, "");1036+1037+    const wrap = container.appendChild(document.createElement("div"));1038+    wrap.className = "save-offer";1039+1040+    const title = wrap.appendChild(document.createElement("div"));1041+    title.className = "save-offer-title";1042+    title.textContent = "Save this login to Peach?";1043+1044+    function detailRow(label: string, value: string) {1045+      const row = wrap.appendChild(document.createElement("div"));1046+      row.className = "save-offer-row";1047+      const lbl = row.appendChild(document.createElement("div"));1048+      lbl.className = "save-offer-lbl";1049+      lbl.textContent = label;1050+      const val = row.appendChild(document.createElement("div"));1051+      val.className = "save-offer-val";1052+      val.textContent = value;1053+    }1054+1055+    detailRow("site", domain);1056+    detailRow("email", filledEmail);1057+    detailRow("password", "••••••••••••••••");1058+1059+    const btns = wrap.appendChild(document.createElement("div"));1060+    btns.className = "save-offer-btns";1061+1062+    const saveBtn = btns.appendChild(document.createElement("button"));1063+    saveBtn.className = "save-offer-save";1064+    saveBtn.type = "button";1065+    saveBtn.textContent = "Save";1066+    saveBtn.addEventListener("click", async () => {1067+      saveBtn.disabled = true;1068+      saveBtn.textContent = "Saving…";1069+      try {1070+        const res = await chrome.runtime.sendMessage({1071+          type: "ADD_ENTRY",1072+          payload: { title: sTitle, url: sOrigin, username: filledEmail, password: filledPassword, type: "login" },1073+        });1074+        if ((res as { ok?: boolean }).ok) {1075+          showSuccess(sTitle);1076+        } else {1077+          saveBtn.disabled = false;1078+          saveBtn.textContent = "Save";1079+        }1080+      } catch { saveBtn.disabled = false; saveBtn.textContent = "Save"; }1081+    });1082+1083+    const skipBtn = btns.appendChild(document.createElement("button"));1084+    skipBtn.className = "save-offer-skip";1085+    skipBtn.type = "button";1086+    skipBtn.textContent = "Not now";1087+    skipBtn.addEventListener("click", () => { dismiss(true); });1088+  }1089+7891090   function renderList() {7901091     container.innerHTML = "";7911092     focusedIndex = -1;@@ -1439,11 +1740,13 @@14391740 14401741   if (options.locked) {14411742     renderLocked();1743+  } else if (options.isSignup && options.savedIdentities) {1744+    // State 10: identity helper — signup form detected, show derived1745+    // identity list (emails then usernames) instead of vault entry list.1746+    renderIdentityHelper();14421747   } else if (entries.length === 1 && !options.isSignup) {14431748     // Silver platter (State 1): single domain match → focused fill view.1444-    // Multi-match goes through renderList instead. Signup mode always uses1445-    // the list because the user is choosing which existing identity to1446-    // surface — there's no single "fill credentials" interpretation.1749+    // Multi-match goes through renderList instead.14471750     renderSilverPlatter(entries[0]!);14481751   } else {14491752     renderList();

view file