83e4ac8c9fbf

before pro tier master plan v2.3.0

time
2026-04-27 13:15 UTC · 6 months ago
machine
koh2
parent
15583ef6c4c5
11 files src/background/health.test.ts+141 −0src/background/health.ts+216 −0src/background/license.ts+4 −0src/background/messages.ts+71 −2src/background/vault.ts+13 −1src/popup/App.tsx+30 −4src/popup/styles/popup.css+294 −0src/popup/views/Health.tsx+64 −2src/popup/views/Lock.tsx+13 −2src/popup/views/Settings.tsx+158 −80src/shared/types.ts+45 −1
src/background/health.test.ts+141 −0
@@ -0,0 +1,141 @@1+import { describe, expect, it } from "vitest";2+import { buildAdvancedHealthReport, resolveResetUrl } from "./health";3+import type { VaultEntry } from "../shared/types";4+5+const NINETY_ONE_DAYS = 91 * 86_400_000;6+const ONE_EIGHTY_ONE_DAYS = 181 * 86_400_000;7+8+function entry(overrides: Partial<VaultEntry>): VaultEntry {9+  return {10+    id: overrides.id ?? crypto.randomUUID(),11+    type: "login",12+    title: "Test",13+    username: "[email protected]",14+    password: "Strong-Passw0rd!",15+    url: "https://example.com",16+    createdAt: Date.now() - 10_000,17+    modifiedAt: Date.now() - 10_000,18+    ...overrides,19+  };20+}21+22+describe("buildAdvancedHealthReport", () => {23+  it("flags reused passwords across entries", async () => {24+    const password = "Repeated-Passw0rd!";25+    const a = entry({ id: "a", url: "https://github.com", password });26+    const b = entry({ id: "b", url: "https://gitlab.com", password });27+    const c = entry({ id: "c", url: "https://other.com", password: "Different-Passw0rd!" });28+29+    const report = await buildAdvancedHealthReport([a, b, c]);30+31+    expect(report.reusedGroups).toBe(1);32+    const ra = report.remediation.find((r) => r.id === "a");33+    const rc = report.remediation.find((r) => r.id === "c");34+    expect(ra?.factors.reused).toBe(true);35+    expect(rc?.factors.reused).toBeFalsy();36+  });37+38+  it("ages passwords using 90-day threshold for sensitive domains", async () => {39+    const sensitive = entry({40+      id: "s",41+      url: "https://www.gmail.com",42+      passwordChangedAt: Date.now() - NINETY_ONE_DAYS,43+    });44+    const regular = entry({45+      id: "r",46+      url: "https://news.example.com",47+      passwordChangedAt: Date.now() - NINETY_ONE_DAYS,48+    });49+50+    const report = await buildAdvancedHealthReport([sensitive, regular]);51+52+    const rs = report.remediation.find((r) => r.id === "s");53+    const rr = report.remediation.find((r) => r.id === "r");54+    expect(rs?.factors.aged).toBe(true);55+    expect(rr?.factors.aged).toBeFalsy();56+  });57+58+  it("ages non-sensitive entries past 180 days", async () => {59+    const old = entry({60+      id: "o",61+      url: "https://news.example.com",62+      passwordChangedAt: Date.now() - ONE_EIGHTY_ONE_DAYS,63+    });64+    const report = await buildAdvancedHealthReport([old]);65+    expect(report.remediation[0]?.factors.aged).toBe(true);66+  });67+68+  it("scores breach + reuse + age higher than weak alone", async () => {69+    const breached = entry({70+      id: "b",71+      url: "https://bank.example.com",72+      breach: 25,73+      passwordChangedAt: Date.now() - NINETY_ONE_DAYS,74+    });75+    const breached2 = entry({76+      id: "b2",77+      url: "https://other.com",78+      password: breached.password,79+    });80+    const weakOnly = entry({ id: "w", password: "abc12345", url: "https://other2.com" });81+82+    const report = await buildAdvancedHealthReport([breached, breached2, weakOnly]);83+84+    expect(report.remediation[0]?.id).toBe("b");85+    const top = report.remediation[0]!;86+    expect(top.score).toBeGreaterThan(60);87+    expect(top.factors.breached).toBe(true);88+    expect(top.factors.reused).toBe(true);89+  });90+91+  it("ignores deleted and non-login entries", async () => {92+    const deleted = entry({ id: "d", deletedAt: Date.now() });93+    const note = entry({ id: "n", type: "note", password: undefined });94+    const report = await buildAdvancedHealthReport([deleted, note]);95+    expect(report.remediation).toHaveLength(0);96+    expect(report.entryCount).toBe(0);97+  });98+99+  it("returns 0 vault score for an empty vault", async () => {100+    const report = await buildAdvancedHealthReport([]);101+    expect(report.vaultScore).toBe(0);102+    expect(report.remediation).toHaveLength(0);103+  });104+105+  it("never stores or returns the hash itself", async () => {106+    const a = entry({ id: "a", password: "secret-Passw0rd!" });107+    const report = await buildAdvancedHealthReport([a]);108+    const json = JSON.stringify(report);109+    expect(json.includes("secret-Passw0rd!")).toBe(false);110+    // sha256 of "secret-Passw0rd!" must not appear either111+    const expectedHash = await crypto.subtle.digest(112+      "SHA-256",113+      new TextEncoder().encode("secret-Passw0rd!")114+    );115+    const hex = Array.from(new Uint8Array(expectedHash))116+      .map((b) => b.toString(16).padStart(2, "0"))117+      .join("");118+    expect(json.includes(hex)).toBe(false);119+  });120+});121+122+describe("resolveResetUrl", () => {123+  it("maps known domains to their security pages", () => {124+    expect(resolveResetUrl(entry({ url: "https://github.com/login" }))).toBe(125+      "https://github.com/settings/security"126+    );127+    expect(resolveResetUrl(entry({ url: "https://mail.google.com/u/0" }))).toBe(128+      "https://myaccount.google.com/security"129+    );130+  });131+132+  it("falls back to /account/security on the entry's origin", () => {133+    expect(resolveResetUrl(entry({ url: "https://app.example.com/login" }))).toBe(134+      "https://app.example.com/account/security"135+    );136+  });137+138+  it("returns null when no URL is set", () => {139+    expect(resolveResetUrl(entry({ url: undefined }))).toBeNull();140+  });141+});

view file

src/background/health.ts+216 −0
@@ -0,0 +1,216 @@1+/**2+ * health.ts — Pro advanced vault health analysis.3+ *4+ * Pure analysis on top of `vault.getEntries()`. No side effects, no storage5+ * writes, no network. Reuse detection hashes passwords in memory and discards6+ * the hash; nothing about a password ever lands on disk through this module.7+ *8+ * The basic health surface (breach flagging, weak password detection) lives9+ * in `popup/views/Health.tsx` and is free. The advanced report adds:10+ *   - password age tracking with 90/180-day thresholds11+ *   - cross-domain reuse detection12+ *   - exposure scoring (0–100, financial/email weighted 2× in vault score)13+ *   - prioritised remediation queue14+ */15+16+import type {17+  AdvancedHealthEntryReport,18+  AdvancedHealthReport,19+  VaultEntry,20+} from "../shared/types";21+import { extractDomain } from "../shared/domain";22+23+const SENSITIVE_DOMAIN_PATTERNS =24+  /bank|pay|finance|invest|mail|gmail|outlook|yahoo|icloud|instagram|twitter|facebook|linkedin/i;25+26+const NINETY_DAYS_MS = 90 * 24 * 60 * 60 * 1000;27+const ONE_EIGHTY_DAYS_MS = 180 * 24 * 60 * 60 * 1000;28+29+const SCORE_BREACHED = 60;30+const SCORE_REUSED = 25;31+const SCORE_AGED = 15;32+const SCORE_WEAK = 20;33+const SCORE_NO_TOTP_SENSITIVE = 10;34+35+async function sha256Hex(plain: string): Promise<string> {36+  const buf = await crypto.subtle.digest(37+    "SHA-256",38+    new TextEncoder().encode(plain)39+  );40+  return Array.from(new Uint8Array(buf))41+    .map((b) => b.toString(16).padStart(2, "0"))42+    .join("");43+}44+45+function isSensitiveDomain(domain: string): boolean {46+  return SENSITIVE_DOMAIN_PATTERNS.test(domain);47+}48+49+function ageThresholdMs(domain: string): number {50+  return isSensitiveDomain(domain) ? NINETY_DAYS_MS : ONE_EIGHTY_DAYS_MS;51+}52+53+/**54+ * Lightweight zxcvbn-free heuristic for "weak password". Real zxcvbn is too55+ * heavy for the service worker; the basic Health view computes it in the popup56+ * already, but the background scoring needs a fast approximation. Anything57+ * shorter than 10 chars or lacking variety counts as weak (zxcvbn < 2 region).58+ */59+function isWeakHeuristic(password: string): boolean {60+  if (password.length < 10) return true;61+  let classes = 0;62+  if (/[a-z]/.test(password)) classes += 1;63+  if (/[A-Z]/.test(password)) classes += 1;64+  if (/[0-9]/.test(password)) classes += 1;65+  if (/[^A-Za-z0-9]/.test(password)) classes += 1;66+  return classes < 3;67+}68+69+export async function buildAdvancedHealthReport(70+  entries: VaultEntry[]71+): Promise<AdvancedHealthReport> {72+  const now = Date.now();73+  const live = entries.filter(74+    (e) => !e.deletedAt && (e.type === undefined || e.type === "login")75+  );76+77+  const hashGroups = new Map<string, string[]>();78+  for (const entry of live) {79+    if (!entry.password) continue;80+    const hash = await sha256Hex(entry.password);81+    const ids = hashGroups.get(hash) ?? [];82+    ids.push(entry.id);83+    hashGroups.set(hash, ids);84+  }85+  const reusedIds = new Set<string>();86+  let reusedGroupCount = 0;87+  for (const ids of hashGroups.values()) {88+    if (ids.length > 1) {89+      reusedGroupCount += 1;90+      for (const id of ids) reusedIds.add(id);91+    }92+  }93+94+  const reports: AdvancedHealthEntryReport[] = [];95+  let agedCount = 0;96+  let weightedSum = 0;97+  let weightSum = 0;98+99+  for (const entry of live) {100+    const domain = extractDomain(entry.url ?? "") ?? "";101+    const sensitive = domain ? isSensitiveDomain(domain) : false;102+    const passwordChanged =103+      entry.passwordChangedAt ?? entry.createdAt ?? now;104+    const ageMs = now - passwordChanged;105+    const aged = entry.password106+      ? ageMs > ageThresholdMs(domain)107+      : false;108+    if (aged) agedCount += 1;109+110+    const breached = (entry.breach ?? 0) > 0;111+    const reused = reusedIds.has(entry.id);112+    const weak = entry.password ? isWeakHeuristic(entry.password) : false;113+    const missingTotp = sensitive && !entry.totp;114+115+    let score = 0;116+    if (breached) score += SCORE_BREACHED;117+    if (reused) score += SCORE_REUSED;118+    if (aged) score += SCORE_AGED;119+    if (weak) score += SCORE_WEAK;120+    if (missingTotp) score += SCORE_NO_TOTP_SENSITIVE;121+    if (score > 100) score = 100;122+123+    const reasons: string[] = [];124+    if (breached) reasons.push(`Breached (${entry.breach} exposures)`);125+    if (reused) reasons.push("Reused");126+    if (aged) {127+      const days = Math.floor(ageMs / 86_400_000);128+      reasons.push(`${days} days old`);129+    }130+    if (weak) reasons.push("Weak");131+    if (missingTotp) reasons.push("No 2FA on sensitive site");132+133+    const weight = sensitive ? 2 : 1;134+    weightedSum += score * weight;135+    weightSum += weight;136+137+    if (score > 0) {138+      reports.push({139+        id: entry.id,140+        title: entry.title,141+        domain,142+        url: entry.url,143+        score,144+        ageDays: Math.floor(ageMs / 86_400_000),145+        reasons,146+        factors: {147+          breached,148+          breachCount: entry.breach ?? 0,149+          reused,150+          aged,151+          weak,152+          missingTotpOnSensitive: missingTotp,153+        },154+      });155+    }156+  }157+158+  reports.sort((a, b) => b.score - a.score);159+160+  const vaultScore = weightSum > 0 ? Math.round(weightedSum / weightSum) : 0;161+162+  return {163+    vaultScore,164+    generatedAt: now,165+    entryCount: live.length,166+    reusedGroups: reusedGroupCount,167+    agedCount,168+    remediation: reports,169+  };170+}171+172+/**173+ * Resolve a best-effort password reset URL for the entry's domain. Returns174+ * the entry's own URL as a fallback so Pro users still land somewhere175+ * sensible — a banner inside the in-page popup later picks up the form176+ * pre-fill. Form auto-fill is intentionally not done here; that's the177+ * content-script half of smart password change and stays event-driven.178+ */179+export function resolveResetUrl(entry: VaultEntry): string | null {180+  const url = entry.url ?? "";181+  const domain = extractDomain(url);182+  if (!domain) return null;183+184+  const knownResetPaths: Record<string, string> = {185+    "google.com": "https://myaccount.google.com/security",186+    "gmail.com": "https://myaccount.google.com/security",187+    "github.com": "https://github.com/settings/security",188+    "twitter.com": "https://twitter.com/settings/password",189+    "x.com": "https://x.com/settings/password",190+    "facebook.com": "https://www.facebook.com/settings?tab=security",191+    "instagram.com": "https://www.instagram.com/accounts/password/change/",192+    "linkedin.com":193+      "https://www.linkedin.com/psettings/change-password",194+    "amazon.com": "https://www.amazon.com/ap/changepassword",195+    "apple.com": "https://appleid.apple.com",196+    "icloud.com": "https://appleid.apple.com",197+    "microsoft.com": "https://account.microsoft.com/security",198+    "outlook.com": "https://account.microsoft.com/security",199+    "live.com": "https://account.microsoft.com/security",200+    "dropbox.com": "https://www.dropbox.com/account/security",201+    "netflix.com": "https://www.netflix.com/password",202+    "spotify.com": "https://www.spotify.com/account/change-password/",203+    "reddit.com": "https://www.reddit.com/settings/account",204+  };205+206+  for (const [match, target] of Object.entries(knownResetPaths)) {207+    if (domain === match || domain.endsWith("." + match)) return target;208+  }209+210+  try {211+    const u = new URL(url);212+    return `${u.origin}/account/security`;213+  } catch {214+    return null;215+  }216+}

view file

src/background/license.ts+4 −0
@@ -162,3 +162,7 @@162162   const info = await getLicenseInfo();163163   return info.valid && info.plan === "pro";164164 }165+166+export async function deactivateLicense(): Promise<void> {167+  await chrome.storage.local.remove("peach_license");168+}

view file

src/background/messages.ts+71 −2
@@ -12,16 +12,43 @@1212 import { copyToClipboard } from "./clipboard";1313 import { setupAutoLock, touchAutoLock } from "./alarms";1414 import * as vault from "./vault";15-import { activateLicense, getLicenseInfo } from "./license";1615 import {16+  activateLicense,17+  deactivateLicense,18+  getLicenseInfo,19+  isProUser,20+} from "./license";21+import {1722   getPasskeyProviderState,1823   getPendingPasskeyRequest,1924   resolvePendingPasskeyRequest,2025   setPasskeyProviderEnabled,2126 } from "./passkey";22-import type { Message, MessageResponse, MessageType } from "../shared/types";27+import { buildAdvancedHealthReport, resolveResetUrl } from "./health";28+import type {29+  Message,30+  MessageResponse,31+  MessageType,32+  ProFeature,33+} from "../shared/types";2334 import type { SyncStatus } from "../shared/sync-types";2435 36+/**37+ * The single Pro gate. Every gated handler calls this first; the broadcast38+ * lets the popup react (navigate to the License section, highlight the39+ * relevant feature). Background never touches the popup view directly —40+ * the popup owns navigation. See Projects.md "Pro gate rule".41+ */42+async function requirePro(feature: ProFeature): Promise<{ allowed: boolean }> {43+  if (await isProUser()) return { allowed: true };44+  chrome.runtime45+    .sendMessage({ type: "PRO_GATE_TRIGGERED", payload: { feature } })46+    .catch(() => {47+      // Popup may be closed — the gate signal is best-effort.48+    });49+  return { allowed: false };50+}51+2552 const PRIVATE_IPV4_RANGES = [2653   { prefix: "10." },2754   { prefix: "127." },@@ -316,6 +343,12 @@316343         return { ok: true, data: await getPasskeyProviderState() };317344       case "SET_PASSKEY_PROVIDER_ENABLED": {318345         const { enabled } = msg.payload as { enabled: boolean };346+        // Disabling is always free; gating fires only when a free user is347+        // trying to turn passkey interception on.348+        if (enabled) {349+          const gate = await requirePro("passkeys");350+          if (!gate.allowed) return { ok: false, error: "pro_required" };351+        }319352         return { ok: true, data: await setPasskeyProviderEnabled(enabled) };320353       }321354       case "FIRE_CANARY_PING": {@@ -368,6 +401,36 @@368401         const licenseInfo = await getLicenseInfo();369402         return { ok: true, data: licenseInfo };370403       }404+      case "DEACTIVATE_LICENSE": {405+        await deactivateLicense();406+        return { ok: true };407+      }408+      case "GET_ADVANCED_HEALTH_REPORT": {409+        const gate = await requirePro("advanced_health");410+        if (!gate.allowed) return { ok: false, error: "pro_required" };411+        if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };412+        const report = await buildAdvancedHealthReport(vault.getEntries());413+        return { ok: true, data: report };414+      }415+      case "INITIATE_PASSWORD_CHANGE": {416+        const gate = await requirePro("smart_password_change");417+        if (!gate.allowed) return { ok: false, error: "pro_required" };418+        if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };419+        const { id } = msg.payload as { id: string };420+        const entry = vault.getEntryById(id);421+        if (!entry) return { ok: false, error: "entry not found" };422+        const url = resolveResetUrl(entry);423+        if (!url) return { ok: false, error: "no reset url" };424+        try {425+          await chrome.tabs.create({ url });426+          return { ok: true, data: { url } };427+        } catch (err) {428+          return {429+            ok: false,430+            error: err instanceof Error ? err.message : "failed to open tab",431+          };432+        }433+      }371434       case "IS_DURESS_ENABLED": {372435         const enabled = await vault.isDuressEnabled();373436         return { ok: true, data: enabled };@@ -431,15 +494,21 @@431494         return { ok: true, data: { pairedDevices, trustedNetworks, status, isPro: pro } };432495       }433496       case "TRIGGER_SYNC": {497+        const gate = await requirePro("sync");498+        if (!gate.allowed) return { ok: false, error: "pro_required" };434499         return { ok: true };435500       }436501       case "START_PAIRING": {502+        const gate = await requirePro("sync");503+        if (!gate.allowed) return { ok: false, error: "pro_required" };437504         return { ok: true };438505       }439506       case "CANCEL_PAIRING": {440507         return { ok: true };441508       }442509       case "ACCEPT_PAIRING": {510+        const gate = await requirePro("sync");511+        if (!gate.allowed) return { ok: false, error: "pro_required" };443512         return { ok: true };444513       }445514       case "REMOVE_PAIRED_DEVICE": {

view file

src/background/vault.ts+13 −1
@@ -718,6 +718,9 @@718718     id: crypto.randomUUID(),719719     createdAt: now,720720     modifiedAt: now,721+    // Stamp passwordChangedAt at creation so age tracking is accurate from722+    // day one. Non-login entries carry the same stamp harmlessly.723+    passwordChangedAt: sanitized.password ? now : undefined,721724   };722725 723726   entries.push(nextEntry);@@ -751,10 +754,19 @@751754   }752755 753756   const current = entries[idx];757+  const sanitizedPatch = sanitizeEntryPatch(patch);758+  // Refresh passwordChangedAt only when the password value actually changes —759+  // a metadata-only update (title, tags, etc.) must not reset the age clock760+  // that drives the Pro health rotation recommendation.761+  const passwordChanged =762+    "password" in patch &&763+    typeof sanitizedPatch.password === "string" &&764+    sanitizedPatch.password !== current.password;754765   entries[idx] = {755766     ...current,756-    ...sanitizeEntryPatch(patch),767+    ...sanitizedPatch,757768     modifiedAt: Date.now(),769+    ...(passwordChanged ? { passwordChangedAt: Date.now() } : {}),758770   };759771 760772   // sanitizeEntryPatch maps an empty password to `undefined`. For login

view file

src/popup/App.tsx+30 −4
@@ -1,6 +1,6 @@11 import { createEffect, createMemo, createSignal, lazy, onCleanup, onMount, Show, Suspense } from "solid-js";22 import { ACCENT_COLORS } from "../shared/types";3-import type { AccentColorName, PasskeyEntry, PendingPasskeyRequest, VaultEntry } from "../shared/types";3+import type { AccentColorName, PasskeyEntry, PendingPasskeyRequest, ProFeature, VaultEntry } from "../shared/types";44 import { LockView } from "./views/Lock";55 import { VaultView, type SortOrder } from "./views/Vault";66 import { EntryView } from "./views/Entry";@@ -182,6 +182,13 @@182182   const [restoreView, setRestoreView] = createSignal<"cjk" | "file" | null>(null);183183   const [pendingPasskey, setPendingPasskey] = createSignal<PendingPasskeyRequest | null>(null);184184   const [currentTabDomain, setCurrentTabDomain] = createSignal<string>("");185+  // Cached Pro status — synchronous for render paths. Background's186+  // GET_LICENSE is async, but render-time reads of isPro() must not block.187+  // Refreshed at boot, after activate/deactivate, and whenever the popup188+  // re-opens onto a stored license that has changed via another window.189+  const [proStatus, setProStatus] = createSignal(false);190+  const [settingsInitialSection, setSettingsInitialSection] = createSignal<string | null>(null);191+  const [upgradeHighlight, setUpgradeHighlight] = createSignal<ProFeature | null>(null);185192   const [sortOrder, setSortOrder] = createSignal<SortOrder>("url-first");186193   const [listSearchSeed, setListSearchSeed] = createSignal("");187194   const [listSearchSeedNonce, setListSearchSeedNonce] = createSignal(0);@@ -286,7 +293,7 @@286293       (vaultResponse as { ok: boolean; data?: boolean }).data287294     );288295     setHasVault(vaultExists);289-    await restoreSettings();296+    await Promise.all([restoreSettings(), refreshProStatus()]);290297 291298     if (unlocked && vaultExists) {292299       await openVault();@@ -338,8 +345,15 @@338345 339346     // Background auto-lock broadcasts VAULT_LOCKED so the popup drops stale340347     // entries instead of waiting for the next GET_ENTRIES round-trip.341-    const handleRuntimeMessage = (msg: { type?: string }) => {348+    const handleRuntimeMessage = (msg: { type?: string; payload?: { feature?: ProFeature } }) => {342349       if (msg?.type === "VAULT_LOCKED" && view() !== "lock") handleLock();350+      if (msg?.type === "PRO_GATE_TRIGGERED") {351+        // Background never navigates UI directly — it asks; the popup decides.352+        // Land on Settings → License with the triggering feature highlighted.353+        setUpgradeHighlight(msg.payload?.feature ?? null);354+        setSettingsInitialSection("license");355+        setView("settings");356+      }343357     };344358     chrome.runtime.onMessage.addListener(handleRuntimeMessage);345359     onCleanup(() => chrome.runtime.onMessage.removeListener(handleRuntimeMessage));@@ -475,6 +489,12 @@475489     setPendingPasskey((res as { ok: boolean; data?: PendingPasskeyRequest | null }).data ?? null);476490   }477491 492+  async function refreshProStatus() {493+    const res = await sendMessage({ type: "GET_LICENSE" });494+    const data = (res as { ok: boolean; data?: { valid?: boolean; plan?: string } }).data;495+    setProStatus(Boolean(data?.valid && data?.plan === "pro"));496+  }497+478498   async function restoreSettings() {479499     const res = await sendMessage({ type: "GET_SETTINGS" });480500     const s = (res as { ok: boolean; data?: any }).data ?? {};@@ -738,6 +758,7 @@738758               <LockView739759                 hasVault={hasVault()}740760                 ready={bootState() === "ready"}761+                isPro={proStatus()}741762                 onUnlock={handleUnlock}742763               />743764             }@@ -885,6 +906,9 @@885906                   trashRetentionDays={trashRetentionDays()}886907                   theme={theme()}887908                   accentColor={accentColor()}909+                  isPro={proStatus()}910+                  initialSection={settingsInitialSection()}911+                  upgradeHighlight={upgradeHighlight()}888912                   onBreachMonitoringChange={applyBreachMonitoring}889913                   onLockOnCloseChange={applyLockOnClose}890914                   onAutoLockMinutesChange={applyAutoLockMinutes}@@ -893,7 +917,8 @@893917                   onAccentChange={applyAccentColor}894918                   shortcuts={shortcutBindings()}895919                   onShortcutsChange={applyShortcuts}896-                  onBack={handleBack}920+                  onLicenseChange={refreshProStatus}921+                  onBack={() => { setSettingsInitialSection(null); setUpgradeHighlight(null); handleBack(); }}897922                   onImport={() => setView("import")}898923                   onExport={() => setView("export")}899924                   onBreachCheckDone={loadEntries}@@ -925,6 +950,7 @@925950               <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>926951                 <HealthView927952                   entries={entries()}953+                  isPro={proStatus()}928954                   onFixEntry={handleFixEntry}929955                   onBack={() => setView("settings")}930956                   onRefresh={loadEntries}

view file

src/popup/styles/popup.css+294 −0
@@ -4958,3 +4958,297 @@49584958 .peach-card-expiry-sep {49594959   color: var(--peach-text-dim);49604960 }4961+4962+/* ── Pro tier — upgrade screen ──────────────────────────────── */4963+.peach-pro-mark {4964+  font-size: 9px;4965+  color: var(--peach-accent);4966+  margin-left: 6px;4967+  vertical-align: super;4968+  opacity: 0.7;4969+}4970+4971+.peach-pro-mini-badge {4972+  display: block;4973+  font-size: 10px;4974+  font-style: italic;4975+  color: var(--peach-accent);4976+  letter-spacing: 0.04em;4977+  margin-top: -2px;4978+  padding-left: 2px;4979+  opacity: 0.85;4980+  text-align: center;4981+}4982+4983+.peach-wordmark-stack {4984+  display: inline-flex;4985+  flex-direction: column;4986+  align-items: center;4987+}4988+4989+.peach-pro-upgrade {4990+  display: flex;4991+  flex-direction: column;4992+  gap: 14px;4993+  padding: 4px 4px 12px;4994+}4995+4996+.peach-pro-upgrade-hero {4997+  display: flex;4998+  flex-direction: column;4999+  align-items: center;5000+  gap: 4px;5001+  padding: 8px 0 4px;5002+}5003+5004+.peach-pro-upgrade-mark {5005+  font-size: 26px;5006+  line-height: 1;5007+}5008+5009+.peach-pro-upgrade-title {5010+  font-family: var(--font-display, var(--font-ui));5011+  font-size: 22px;5012+  margin: 0;5013+  color: var(--peach-t0);5014+  letter-spacing: 0.01em;5015+}5016+5017+.peach-pro-upgrade-tagline {5018+  margin: 0;5019+  font-style: italic;5020+  color: var(--peach-t2);5021+  font-size: 12px;5022+}5023+5024+.peach-pro-features {5025+  display: flex;5026+  flex-direction: column;5027+  gap: 10px;5028+  border-top: 1px solid var(--peach-border);5029+  border-bottom: 1px solid var(--peach-border);5030+  padding: 14px 4px;5031+}5032+5033+.peach-pro-feature {5034+  display: flex;5035+  gap: 10px;5036+  align-items: flex-start;5037+}5038+5039+.peach-pro-feature-mark {5040+  color: var(--peach-accent);5041+  font-size: 14px;5042+  line-height: 1.2;5043+  flex-shrink: 0;5044+  display: inline-block;5045+  transform-origin: center;5046+}5047+5048+.peach-pro-feature-text {5049+  display: flex;5050+  flex-direction: column;5051+  gap: 2px;5052+}5053+5054+.peach-pro-feature-name {5055+  font-size: 13px;5056+  color: var(--peach-t0);5057+}5058+5059+.peach-pro-feature-desc {5060+  font-size: 11px;5061+  color: var(--peach-t2);5062+}5063+5064+.peach-pro-feature.is-highlighted .peach-pro-feature-mark {5065+  animation: peach-pulse-accent 600ms ease-out forwards;5066+}5067+5068+@keyframes peach-pulse-accent {5069+  0%   { opacity: 1; transform: scale(1.4); }5070+  100% { opacity: 0.7; transform: scale(1); }5071+}5072+5073+.peach-pro-pricing {5074+  margin: 0;5075+  text-align: center;5076+  font-size: 12px;5077+  color: var(--peach-t2);5078+}5079+5080+.peach-pro-upgrade-cta {5081+  width: 100%;5082+  text-align: center;5083+  text-decoration: none;5084+  display: block;5085+  padding: 10px 16px;5086+}5087+5088+.peach-pro-key-link {5089+  background: transparent;5090+  border: 0;5091+  color: var(--peach-t2);5092+  font-size: 11px;5093+  cursor: pointer;5094+  align-self: center;5095+  padding: 4px 8px;5096+}5097+5098+.peach-pro-key-link:hover {5099+  color: var(--peach-accent);5100+}5101+5102+.peach-pro-key-entry {5103+  display: flex;5104+  flex-direction: column;5105+  gap: 8px;5106+}5107+5108+.peach-pro-key-input {5109+  font-family: var(--font-mono, monospace);5110+  letter-spacing: 0.06em;5111+  text-align: center;5112+}5113+5114+.peach-pro-key-error {5115+  margin: 0;5116+  font-size: 11px;5117+  color: var(--peach-breach, #e55);5118+}5119+5120+/* ── Pro tier — active state ─────────────────────────────────── */5121+.peach-pro-active {5122+  display: flex;5123+  flex-direction: column;5124+  gap: 8px;5125+  padding: 14px 4px;5126+}5127+5128+.peach-pro-active-head {5129+  display: flex;5130+  align-items: center;5131+  gap: 8px;5132+}5133+5134+.peach-pro-active-mark { font-size: 16px; line-height: 1; }5135+5136+.peach-pro-active-title {5137+  margin: 0;5138+  font-size: 15px;5139+  font-weight: 500;5140+  color: var(--peach-t0);5141+}5142+5143+.peach-pro-active-since {5144+  margin: 0;5145+  font-size: 12px;5146+  color: var(--peach-t2);5147+}5148+5149+.peach-pro-active-divider {5150+  height: 1px;5151+  background: var(--peach-border);5152+  margin: 8px 0 4px;5153+}5154+5155+.peach-pro-active-thanks {5156+  margin: 0;5157+  font-size: 12px;5158+  font-style: italic;5159+  color: var(--peach-t2);5160+}5161+5162+.peach-pro-deactivate {5163+  align-self: flex-end;5164+  background: transparent;5165+  border: 0;5166+  color: var(--peach-t2);5167+  font-size: 11px;5168+  cursor: pointer;5169+  padding: 6px 0;5170+  margin-top: 4px;5171+}5172+5173+.peach-pro-deactivate:hover {5174+  color: var(--peach-accent);5175+}5176+5177+.peach-pro-deactivate:disabled {5178+  opacity: 0.6;5179+  cursor: default;5180+}5181+5182+/* ── Pro advanced health remediation queue ──────────────────── */5183+.peach-health-pro-queue {5184+  display: flex;5185+  flex-direction: column;5186+  gap: 10px;5187+  padding: 12px;5188+  margin: 0 0 12px;5189+  background: color-mix(in srgb, var(--peach-accent) 4%, transparent);5190+  border: 1px solid color-mix(in srgb, var(--peach-accent) 18%, transparent);5191+  border-radius: 10px;5192+}5193+5194+.peach-health-pro-queue-head {5195+  display: flex;5196+  justify-content: space-between;5197+  align-items: baseline;5198+}5199+5200+.peach-health-pro-queue-title {5201+  font-size: 12px;5202+  font-weight: 500;5203+  letter-spacing: 0.04em;5204+  text-transform: uppercase;5205+  color: var(--peach-accent);5206+}5207+5208+.peach-health-pro-queue-score {5209+  font-size: 11px;5210+  color: var(--peach-t2);5211+}5212+5213+.peach-health-pro-row {5214+  display: flex;5215+  gap: 10px;5216+  align-items: center;5217+  padding: 8px 0;5218+  border-top: 1px solid var(--peach-border);5219+}5220+5221+.peach-health-pro-row-info {5222+  flex: 1;5223+  display: flex;5224+  flex-direction: column;5225+  gap: 4px;5226+  min-width: 0;5227+}5228+5229+.peach-health-pro-row-name {5230+  font-size: 13px;5231+  color: var(--peach-t0);5232+  overflow: hidden;5233+  text-overflow: ellipsis;5234+  white-space: nowrap;5235+}5236+5237+.peach-health-pro-row-meta {5238+  font-size: 11px;5239+  color: var(--peach-t2);5240+}5241+5242+.peach-health-pro-bar {5243+  height: 2px;5244+  background: var(--peach-border);5245+  border-radius: 2px;5246+  overflow: hidden;5247+  margin-top: 2px;5248+}5249+5250+.peach-health-pro-bar-fill {5251+  height: 100%;5252+  background: var(--peach-accent);5253+  transition: width 200ms ease-out;5254+}

view file

src/popup/views/Health.tsx+64 −2
@@ -1,6 +1,6 @@1-import { createMemo, createSignal, For, Show } from "solid-js";1+import { createMemo, createSignal, For, onMount, Show } from "solid-js";22 import { default as zxcvbn } from "zxcvbn";3-import type { VaultEntry } from "../../shared/types";3+import type { AdvancedHealthReport, VaultEntry } from "../../shared/types";44 import { getDisplayDomain } from "../../shared/domain";55 import { sendMessage } from "../App";66 import { VaultView } from "./Vault";@@ -22,6 +22,7 @@2222 2323 interface HealthProps {2424   entries: VaultEntry[];25+  isPro?: boolean;2526   onFixEntry: (entry: VaultEntry) => void;2627   onBack: () => void;2728   onRefresh: () => void;@@ -64,7 +65,32 @@6465     passkeys: true,6566   });6667   const [viewingReusedGroup, setViewingReusedGroup] = createSignal<VaultEntry[] | null>(null);68+  const [advancedReport, setAdvancedReport] = createSignal<AdvancedHealthReport | null>(null);69+  const [smartChangingId, setSmartChangingId] = createSignal<string | null>(null);6770 71+  onMount(async () => {72+    if (!props.isPro) return;73+    const res = await sendMessage({ type: "GET_ADVANCED_HEALTH_REPORT" });74+    if ((res as { ok: boolean }).ok) {75+      setAdvancedReport((res as { ok: boolean; data?: AdvancedHealthReport }).data ?? null);76+    }77+  });78+79+  async function handleSmartChange(entryId: string) {80+    setSmartChangingId(entryId);81+    const res = await sendMessage({82+      type: "INITIATE_PASSWORD_CHANGE",83+      payload: { id: entryId },84+    });85+    setSmartChangingId(null);86+    if ((res as { ok: boolean }).ok) {87+      // The reset URL opened in a new tab — close the popup so the user can88+      // act on the page. Refresh the report on next mount so the remediation89+      // queue reflects any updates the user makes.90+      window.close();91+    }92+  }93+6894   const ONE_YEAR_MS = 365 * 24 * 60 * 60 * 1000;6995 7096   const breachedEntries = createMemo(() =>@@ -176,6 +202,42 @@176202         </div>177203 178204         <div class="peach-health-body">205+          <Show when={props.isPro && advancedReport() && advancedReport()!.remediation.length > 0}>206+            <div class="peach-health-pro-queue">207+              <div class="peach-health-pro-queue-head">208+                <span class="peach-health-pro-queue-title">Remediation queue</span>209+                <span class="peach-health-pro-queue-score">210+                  Vault score {advancedReport()!.vaultScore}/100211+                </span>212+              </div>213+              <For each={advancedReport()!.remediation.slice(0, 8)}>214+                {(item) => (215+                  <div class="peach-health-pro-row">216+                    <div class="peach-health-pro-row-info">217+                      <span class="peach-health-pro-row-name">{item.title}</span>218+                      <span class="peach-health-pro-row-meta">219+                        {item.reasons.join(" · ")}220+                      </span>221+                      <div class="peach-health-pro-bar" aria-hidden="true">222+                        <div223+                          class="peach-health-pro-bar-fill"224+                          style={{ width: `${Math.min(item.score, 100)}%` }}225+                        ></div>226+                      </div>227+                    </div>228+                    <button229+                      class="peach-btn peach-btn--accent peach-btn--sm"230+                      onClick={() => void handleSmartChange(item.id)}231+                      disabled={smartChangingId() === item.id}232+                    >233+                      {smartChangingId() === item.id ? "opening…" : "Change this password"}234+                    </button>235+                  </div>236+                )}237+              </For>238+            </div>239+          </Show>240+179241           <div class="peach-health-summary">180242             <Show181243               when={totalNeedsAttention() > 0}

view file

src/popup/views/Lock.tsx+13 −2
@@ -17,6 +17,7 @@1717 interface LockProps {1818   hasVault: boolean;1919   ready: boolean;20+  isPro?: boolean;2021   onUnlock: () => void | Promise<void>;2122 }2223 @@ -177,7 +178,12 @@177178       <Match when={props.hasVault}>178179         <section class="peach-screen peach-theme lock-screen lock-screen--vault-stage" data-motion-phase="enter">179180           <header class="peach-screen-header lock-screen-header">180-            <PeachWordmark class="peach-wordmark-wrap" />181+            <div class="peach-wordmark-stack">182+              <PeachWordmark class="peach-wordmark-wrap" />183+              <Show when={props.isPro}>184+                <span class="peach-pro-mini-badge">pro</span>185+              </Show>186+            </div>181187           </header>182188 183189           <div class="peach-lock-hero peach-lock-hero--unlock">@@ -234,7 +240,12 @@234240           data-motion-phase="enter"235241         >236242           <header class="peach-screen-header lock-screen-header">237-            <PeachWordmark class="peach-wordmark-wrap" />243+            <div class="peach-wordmark-stack">244+              <PeachWordmark class="peach-wordmark-wrap" />245+              <Show when={props.isPro}>246+                <span class="peach-pro-mini-badge">pro</span>247+              </Show>248+            </div>238249           </header>239250 240251           <div class="peach-lock-hero">

view file

src/popup/views/Settings.tsx+158 −80
@@ -1,7 +1,7 @@11 import { createSignal, For, Match, onMount, Show, Switch } from "solid-js";22 import { sendMessage } from "../App";33 import { ACCENT_COLORS } from "../../shared/types";4-import type { AccentColorName, PasskeyProviderState } from "../../shared/types";4+import type { AccentColorName, PasskeyProviderState, ProFeature } from "../../shared/types";55 import type { PairedDevice, TrustedNetwork, SyncStatus } from "../../shared/sync-types";66 import {77   DEFAULT_BINDINGS,@@ -27,6 +27,9 @@2727   trashRetentionDays: number;2828   theme: "dark" | "light";2929   accentColor: AccentColorName;30+  isPro?: boolean;31+  initialSection?: string | null;32+  upgradeHighlight?: ProFeature | null;3033   onBreachMonitoringChange: (enabled: boolean) => void | Promise<void>;3134   onLockOnCloseChange: (enabled: boolean) => void | Promise<void>;3235   onAutoLockMinutesChange: (minutes: number) => void | Promise<void>;@@ -35,6 +38,7 @@3538   onAccentChange: (name: AccentColorName) => void | Promise<void>;3639   shortcuts: Record<ShortcutAction, Keybinding>;3740   onShortcutsChange: (active: Record<ShortcutAction, Keybinding>) => void | Promise<void>;41+  onLicenseChange?: () => void | Promise<void>;3842   onBack: () => void;3943   onImport: () => void;4044   onExport: () => void;@@ -56,9 +60,35 @@5660 5761 const FEEDBACK_CATEGORIES = ["Bug report", "Feature request", "Question", "Other"];5862 63+function formatActivatedSince(issued: string | undefined): string {64+  if (!issued) return "Active";65+  try {66+    const d = new Date(issued);67+    if (Number.isNaN(d.getTime())) return "Active";68+    return `Active since ${d.toLocaleDateString(undefined, { month: "long", year: "numeric" })}`;69+  } catch {70+    return "Active";71+  }72+}73+74+function isSectionName(value: string): value is SectionName {75+  return (76+    value === "security" ||77+    value === "vault" ||78+    value === "sync" ||79+    value === "appearance" ||80+    value === "shortcuts" ||81+    value === "license" ||82+    value === "feedback"83+  );84+}85+5986 export function SettingsView(props: SettingsProps) {6087   const version = chrome.runtime.getManifest().version;61-  const [activeSection, setActiveSection] = createSignal<SectionName | null>(null);88+  const initial = props.initialSection;89+  const [activeSection, setActiveSection] = createSignal<SectionName | null>(90+    initial && isSectionName(initial) ? (initial as SectionName) : null91+  );6292 6393   const [checkingBreaches, setCheckingBreaches] = createSignal(false);6494   const [breachResult, setBreachResult] = createSignal<string | null>(null);@@ -72,7 +102,11 @@72102   const [trustedNetworks, setTrustedNetworks] = createSignal<TrustedNetwork[]>([]);73103   const [syncStatus, setSyncStatus] = createSignal<SyncStatus>("offline");74104   const [lastSyncAt, setLastSyncAt] = createSignal<number | undefined>(undefined);75-  const [isPro, setIsPro] = createSignal(false);105+  // Prefer the prop (cached at App boot), fall back to the sync-state probe.106+  // The prop is authoritative once present so license activation reflects107+  // immediately without waiting for a sync round-trip.108+  const [syncProStatus, setSyncProStatus] = createSignal(false);109+  const isPro = () => props.isPro ?? syncProStatus();76110   const [syncingNow, setSyncingNow] = createSignal(false);77111   const [passkeyState, setPasskeyState] = createSignal<PasskeyProviderState | null>(null);78112   const [passkeySaving, setPasskeySaving] = createSignal(false);@@ -131,7 +165,7 @@131165         setTrustedNetworks(data.data.trustedNetworks ?? []);132166         setSyncStatus(data.data.status ?? "offline");133167         setLastSyncAt(data.data.lastSyncAt);134-        setIsPro(data.data.isPro ?? false);168+        setSyncProStatus(data.data.isPro ?? false);135169       }136170     }137171 @@ -156,7 +190,7 @@156190         setTrustedNetworks(msg.trustedNetworks ?? []);157191         setSyncStatus(msg.status ?? "offline");158192         setLastSyncAt(msg.lastSyncAt);159-        setIsPro(msg.isPro ?? false);193+        setSyncProStatus(msg.isPro ?? false);160194       }161195     });162196   });@@ -432,7 +466,7 @@432466           <span class="peach-settings-cat-name">433467             {opts.name}434468             <Show when={opts.pro && !isPro()}>435-              <span class="peach-sync-pro-badge" style="margin-left:6px">Pro</span>469+              <span class="peach-pro-mark" aria-label="Pro feature">✦</span>436470             </Show>437471           </span>438472           <span class="peach-settings-cat-desc">{opts.desc}</span>@@ -1190,97 +1224,141 @@11901224     }11911225   }11921226 1227+  const PRO_FEATURES: Array<{1228+    feature: ProFeature;1229+    name: string;1230+    desc: string;1231+  }> = [1232+    { feature: "sync", name: "Peach Sync", desc: "Encrypted · peer-to-peer · local" },1233+    { feature: "ios_app", name: "Android & iOS", desc: "Your vault everywhere" },1234+    { feature: "passkeys", name: "Passkeys", desc: "Zero-knowledge WebAuthn" },1235+    { feature: "advanced_health", name: "Advanced Vault Health", desc: "Reuse · age · exposure scoring" },1236+    { feature: "smart_password_change", name: "Smart Password Change", desc: "Guided reset · pre-filled · vault updated" },1237+  ];1238+1239+  const [showKeyEntry, setShowKeyEntry] = createSignal(false);1240+  const [deactivating, setDeactivating] = createSignal(false);1241+1242+  async function handleDeactivateLicense() {1243+    setDeactivating(true);1244+    await sendMessage({ type: "DEACTIVATE_LICENSE" });1245+    setLicenseInfo(null);1246+    await props.onLicenseChange?.();1247+    setDeactivating(false);1248+  }1249+11931250   function renderLicense() {11941251     return (11951252       <div class="peach-settings-body">1196-        <div class="peach-settings-group">1197-          <p class="peach-settings-sub-label">PLAN</p>1198-          <Show1199-            when={licenseInfo()?.valid && licenseInfo()?.plan === "pro"}1200-            fallback={1201-              <>1202-                <div class="peach-pro-promo">1203-                  <div class="peach-pro-promo-glow" aria-hidden="true"></div>1204-                  <div class="peach-pro-promo-inner">1205-                    <h3 class="peach-pro-promo-title">Peach Pro</h3>1206-                    <p class="peach-pro-promo-tagline">One-time purchase. Yours forever.</p>1207-                    <ul class="peach-pro-promo-list">1208-                      <li>1209-                        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">1210-                          <path d="M3 8a5 5 0 0 1 9-3" />1211-                          <path d="M13 8a5 5 0 0 1-9 3" />1212-                          <path d="M12 2v3h-3" />1213-                          <path d="M4 14v-3h3" />1214-                        </svg>1215-                        <span>Sync across every device, end-to-end encrypted</span>1216-                      </li>1217-                      <li>1218-                        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">1219-                          <circle cx="6" cy="9" r="3" />1220-                          <path d="M9 9h5" />1221-                          <path d="M12 9v2.5" />1222-                          <path d="M14 9v2" />1223-                        </svg>1224-                        <span>Create and store passkeys directly in Peach</span>1225-                      </li>1226-                      <li>1227-                        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">1228-                          <path d="M8 2.5l1.6 3.4 3.7.4-2.8 2.5.8 3.7L8 10.7l-3.3 1.8.8-3.7L2.7 6.3l3.7-.4z" />1229-                        </svg>1230-                        <span>Every future Pro feature, no extra cost</span>1231-                      </li>1232-                    </ul>1233-                    <a1234-                      href="https://peachpasswords.com/#pricing"1235-                      target="_blank"1236-                      rel="noopener noreferrer"1237-                      class="peach-btn peach-pro-promo-cta"1253+        <Show1254+          when={isPro()}1255+          fallback={1256+            <div class="peach-pro-upgrade">1257+              <div class="peach-pro-upgrade-hero">1258+                <span class="peach-pro-upgrade-mark" aria-hidden="true">🍑</span>1259+                <h2 class="peach-pro-upgrade-title">Peach Pro</h2>1260+                <p class="peach-pro-upgrade-tagline">Everything on every device.</p>1261+              </div>1262+1263+              <div class="peach-pro-features">1264+                <For each={PRO_FEATURES}>1265+                  {(item) => (1266+                    <div1267+                      class="peach-pro-feature"1268+                      classList={{1269+                        "is-highlighted": props.upgradeHighlight === item.feature,1270+                      }}12381271                     >1239-                      Upgrade to Pro →1240-                    </a>1241-                  </div>1242-                </div>1243-                <p class="peach-pro-or-divider"><span>or activate a key</span></p>1244-                <div class="peach-settings-row" style="flex-direction:column;align-items:stretch;gap:8px">1272+                      <span class="peach-pro-feature-mark">✦</span>1273+                      <div class="peach-pro-feature-text">1274+                        <span class="peach-pro-feature-name">{item.name}</span>1275+                        <span class="peach-pro-feature-desc">{item.desc}</span>1276+                      </div>1277+                    </div>1278+                  )}1279+                </For>1280+              </div>1281+1282+              <p class="peach-pro-pricing">$39 · once · yours forever</p>1283+1284+              <a1285+                href="https://peachpasswords.com/#pricing"1286+                target="_blank"1287+                rel="noopener noreferrer"1288+                class="peach-btn peach-btn--accent peach-pro-upgrade-cta"1289+              >1290+                Get Peach Pro1291+              </a>1292+1293+              <Show1294+                when={showKeyEntry()}1295+                fallback={1296+                  <button1297+                    class="peach-pro-key-link"1298+                    onClick={() => setShowKeyEntry(true)}1299+                  >1300+                    Already have a key? Enter it →1301+                  </button>1302+                }1303+              >1304+                <div class="peach-pro-key-entry">12451305                   <input1246-                    class="peach-underline-input"1306+                    class="peach-underline-input peach-pro-key-input"12471307                     type="text"12481308                     placeholder="PPK-XXXX-XXXX-XXXX-XXXX-XXXX"12491309                     value={licenseKeyInput()}1250-                    onInput={(e) => setLicenseKeyInput(e.currentTarget.value)}1251-                    style="width:100%;box-sizing:border-box"1310+                    onInput={(e) =>1311+                      setLicenseKeyInput(e.currentTarget.value.toUpperCase())1312+                    }1313+                    onKeyDown={(e) => {1314+                      if (e.key === "Enter") void onActivate();1315+                    }}1316+                    autocomplete="off"1317+                    spellcheck={false}12521318                   />12531319                   <button1254-                    class="peach-btn peach-btn--subtle peach-btn--sm"1255-                    onClick={() => void handleActivateLicense()}1320+                    class="peach-btn peach-btn--accent peach-btn--sm"1321+                    onClick={() => void onActivate()}12561322                     disabled={activatingLicense() || !licenseKeyInput().trim()}12571323                   >1258-                    {activatingLicense() ? "activating..." : "Activate"}1324+                    {activatingLicense() ? "activating…" : "Activate"}12591325                   </button>1326+                  <Show when={licenseError()}>1327+                    <p class="peach-pro-key-error">That key doesn't look right.</p>1328+                  </Show>12601329                 </div>1261-                <Show when={licenseError()}>1262-                  <div class="peach-settings-row peach-settings-row--meta">1263-                    <span class="peach-settings-value" style="color:#e55">{licenseError()}</span>1264-                  </div>1265-                </Show>1266-                <Show when={licenseSuccess()}>1267-                  <div class="peach-settings-row peach-settings-row--meta">1268-                    <span class="peach-settings-value" style="color:#5e5">{licenseSuccess()}</span>1269-                  </div>1270-                </Show>1271-              </>1272-            }1273-          >1274-            <div class="peach-settings-row peach-settings-row--meta">1275-              <span>Peach Pro</span>1276-              <span class="peach-settings-value">1277-                activated {licenseInfo()?.issued ? new Date(licenseInfo()!.issued!).toLocaleDateString() : ""}1278-              </span>1330+              </Show>12791331             </div>1280-          </Show>1281-        </div>1332+          }1333+        >1334+          <div class="peach-pro-active">1335+            <div class="peach-pro-active-head">1336+              <span class="peach-pro-active-mark" aria-hidden="true">🍑</span>1337+              <h3 class="peach-pro-active-title">Peach Pro</h3>1338+            </div>1339+            <p class="peach-pro-active-since">1340+              {formatActivatedSince(licenseInfo()?.issued)}1341+            </p>1342+            <div class="peach-pro-active-divider"></div>1343+            <p class="peach-pro-active-thanks">Thank you.</p>1344+            <button1345+              class="peach-pro-deactivate"1346+              onClick={() => void handleDeactivateLicense()}1347+              disabled={deactivating()}1348+            >1349+              {deactivating() ? "Deactivating…" : "Deactivate →"}1350+            </button>1351+          </div>1352+        </Show>12821353       </div>12831354     );1355+1356+    async function onActivate() {1357+      await handleActivateLicense();1358+      if ((licenseInfo()?.valid ?? false) && licenseInfo()?.plan === "pro") {1359+        await props.onLicenseChange?.();1360+      }1361+    }12841362   }12851363 12861364   function renderFeedback() {

view file

src/shared/types.ts+45 −1
@@ -41,6 +41,12 @@4141   totp?: string; // base32 seed, stored encrypted — only for login entries4242   notes?: string;4343   breach?: number; // HIBP exposure count; 0 or undefined = clean — only for login entries44+  /**45+   * When the password field was last changed. Set on first-write of `password`46+   * and refreshed on every subsequent change. Defaults to `createdAt` when47+   * legacy entries are read by the advanced health analyser.48+   */49+  passwordChangedAt?: number;4450   canary?: Canary;4551   createdAt: number; // Date.now()4652   modifiedAt: number;@@ -199,6 +205,9 @@199205   | "IS_CARD_DECLINED"200206   | "SUBMIT_FEEDBACK"201207   | "GET_VAULT_STATUS"208+  | "GET_ADVANCED_HEALTH_REPORT"209+  | "INITIATE_PASSWORD_CHANGE"210+  | "DEACTIVATE_LICENSE"202211   // Content-script-bound message types — sent BY the background TO content203212   // scripts. They never reach messages.ts's switch. Kept in this union so204213   // existing call sites type-check; do not add cases for them in the@@ -206,7 +215,42 @@206215   | "PING"            // background → content: liveness probe207216   | "SUMMON_AUTOFILL" // background → content: open the in-page palette208217   | "VAULT_LOCKED"    // background → content/popup: vault just locked209-  | "VAULT_UNLOCKED"; // background → content/popup: vault just unlocked218+  | "VAULT_UNLOCKED"  // background → content/popup: vault just unlocked219+  | "PRO_GATE_TRIGGERED"; // background → popup: a Pro feature was attempted on free220+221+export type ProFeature =222+  | "sync"223+  | "passkeys"224+  | "advanced_health"225+  | "smart_password_change"226+  | "ios_app";227+228+export interface AdvancedHealthEntryReport {229+  id: string;230+  title: string;231+  domain: string;232+  url?: string;233+  score: number;234+  ageDays: number;235+  reasons: string[];236+  factors: {237+    breached: boolean;238+    breachCount: number;239+    reused: boolean;240+    aged: boolean;241+    weak: boolean;242+    missingTotpOnSensitive: boolean;243+  };244+}245+246+export interface AdvancedHealthReport {247+  vaultScore: number;248+  generatedAt: number;249+  entryCount: number;250+  reusedGroups: number;251+  agedCount: number;252+  remediation: AdvancedHealthEntryReport[];253+}210254 211255 export const ACCENT_COLORS = [212256   { name: "peach",    hex: "#F49C70" },

view file