83e4ac8c9fbf
before pro tier master plan v2.3.0
- time
- 2026-04-27 13:15 UTC · 6 months ago
- machine
- koh2
- parent
- 15583ef6c4c5
↓ peach-browser-83e4ac8c.tar.gzatlas at this save
files changed11 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 −1src/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+});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+}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+}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": {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 loginsrc/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}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+}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}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">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() {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" },