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
↓ peach-browser-2c915c4e.tar.gzatlas at this save
files changed10 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 −0manifest.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": {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",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": {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": {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 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") {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 classessrc/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>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+}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