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
↓ peach-browser-4b42e5fd.tar.gzatlas at this save
files changed2 files
src/content/index.ts+25 −3src/content/inject.ts+307 −4src/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(); },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();