2c915c4eb112

passkey Pro gate in inline popup (State 11), background sync pendingProNotice banner (v2.4.2)

time
2026-04-27 14:11 UTC · 6 months ago
machine
koh2
parent
f87c03ab9e01
10 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/messages.ts+32 −6src/background/passkey.ts+18 −0src/content/index.ts+8 −0src/content/inject.ts+169 −0src/popup/App.tsx+34 −0src/popup/styles/popup.css+37 −0src/shared/types.ts+2 −0
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach",4-  "version": "2.4.1",4+  "version": "2.4.2",55   "description": "Zero-knowledge password manager",66   "browser_specific_settings": {77     "gecko": {

view file

manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach",4-  "version": "2.4.1",4+  "version": "2.4.2",55   "description": "Zero-knowledge password manager",66   "action": {77     "default_popup": "src/popup/index.html",

view file

package.json+1 −1
@@ -1,6 +1,6 @@11 {22   "name": "peach",3-  "version": "2.4.1",3+  "version": "2.4.2",44   "description": "Zero-knowledge password manager",55   "type": "module",66   "scripts": {

view file

src/background/messages.ts+32 −6
@@ -42,13 +42,30 @@4242  * relevant feature). Background never touches the popup view directly —4343  * the popup owns navigation. See Projects.md "Pro gate rule".4444  */45-async function requirePro(feature: ProFeature): Promise<{ allowed: boolean }> {45+async function requirePro(46+  feature: ProFeature,47+  trigger: "user" | "background" = "user"48+): Promise<{ allowed: boolean }> {4649   if (await isProUser()) return { allowed: true };47-  chrome.runtime48-    .sendMessage({ type: "PRO_GATE_TRIGGERED", payload: { feature } })49-    .catch(() => {50-      // Popup may be closed — the gate signal is best-effort.51-    });50+51+  if (trigger === "user") {52+    // User deliberately triggered this feature — redirect the popup immediately.53+    chrome.runtime54+      .sendMessage({ type: "PRO_GATE_TRIGGERED", payload: { feature } })55+      .catch(() => {56+        // Popup may be closed — the gate signal is best-effort.57+      });58+  } else {59+    // Background trigger (e.g. passive sync initiation) — no popup is open.60+    // Queue a notice so the next popup open shows a soft banner instead of61+    // silently failing.62+    if (chrome.storage.session) {63+      chrome.storage.session64+        .set({ pendingProNotice: { feature, triggeredAt: Date.now() } })65+        .catch(() => {});66+    }67+  }68+5269   return { allowed: false };5370 }5471 @@ -418,6 +435,15 @@418435       }419436       case "MARK_CELEBRATION_PLAYED": {420437         await markCelebrationPlayed();438+        return { ok: true };439+      }440+      case "OPEN_PRO_GATE": {441+        // Content script requests: open popup and navigate to License section.442+        const { feature } = (msg.payload ?? {}) as { feature?: string };443+        if (chrome.storage.session) {444+          await chrome.storage.session.set({ pendingProGate: { feature: feature ?? "passkeys" } });445+        }446+        try { await chrome.action.openPopup(); } catch { /* badge fallback */ }421447         return { ok: true };422448       }423449       case "GET_IDENTITIES": {

view file

src/background/passkey.ts+18 −0
@@ -244,6 +244,8 @@244244       "NotAllowedError",245245       "Passkey creation requires Peach Pro."246246     );247+    // Show the inline passkey gate so the user knows what happened and can upgrade.248+    broadcastPasskeyProGate();247249     return;248250   }249251 @@ -329,6 +331,16 @@329331   });330332 }331333 334+function broadcastPasskeyProGate(): void {335+  chrome.tabs.query({}, (tabs) => {336+    for (const tab of tabs) {337+      if (tab.id !== undefined) {338+        chrome.tabs.sendMessage(tab.id, { type: "PASSKEY_PRO_GATE" }).catch(() => {});339+      }340+    }341+  });342+}343+332344 async function handleGetRequest(333345   request: chrome.webAuthenticationProxy.GetRequest334346 ): Promise<void> {@@ -336,6 +348,12 @@336348   if (!vault.isUnlocked()) {337349     await openApprovalPopup();338350     await rejectGet(request.requestId, "NotAllowedError", "Unlock Peach first.");351+    return;352+  }353+354+  if (!(await isProUser())) {355+    await rejectGet(request.requestId, "NotAllowedError", "Passkey use requires Peach Pro.");356+    broadcastPasskeyProGate();339357     return;340358   }341359 

view file

src/content/index.ts+8 −0
@@ -14,6 +14,7 @@1414   SelectorEntry,1515   showAutofillSelector,1616   showCardSelector,17+  showPasskeyProGate,1718   showRememberSiteToast,1819 } from "./inject";1920 import { fillCardForm, fillFields } from "./fill";@@ -904,6 +905,13 @@904905     }905906 906907     sendResponse({ ok: hasForm || isShortcut });908+    return true;909+  }910+  if (msg?.type === "PASSKEY_PRO_GATE") {911+    // Background rejected a passkey request for a free user. Show State 11912+    // so they know what happened and can choose to upgrade.913+    showPasskeyProGate();914+    sendResponse({ ok: true });907915     return true;908916   }909917   if (msg?.type === "VAULT_UNLOCKED") {

view file

src/content/inject.ts+169 −0
@@ -1809,6 +1809,175 @@18091809 }18101810 18111811 /**1812+ * State 11 — passkey Pro gate. Shown when a free user triggers a WebAuthn1813+ * passkey create or get. Renders as a fixed-position floating popup (no1814+ * field anchor needed — passkeys don't require a focused input). Uses the1815+ * same theme tokens and entrance animation as all other inline popup states.1816+ */1817+export function showPasskeyProGate(): void {1818+  const domain = location.hostname.replace(/^www\./, "");1819+1820+  const host = document.createElement("div");1821+  host.setAttribute("data-peach-passkey-gate", "1");1822+  host.style.cssText = "all:initial;position:fixed;top:0;left:0;width:100%;height:100%;z-index:2147483646;pointer-events:none;";1823+  const shadow = host.attachShadow({ mode: "closed" });1824+1825+  const style = document.createElement("style");1826+  style.textContent = `1827+    :host { all: initial; }1828+    .gate-wrap {1829+      position: fixed;1830+      top: 50%;1831+      left: 50%;1832+      transform: translate(-50%, -50%);1833+      width: 280px;1834+      pointer-events: auto;1835+      animation: g-in 220ms cubic-bezier(0.16,1,0.3,1) both;1836+    }1837+    @keyframes g-in {1838+      from { opacity:0; transform: translate(-50%,-52%); }1839+      to   { opacity:1; transform: translate(-50%,-50%); }1840+    }1841+    .gate-wrap.is-exiting {1842+      animation: g-out 130ms ease-in forwards;1843+    }1844+    @keyframes g-out {1845+      to { opacity:0; transform: translate(-50%,-52%); }1846+    }1847+    .gate {1848+      background: ${popupTheme === "light" ? "#f8f5f0" : "#1a1712"};1849+      border: 1px solid ${popupTheme === "light" ? "rgba(0,0,0,0.10)" : "rgba(240,232,216,0.09)"};1850+      border-radius: 12px;1851+      box-shadow: 0 12px 48px rgba(0,0,0,0.75);1852+      padding: 16px;1853+      font-family: Inter, sans-serif;1854+    }1855+    .gate-header {1856+      display: flex;1857+      align-items: center;1858+      gap: 7px;1859+      margin-bottom: 12px;1860+      padding-bottom: 10px;1861+      border-bottom: 1px solid ${popupTheme === "light" ? "rgba(0,0,0,0.07)" : "rgba(240,232,216,0.07)"};1862+    }1863+    .gate-domain {1864+      font-size: 12px;1865+      color: ${popupTheme === "light" ? "rgba(0,0,0,0.45)" : "rgba(240,232,216,0.45)"};1866+      font-family: "IBM Plex Mono", monospace;1867+      overflow: hidden;1868+      text-overflow: ellipsis;1869+      white-space: nowrap;1870+    }1871+    .gate-title {1872+      font-size: 14px;1873+      font-weight: 500;1874+      color: ${popupTheme === "light" ? "#1a1712" : "rgba(240,232,216,0.92)"};1875+      margin-bottom: 6px;1876+    }1877+    .gate-subtitle {1878+      font-size: 12px;1879+      color: ${popupTheme === "light" ? "rgba(0,0,0,0.50)" : "rgba(240,232,216,0.45)"};1880+      line-height: 1.5;1881+      margin-bottom: 14px;1882+    }1883+    .gate-btn {1884+      display: block;1885+      width: 100%;1886+      padding: 9px 0;1887+      border-radius: 8px;1888+      border: none;1889+      cursor: pointer;1890+      font-size: 13px;1891+      font-family: Inter, sans-serif;1892+      font-weight: 500;1893+      transition: opacity 100ms;1894+      margin-bottom: 7px;1895+      text-align: center;1896+    }1897+    .gate-btn:last-child { margin-bottom: 0; }1898+    .gate-btn--accent {1899+      background: ${accentHex};1900+      color: #fff;1901+    }1902+    .gate-btn--accent:hover { opacity: 0.88; }1903+    .gate-btn--ghost {1904+      background: ${popupTheme === "light" ? "rgba(0,0,0,0.06)" : "rgba(240,232,216,0.07)"};1905+      color: ${popupTheme === "light" ? "rgba(0,0,0,0.55)" : "rgba(240,232,216,0.45)"};1906+    }1907+    .gate-btn--ghost:hover { opacity: 0.75; }1908+    .backdrop {1909+      position: fixed;1910+      inset: 0;1911+      pointer-events: auto;1912+    }1913+  `;1914+  shadow.appendChild(style);1915+1916+  const backdrop = document.createElement("div");1917+  backdrop.className = "backdrop";1918+  shadow.appendChild(backdrop);1919+1920+  const wrap = document.createElement("div");1921+  wrap.className = "gate-wrap";1922+  shadow.appendChild(wrap);1923+1924+  const gate = document.createElement("div");1925+  gate.className = "gate";1926+  wrap.appendChild(gate);1927+1928+  // Header: peach icon + domain1929+  const header = document.createElement("div");1930+  header.className = "gate-header";1931+  const icon = document.createElement("span");1932+  icon.innerHTML = peachIconSvg();1933+  const domainEl = document.createElement("span");1934+  domainEl.className = "gate-domain";1935+  domainEl.textContent = domain;1936+  header.append(icon, domainEl);1937+  gate.appendChild(header);1938+1939+  const title = document.createElement("div");1940+  title.className = "gate-title";1941+  title.textContent = "Passkeys are part of Peach Pro.";1942+  gate.appendChild(title);1943+1944+  const subtitle = document.createElement("div");1945+  subtitle.className = "gate-subtitle";1946+  subtitle.textContent = "Store and use passkeys across all your devices, securely.";1947+  gate.appendChild(subtitle);1948+1949+  const upgradeBtn = document.createElement("button");1950+  upgradeBtn.className = "gate-btn gate-btn--accent";1951+  upgradeBtn.type = "button";1952+  upgradeBtn.textContent = "Upgrade to Pro";1953+  gate.appendChild(upgradeBtn);1954+1955+  const laterBtn = document.createElement("button");1956+  laterBtn.className = "gate-btn gate-btn--ghost";1957+  laterBtn.type = "button";1958+  laterBtn.textContent = "Not right now";1959+  gate.appendChild(laterBtn);1960+1961+  function dismiss() {1962+    wrap.classList.add("is-exiting");1963+    setTimeout(() => host.remove(), 130);1964+  }1965+1966+  upgradeBtn.addEventListener("click", () => {1967+    chrome.runtime.sendMessage({ type: "OPEN_PRO_GATE", payload: { feature: "passkeys" } }).catch(() => {});1968+    dismiss();1969+  });1970+  laterBtn.addEventListener("click", dismiss);1971+  backdrop.addEventListener("click", dismiss);1972+1973+  document.addEventListener("keydown", function onEsc(e: KeyboardEvent) {1974+    if (e.key === "Escape") { dismiss(); document.removeEventListener("keydown", onEsc); }1975+  });1976+1977+  document.body.appendChild(host);1978+}1979+1980+/**18121981  * State 7 — card silver platter. Single-card focused fill view rendered18131982  * over the cc-number field. Reuses the SELECTOR_STYLES Shadow DOM, the18141983  * theme tokens, and the .sp-fill-btn / .sp-actions primary action classes

view file

src/popup/App.tsx+34 −0
@@ -195,6 +195,7 @@195195   const [listSearchSeed, setListSearchSeed] = createSignal("");196196   const [listSearchSeedNonce, setListSearchSeedNonce] = createSignal(0);197197   const [pendingCelebration, setPendingCelebration] = createSignal(false);198+  const [pendingProNotice, setPendingProNotice] = createSignal<{ feature: string } | null>(null);198199 199200   let contentPhaseTimer: number | undefined;200201 @@ -338,6 +339,21 @@338339     setBootState("ready");339340     document.getElementById("pre-lock")?.setAttribute("hidden", "true");340341 342+    // Check for pending session-storage notices from the background.343+    if (chrome.storage.session) {344+      const session = await chrome.storage.session.get(["pendingProGate", "pendingProNotice"]) as345+        Record<string, { feature?: string } | undefined>;346+      if (session.pendingProGate?.feature) {347+        await chrome.storage.session.remove("pendingProGate");348+        setUpgradeHighlight((session.pendingProGate.feature as ProFeature) ?? null);349+        setSettingsInitialSection("license");350+        setView("settings");351+      } else if (session.pendingProNotice?.feature) {352+        await chrome.storage.session.remove("pendingProNotice");353+        setPendingProNotice({ feature: session.pendingProNotice.feature ?? "" });354+      }355+    }356+341357     // Warm the lazy-module cache so the Suspense fallback never shows for342358     // locally-bundled extension views. Without this, the first navigation to343359     // Settings/Import/Export/etc can briefly mount `.peach-loading`, which@@ -846,6 +862,24 @@846862                 </button>847863               </div>848864             </header>865+866+            <Show when={pendingProNotice() !== null}>867+              <div class="peach-pro-notice-banner">868+                <span>✦ {pendingProNotice()?.feature === "sync" ? "Sync" : "A Pro feature"} requires Peach Pro</span>869+                <button870+                  class="peach-pro-notice-link"871+                  onClick={() => {872+                    setUpgradeHighlight((pendingProNotice()?.feature as ProFeature) ?? null);873+                    setSettingsInitialSection("license");874+                    setView("settings");875+                    setPendingProNotice(null);876+                  }}877+                >878+                  Learn more →879+                </button>880+                <button class="peach-pro-notice-close" onClick={() => setPendingProNotice(null)} aria-label="Dismiss">✕</button>881+              </div>882+            </Show>849883 850884             <div class="peach-shell-body">851885               <div class="peach-shell-veil"></div>

view file

src/popup/styles/popup.css+37 −0
@@ -5318,3 +5318,40 @@53185318   color: var(--peach-t2);53195319   animation: peach-pro-wordmark-in 400ms cubic-bezier(0.16, 1, 0.3, 1) 800ms both;53205320 }5321+5322+5323+/* ── Pending Pro notice banner (shown after background sync gate fires) ── */5324+.peach-pro-notice-banner {5325+  display: flex;5326+  align-items: center;5327+  gap: 8px;5328+  padding: 7px 12px;5329+  background: color-mix(in srgb, var(--peach-accent) 12%, var(--peach-bg-card));5330+  border-bottom: 1px solid color-mix(in srgb, var(--peach-accent) 25%, transparent);5331+  font-size: 11px;5332+  color: var(--peach-t2);5333+  font-family: "IBM Plex Mono", monospace;5334+  flex-shrink: 0;5335+}5336+5337+.peach-pro-notice-link {5338+  background: none;5339+  border: none;5340+  padding: 0;5341+  cursor: pointer;5342+  font-size: 11px;5343+  color: var(--peach-accent);5344+  font-family: "IBM Plex Mono", monospace;5345+  margin-left: auto;5346+  white-space: nowrap;5347+}5348+5349+.peach-pro-notice-close {5350+  background: none;5351+  border: none;5352+  padding: 0 0 0 4px;5353+  cursor: pointer;5354+  font-size: 10px;5355+  color: var(--peach-t3);5356+  line-height: 1;5357+}

view file

src/shared/types.ts+2 −0
@@ -219,6 +219,8 @@219219   | "VAULT_LOCKED"    // background → content/popup: vault just locked220220   | "VAULT_UNLOCKED"  // background → content/popup: vault just unlocked221221   | "PRO_GATE_TRIGGERED" // background → popup: a Pro feature was attempted on free222+  | "PASSKEY_PRO_GATE" // background → content: show passkey Pro gate in inline popup223+  | "OPEN_PRO_GATE" // content → background: open popup and navigate to License section222224   | "SHOULD_PLAY_CELEBRATION"223225   | "MARK_CELEBRATION_PLAYED";224226 

view file