1import { createEffect, createMemo, createSignal, For, Match, onCleanup, onMount, Show, Switch } from "solid-js";2import { sendMessage } from "../App";3import { PeachMark } from "../components/PeachMark";4import { StackDeck } from "../components/settings/StackDeck";5import type { CardId, StackBodies, StackCardSpec } from "../components/settings/types";6import { RecoveryWordDisplayView } from "./RecoveryWordDisplay";7import type { Canary } from "../../shared/types";8import { ACCENT_COLORS } from "../../shared/types";9import type {10 AccentColorName,11 PasskeyProviderState,12 ProFeature,13 VaultSnapshot,14} from "../../shared/types";15import type { PairedDevice } from "../../shared/sync-types";16import {17 DEFAULT_BINDINGS,18 findConflict,19 formatKeybinding,20 keybindingFromEvent,21 SHORTCUT_DESCRIPTORS,22 type Keybinding,23 type ShortcutAction,24} from "../../shared/shortcuts";25import { isNativePlatform, nativePlugin } from "../../shared/native";26import { WEB3FORMS_KEY } from "../../shared/config";2728interface LicenseInfo {29 valid: boolean;30 plan: "free" | "pro";31 issued?: string;32}3334interface SettingsProps {35 entriesCount: number;36 breachMonitoring: boolean;37 lockOnClose: boolean;38 autoLockMinutes: number;39 trashRetentionDays: number;40 theme: "dark" | "light";41 accentColor: AccentColorName;42 textSize: "small" | "medium" | "large";43 reduceMotion: boolean;44 isPro?: boolean;45 initialSection?: string | null;46 upgradeHighlight?: ProFeature | null;47 onBreachMonitoringChange: (enabled: boolean) => void | Promise<void>;48 onLockOnCloseChange: (enabled: boolean) => void | Promise<void>;49 onAutoLockMinutesChange: (minutes: number) => void | Promise<void>;50 onTrashRetentionChange: (days: number) => void | Promise<void>;51 onThemeChange: (theme: "dark" | "light") => void;52 onAccentChange: (name: AccentColorName) => void | Promise<void>;53 onTextSizeChange: (size: "small" | "medium" | "large") => void | Promise<void>;54 onReduceMotionChange: (enabled: boolean) => void | Promise<void>;55 shortcuts: Record<ShortcutAction, Keybinding>;56 onShortcutsChange: (active: Record<ShortcutAction, Keybinding>) => void | Promise<void>;57 onLicenseChange?: () => void | Promise<void>;58 isExiting?: boolean;59 onBack: () => void;60 onImport: () => void;61 onExport: () => void;62 onBreachCheckDone: () => void;63 onOpenHealth?: () => void;64 onOpenSyncPairing?: (method?: "chirp" | "desktop") => void;65 onOpenFileImport?: () => void;66 onOpenPanic?: () => void;67 onOpenCodexGenerate?: () => void;68 onOpenCodexRestore?: () => void;69 707172 onProGate?: (feature: ProFeature) => void;73 onCardChange?: (id: CardId) => void;74}7576const FEEDBACK_CATEGORIES = ["Bug report", "Feature request", "Question", "Other"];77const FEEDBACK_DATA_PERMISSION = "technicalAndInteraction";78const MAX_FEEDBACK_MESSAGE_LENGTH = 4000;79const PRO_CELEBRATION_CARD_STEP_MS = 150;80const PRO_CELEBRATION_THANK_YOU_MS = 3200;8182function sanitizeFeedbackMessage(value: string): string {83 return value84 .trim()85 .slice(0, MAX_FEEDBACK_MESSAGE_LENGTH)86 .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "")87 .replace(/&/g, "&")88 .replace(/</g, "<")89 .replace(/>/g, ">")90 .replace(/"/g, """)91 .replace(/'/g, "'");92}9394type DataCollectionPermissionApi = {95 permissions?: {96 getAll?: () => Promise<{ data_collection?: string[] }>;97 request?: (permissions: { data_collection: string[] }) => Promise<boolean>;98 };99};100101async function requestFeedbackDataPermission(): Promise<boolean> {102 const api = ((globalThis as { browser?: DataCollectionPermissionApi }).browser ??103 (typeof chrome !== "undefined" ? chrome : undefined)) as DataCollectionPermissionApi | undefined;104 if (!api?.permissions?.getAll) return true;105106 try {107 const permissions = await api.permissions.getAll();108 if (!("data_collection" in permissions)) return true;109 if (permissions.data_collection?.includes(FEEDBACK_DATA_PERMISSION)) return true;110 return await (api.permissions.request?.({ data_collection: [FEEDBACK_DATA_PERMISSION] }) ?? Promise.resolve(false));111 } catch {112 return false;113 }114}115116function formatActivatedSince(issued: string | undefined): string {117 if (!issued) return "Active";118 try {119 const d = new Date(issued);120 if (Number.isNaN(d.getTime())) return "Active";121 return `Active since ${d.toLocaleDateString(undefined, { month: "long", year: "numeric" })}`;122 } catch {123 return "Active";124 }125}126127function initialSectionToCard(value: string | null | undefined): CardId | null {128 if (!value) return null;129 if (value === "security") return "protection";130 if (value === "license") return "pro";131 if (132 value === "protection" ||133 value === "vault" ||134 value === "sync" ||135 value === "appearance" ||136 value === "shortcuts" ||137 value === "pro" ||138 value === "feedback"139 ) {140 return value;141 }142 return null;143}144145export function SettingsView(props: SettingsProps) {146 const manifest = chrome.runtime.getManifest();147 const version = manifest.version;148 const initial = props.initialSection;149 const [activeCard, setActiveCard] = createSignal<CardId>(150 props.upgradeHighlight ? "pro" : initialSectionToCard(initial) ?? "pro"151 );152153 createEffect(() => { props.onCardChange?.(activeCard()); });154155 const [justActivated, setJustActivated] = createSignal(false);156 const [isFanning, setIsFanning] = createSignal(false);157158 function triggerCelebration() {159 if (isFanning()) return;160 setJustActivated(false);161 setIsFanning(true);162 setActiveCard("pro");163 const fanOrder: CardId[] = ["protection", "vault", "sync", "appearance", "shortcuts", "feedback"];164 fanOrder.forEach((id, i) => {165 window.setTimeout(() => setActiveCard(id), (i + 1) * PRO_CELEBRATION_CARD_STEP_MS);166 });167 window.setTimeout(() => {168 setActiveCard("pro");169 setIsFanning(false);170 setJustActivated(true);171 window.setTimeout(() => setJustActivated(false), PRO_CELEBRATION_THANK_YOU_MS);172 }, (fanOrder.length + 1) * PRO_CELEBRATION_CARD_STEP_MS);173 }174175 function handleProCelebrationClick(e: MouseEvent) {176 e.stopPropagation();177 triggerCelebration();178 }179180 const [checkingBreaches, setCheckingBreaches] = createSignal(false);181 const [breachResult, setBreachResult] = createSignal<string | null>(null);182 const [licenseInfo, setLicenseInfo] = createSignal<LicenseInfo | null>(null);183 const [licenseKeyInput, setLicenseKeyInput] = createSignal("");184 const [licenseError, setLicenseError] = createSignal<string | null>(null);185 const [, setLicenseSuccess] = createSignal<string | null>(null);186 const [activatingLicense, setActivatingLicense] = createSignal(false);187188 const isPro = () => props.isPro ?? false;189 const cards = createMemo<StackCardSpec[]>(() => [190 {191 id: "pro",192 num: "I · FOREVER",193 title: "Peach Pro",194 lede: isPro() ? "active · paid once, yours forever" : "$39 once · yours forever",195 bgVar: "--stack-pro-bg",196 fgVar: "--stack-pro-fg",197 icon: "spark",198 onIconClick: isPro() ? triggerCelebration : undefined,199 },200 {201 id: "protection",202 num: "II · PROTECT",203 title: "Protection",204 lede: `${props.breachMonitoring ? "breaches watched" : "breach watch off"} · ${props.lockOnClose ? "locks on close" : "manual lock"}`,205 bgVar: "--stack-protect-bg",206 fgVar: "--stack-protect-fg",207 icon: "shield",208 },209 {210 id: "vault",211 num: "III · KEEP",212 title: "Vault",213 lede: `${props.entriesCount} ${props.entriesCount === 1 ? "entry" : "entries"} · backups, history, cleanup`,214 bgVar: "--stack-vault-bg",215 fgVar: "--stack-vault-fg",216 icon: "lock",217 },218 {219 id: "sync",220 num: "IV · ECHO",221 title: "Sync",222 lede: isPro() ? "pair by chirp, keep in step" : "Pro · encrypted device pairing",223 bgVar: "--stack-sync-bg",224 fgVar: "--stack-sync-fg",225 icon: "sync",226 },227 {228 id: "appearance",229 num: "V · DRESS",230 title: "Appearance",231 lede: `${props.theme} · ${props.accentColor} · ${props.textSize}`,232 bgVar: "--stack-appear-bg",233 fgVar: "--stack-appear-fg",234 icon: "sun",235 },236 {237 id: "shortcuts",238 num: "VI · KEYS",239 title: "Shortcuts",240 lede: `${SHORTCUT_DESCRIPTORS.length} bindings · what your fingers know`,241 bgVar: "--stack-shortcut-bg",242 fgVar: "--stack-shortcut-fg",243 icon: "keys",244 },245 {246 id: "feedback",247 num: "VII · ECHO",248 title: "Feedback",249 lede: `version ${version} · tell us what would make it better`,250 bgVar: "--stack-feedback-bg",251 fgVar: "--stack-feedback-fg",252 icon: "bubble",253 },254 ]);255 const [biometricSupported, setBiometricSupported] = createSignal(false);256 const [biometricStored, setBiometricStored] = createSignal(false);257 const [biometricBusy, setBiometricBusy] = createSignal(false);258 const [biometricError, setBiometricError] = createSignal<string | null>(null);259 const [passkeyState, setPasskeyState] = createSignal<PasskeyProviderState | null>(null);260 const [passkeySaving, setPasskeySaving] = createSignal(false);261262 263 264 interface PendingVouched { deviceId: string; deviceName: string }265 const [pendingVouched, setPendingVouched] = createSignal<PendingVouched[]>([]);266 const [vouchedBusy, setVouchedBusy] = createSignal<string | null>(null);267 268 const [pairedDevices, setPairedDevices] = createSignal<PairedDevice[]>([]);269 const [confirmUnpair, setConfirmUnpair] = createSignal<string | null>(null);270 const [syncStatus, setSyncStatus] = createSignal<"idle" | "syncing" | "error">("idle");271 const [syncFlash, setSyncFlash] = createSignal<string | null>(null);272273 const STALE_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000;274 function syncRelativeTime(when: number | undefined): string {275 if (!when) return "Not synced yet";276 const elapsed = Date.now() - when;277 if (elapsed < 60_000) return "Just now";278 const minutes = Math.floor(elapsed / 60_000);279 if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"} ago`;280 const hours = Math.floor(minutes / 60);281 if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;282 const days = Math.floor(hours / 24);283 return days < 7 ? `${days} day${days === 1 ? "" : "s"} ago` : "Not seen recently";284 }285 function isSyncStale(d: PairedDevice): boolean {286 return d.lastSyncedAt !== undefined && Date.now() - d.lastSyncedAt > STALE_THRESHOLD_MS;287 }288289 async function loadPairedDevices() {290 if (!isPro()) return;291 const r = (await sendMessage({ type: "CHIRP_LIST_PAIRED_DEVICES" })) as {292 ok: boolean;293 data?: { devices: PairedDevice[] };294 };295 if (r.ok) setPairedDevices(r.data?.devices ?? []);296 }297 async function unpairDevice(deviceId: string) {298 await sendMessage({ type: "CHIRP_UNPAIR_DEVICE", payload: { deviceId } });299 setConfirmUnpair(null);300 await loadPairedDevices();301 }302 async function syncNow() {303 setSyncStatus("syncing");304 setSyncFlash(null);305 const res = (await sendMessage({306 type: "TRIGGER_SYNC",307 payload: { reason: "manual", force: true },308 })) as { ok: boolean; error?: string };309 setTimeout(async () => {310 const state = (await sendMessage({ type: "GET_SYNC_STATE" })) as {311 ok: boolean;312 data?: { status: "idle" | "syncing" | "error"; entriesReceived?: number; error?: string };313 };314 const s = state.data;315 if (!s) return;316 setSyncStatus(s.status);317 if (s.status === "idle") {318 setSyncFlash((s.entriesReceived ?? 0) > 0 ? `Synced — ${s.entriesReceived} updated` : "Up to date");319 setTimeout(() => setSyncFlash(null), 3000);320 await loadPairedDevices();321 } else if (s.status === "error") {322 setSyncFlash(`Sync failed — ${s.error ?? "no host reachable"}`);323 setTimeout(() => setSyncFlash(null), 4000);324 }325 }, 250);326 if (!res.ok) {327 setSyncStatus("error");328 setSyncFlash(`Sync failed — ${res.error ?? "unknown"}`);329 setTimeout(() => setSyncFlash(null), 4000);330 }331 }332 333 const [rotateConfirmOpen, setRotateConfirmOpen] = createSignal(false);334 const [rotating, setRotating] = createSignal(false);335 const [rotateError, setRotateError] = createSignal<string | null>(null);336337 async function loadPendingVouched() {338 const r = (await sendMessage({ type: "GET_PENDING_VOUCHED_DEVICES" })) as {339 ok: boolean;340 data?: PendingVouched[];341 };342 if (r.ok) setPendingVouched(r.data ?? []);343 }344 async function confirmVouched(deviceId: string) {345 setVouchedBusy(deviceId);346 try {347 await sendMessage({ type: "CONFIRM_VOUCHED_DEVICE", payload: { deviceId } });348 await loadPendingVouched();349 } finally {350 setVouchedBusy(null);351 }352 }353 async function rejectVouched(deviceId: string) {354 setVouchedBusy(deviceId);355 try {356 await sendMessage({ type: "REJECT_VOUCHED_DEVICE", payload: { deviceId } });357 await loadPendingVouched();358 } finally {359 setVouchedBusy(null);360 }361 }362 async function doRotateSyncIdentity() {363 setRotateError(null);364 setRotating(true);365 try {366 const r = (await sendMessage({ type: "ROTATE_SYNC_IDENTITY" })) as {367 ok: boolean;368 error?: string;369 };370 if (!r.ok) {371 setRotateError(r.error ?? "Rotation failed.");372 return;373 }374 setRotateConfirmOpen(false);375 376 377 setShowNewRecoveryWords(true);378 } finally {379 setRotating(false);380 }381 }382383 const [dedupCount, setDedupCount] = createSignal<number | null>(null);384 const [dedupRunning, setDedupRunning] = createSignal(false);385 const [dedupDone, setDedupDone] = createSignal<number | null>(null);386387 const [declinedCardCount, setDeclinedCardCount] = createSignal(0);388389 390 391 392 const [historySnapshots, setHistorySnapshots] = createSignal<VaultSnapshot[]>([]);393 const [historyLoading, setHistoryLoading] = createSignal(false);394 const [historyRestoring, setHistoryRestoring] = createSignal<string | null>(null);395 const [historyShowAll, setHistoryShowAll] = createSignal(false);396397 async function loadVaultHistory() {398 setHistoryLoading(true);399 try {400 const res = await sendMessage({ type: "GET_VAULT_HISTORY" });401 const list = (res as { ok: boolean; data?: VaultSnapshot[] }).data ?? [];402 setHistorySnapshots(list);403 } finally {404 setHistoryLoading(false);405 }406 }407408 async function handleRestoreVaultSnapshot(id: string) {409 if (historyRestoring()) return;410 setHistoryRestoring(id);411 const res = await sendMessage({412 type: "RESTORE_VAULT_SNAPSHOT",413 payload: { snapshotId: id },414 });415 setHistoryRestoring(null);416 if ((res as { ok: boolean }).ok) {417 418 419 props.onBack();420 }421 }422423 function formatSnapshotTime(savedAt: number): string {424 const diff = Date.now() - savedAt;425 if (diff < 60_000) return "Just now";426 if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;427 const d = new Date(savedAt);428 const isToday = new Date().toDateString() === d.toDateString();429 const isYest =430 new Date(Date.now() - 86_400_000).toDateString() === d.toDateString();431 const t = d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });432 if (isToday) return `Today ${t}`;433 if (isYest) return `Yesterday ${t}`;434 return `${d.toLocaleDateString([], { month: "short", day: "numeric" })} ${t}`;435 }436437 function reasonLabel(reason: VaultSnapshot["reason"]): string {438 return (439 {440 local_save: "saved",441 sync_received: "synced",442 import: "imported",443 restore: "restored",444 } as const445 )[reason] ?? reason;446 }447448 const [showVaultAdvanced, setShowVaultAdvanced] = createSignal(false);449450 const [showChangePw, setShowChangePw] = createSignal(false);451 const [changePwCurrent, setChangePwCurrent] = createSignal("");452 const [changePwNew, setChangePwNew] = createSignal("");453 const [changePwConfirm, setChangePwConfirm] = createSignal("");454 const [changePwSaving, setChangePwSaving] = createSignal(false);455 const [changePwError, setChangePwError] = createSignal<string | null>(null);456 const [changePwSuccess] = createSignal(false);457 const [showNewRecoveryWords, setShowNewRecoveryWords] = createSignal(false);458459 460 const [showViewWords, setShowViewWords] = createSignal(false);461 const [viewWordsPassword, setViewWordsPassword] = createSignal("");462 const [viewWordsError, setViewWordsError] = createSignal<string | null>(null);463 const [viewedWords, setViewedWords] = createSignal<string[] | null>(null);464 const [viewWordsBusy, setViewWordsBusy] = createSignal(false);465466 async function handleViewRecoveryWords() {467 setViewWordsError(null);468 if (!viewWordsPassword()) {469 setViewWordsError("Enter your master password.");470 return;471 }472 setViewWordsBusy(true);473 try {474 const r = (await sendMessage({475 type: "GET_RECOVERY_WORDS",476 payload: { password: viewWordsPassword() },477 })) as { ok: boolean; data?: string[]; error?: string };478 if (r.ok) {479 setViewedWords(r.data ?? []);480 setViewWordsPassword("");481 return;482 }483 switch (r.error) {484 case "wrong_password":485 setViewWordsError("Incorrect password");486 break;487 case "password_required":488 setViewWordsError("Enter your master password.");489 break;490 case "recovery_not_setup":491 setViewWordsError("Recovery words aren't set up for this vault yet.");492 break;493 default:494 setViewWordsError("Couldn't retrieve recovery words.");495 }496 } finally {497 setViewWordsBusy(false);498 }499 }500 function closeViewWords() {501 setShowViewWords(false);502 setViewWordsPassword("");503 setViewWordsError(null);504 setViewedWords(null);505 }506507 const [duressEnabled, setDuressEnabled] = createSignal(false);508 const [showDuressSetup, setShowDuressSetup] = createSignal(false);509 const [duressPassword, setDuressPassword] = createSignal("");510 const [duressConfirm, setDuressConfirm] = createSignal("");511 const [duressMainPassword, setDuressMainPassword] = createSignal("");512 const [duressSaving, setDuressSaving] = createSignal(false);513 const [duressError, setDuressError] = createSignal<string | null>(null);514 const [duressSuccess, setDuressSuccess] = createSignal<string | null>(null);515516 const [canaryEntries, setCanaryEntries] = createSignal<Array<{id: string; title: string; canary: Canary}>>([]);517518 const [canaryTestingId, setCanaryTestingId] = createSignal<string | null>(null);519 const [canaryTestResult, setCanaryTestResult] = createSignal<{id: string; ok: boolean} | null>(null);520521 const [fbCategory, setFbCategory] = createSignal("Bug report");522 const [fbMessage, setFbMessage] = createSignal("");523 type FeedbackState = 'idle' | 'sending' | 'sent' | 'error';524 const [feedbackState, setFeedbackState] = createSignal<FeedbackState>('idle');525526 createEffect(() => {527 if (props.upgradeHighlight) {528 setActiveCard("pro");529 }530 });531532 onMount(async () => {533 const [licenseRes, passkeyRes, duressRes, settingsRes, keyRes] = await Promise.all([534 sendMessage({ type: "GET_LICENSE" }),535 sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }),536 sendMessage({ type: "IS_DURESS_ENABLED" }),537 sendMessage({ type: "GET_SETTINGS" }),538 sendMessage({ type: "GET_LICENSE_KEY" }),539 ]);540541 542 543 void loadVaultHistory();544545 546 547 void loadPendingVouched();548 void loadPairedDevices();549 const onBroadcast = (msg: unknown) => {550 if ((msg as { type?: string } | null)?.type === "VOUCHED_DEVICE_PENDING") {551 void loadPendingVouched();552 }553 };554 chrome.runtime?.onMessage?.addListener(onBroadcast);555 onCleanup(() => {556 try { chrome.runtime?.onMessage?.removeListener(onBroadcast); } catch { }557 });558559 if ((licenseRes as { ok: boolean }).ok) {560 setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null);561 }562563 if ((passkeyRes as { ok: boolean }).ok) {564 setPasskeyState(565 (passkeyRes as { ok: boolean; data?: PasskeyProviderState }).data ?? null566 );567 }568569 if ((duressRes as { ok: boolean }).ok) {570 setDuressEnabled((duressRes as { ok: boolean; data?: boolean }).data ?? false);571 }572573 if ((settingsRes as { ok: boolean }).ok) {574 const sData = (settingsRes as { ok: boolean; data?: { declinedCardFingerprints?: string[] } }).data;575 setDeclinedCardCount(sData?.declinedCardFingerprints?.length ?? 0);576 }577578 if ((keyRes as { ok: boolean }).ok) {579 setLicenseKey((keyRes as { ok: boolean; data?: string }).data ?? null);580 }581582 const entriesRes = await sendMessage({ type: "GET_ENTRIES" });583 if ((entriesRes as { ok: boolean }).ok) {584 const entries = (entriesRes as { ok: boolean; data?: Array<{id: string; title: string; canary?: Canary}> }).data ?? [];585 setCanaryEntries(586 entries587 .filter((e) => e.canary?.webhookUrl)588 .map((e) => ({ id: e.id, title: e.title, canary: e.canary! }))589 );590 }591592593 if (isNativePlatform()) {594 const biometric = nativePlugin<{ isAvailable: () => Promise<{ available: boolean }> }>("BiometricPlugin");595 const keystore = nativePlugin<{ hasMasterKey: () => Promise<{ exists: boolean }> }>("KeystorePlugin");596 try {597 if (biometric) {598 const a = await biometric.isAvailable();599 setBiometricSupported(a.available);600 }601 if (keystore) {602 const k = await keystore.hasMasterKey();603 setBiometricStored(k.exists);604 }605 } catch { }606 }607 });608609 async function handleEnableBiometrics() {610 setBiometricBusy(true);611 setBiometricError(null);612 try {613 const keyRes = (await sendMessage({ type: "GET_DERIVED_KEY" })) as {614 ok: boolean;615 data?: { masterKeyHex: string };616 error?: string;617 };618 if (!keyRes.ok || !keyRes.data?.masterKeyHex) {619 setBiometricError(keyRes.error === "vault_locked"620 ? "unlock the vault first"621 : "couldn't read derived key");622 return;623 }624 const keystore = nativePlugin<{ storeMasterKey: (p: { masterKeyHex: string }) => Promise<void> }>("KeystorePlugin");625 if (!keystore) {626 setBiometricError("biometric plugin unavailable");627 return;628 }629 await keystore.storeMasterKey({ masterKeyHex: keyRes.data.masterKeyHex });630 await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: true } });631 setBiometricStored(true);632 } catch (e) {633 setBiometricError(e instanceof Error ? e.message : "failed to enable biometrics");634 } finally {635 setBiometricBusy(false);636 }637 }638639 async function handleDisableBiometrics() {640 setBiometricBusy(true);641 setBiometricError(null);642 try {643 const keystore = nativePlugin<{ clearMasterKey: () => Promise<void> }>("KeystorePlugin");644 if (keystore) await keystore.clearMasterKey();645 await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: false } });646 setBiometricStored(false);647 } catch (e) {648 setBiometricError(e instanceof Error ? e.message : "failed to disable biometrics");649 } finally {650 setBiometricBusy(false);651 }652 }653654 async function handleChangePassword() {655 setChangePwError(null);656 if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; }657 if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; }658 if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; }659 if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; }660 setChangePwSaving(true);661 const res = await sendMessage({662 type: "CHANGE_PASSWORD",663 payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() },664 }) as { ok: boolean; error?: string };665 if (res.ok) {666 setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");667 setShowChangePw(false);668 669 670 671 672 await chrome.storage?.local?.set?.({ peach_recovery_pending: "1" });673 setShowNewRecoveryWords(true);674 } else {675 setChangePwError(res.error ?? "failed to change password");676 }677 setChangePwSaving(false);678 }679680 const [copied, setCopied] = createSignal(false);681682 async function copyLicenseKey(key: string) {683 await navigator.clipboard.writeText(key);684 setCopied(true);685 setTimeout(() => setCopied(false), 2000);686 }687688 async function handleActivateLicense() {689 const key = licenseKeyInput().trim();690 if (!key) return;691 setActivatingLicense(true);692 setLicenseError(null);693 setLicenseSuccess(null);694 const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } });695 setActivatingLicense(false);696 if ((res as { ok: boolean }).ok) {697 const data = (res as { ok: boolean; data?: LicenseInfo }).data;698 if (data?.valid) {699 setLicenseInfo(data);700 setLicenseKey(key);701 setLicenseSuccess("License activated!");702 setLicenseKeyInput("");703 } else {704 setLicenseError("Invalid license key");705 }706 } else {707 setLicenseError("Activation failed");708 }709 }710711 async function handleTogglePasskeys() {712 const current = passkeyState();713 if (!current || !current.supported) return;714 setPasskeySaving(true);715 const res = await sendMessage({716 type: "SET_PASSKEY_PROVIDER_ENABLED",717 payload: { enabled: !current.enabled },718 });719 setPasskeySaving(false);720 if ((res as { ok: boolean }).ok) {721 setPasskeyState(722 (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null723 );724 }725 }726727 async function handleCheckAllBreaches() {728 setCheckingBreaches(true);729 setBreachResult(null);730 const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });731 setCheckingBreaches(false);732 if ((res as { ok: boolean }).ok) {733 const data = (res as { ok: boolean; data?: Record<string, number> }).data ?? {};734 const breached = Object.values(data).filter((count) => count > 0).length;735 setBreachResult(736 breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean"737 );738 props.onBreachCheckDone();739 }740 }741742743 async function handleSetupDuress() {744 const mainPw = duressMainPassword().trim();745 const duressPw = duressPassword().trim();746 const confirmPw = duressConfirm().trim();747748 if (!mainPw) { setDuressError("enter your main password to confirm"); return; }749 if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; }750 if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; }751 if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; }752753 setDuressSaving(true);754 setDuressError(null);755756 const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };757 if (!verifyRes.ok || !verifyRes.data) {758 setDuressError("incorrect main password");759 setDuressSaving(false);760 return;761 }762763 const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string };764 setDuressSaving(false);765 if (res.ok) {766 setDuressEnabled(true);767 setShowDuressSetup(false);768 setDuressPassword("");769 setDuressConfirm("");770 setDuressMainPassword("");771 setDuressSuccess("Duress password set. If entered, a decoy vault will be shown.");772 setTimeout(() => setDuressSuccess(null), 5000);773 } else {774 setDuressError(775 res.error === "session_expired"776 ? "Vault session expired — lock and unlock Peach, then try again"777 : res.error ?? "failed to set duress password"778 );779 }780 }781782 async function handleClearDeclinedCards() {783 await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } });784 setDeclinedCardCount(0);785 }786787 async function handleCountDuplicates() {788 setDedupRunning(true);789 setDedupDone(null);790 const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number };791 setDedupRunning(false);792 if (res.ok) setDedupCount(res.data ?? 0);793 }794795 async function handleDeduplicate() {796 setDedupRunning(true);797 const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number };798 setDedupRunning(false);799 if (res.ok) {800 setDedupDone(res.data ?? 0);801 setDedupCount(0);802 }803 }804805 async function handleDisableDuress() {806 const mainPw = duressMainPassword().trim();807 if (!mainPw) { setDuressError("enter your main password to confirm"); return; }808809 setDuressSaving(true);810 setDuressError(null);811812 const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };813 if (!verifyRes.ok || !verifyRes.data) {814 setDuressError("incorrect main password");815 setDuressSaving(false);816 return;817 }818819 const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string };820 setDuressSaving(false);821 if (res.ok) {822 setDuressEnabled(false);823 setShowDuressSetup(false);824 setDuressMainPassword("");825 setDuressSuccess("Duress password disabled.");826 setTimeout(() => setDuressSuccess(null), 4000);827 } else {828 setDuressError(res.error ?? "failed to disable duress password");829 }830 }831832 async function handleSubmitFeedback() {833 const message = sanitizeFeedbackMessage(fbMessage());834 if (!message) return;835836 setFeedbackState('sending');837838 try {839 const hasDataPermission = await requestFeedbackDataPermission();840 if (!hasDataPermission) {841 setFeedbackState('idle');842 return;843 }844845 const category = FEEDBACK_CATEGORIES.includes(fbCategory())846 ? fbCategory()847 : FEEDBACK_CATEGORIES[0];848 const res = await fetch('https://api.web3forms.com/submit', {849 method: 'POST',850 headers: { 'Content-Type': 'application/json' },851 body: JSON.stringify({852 access_key: WEB3FORMS_KEY,853 subject: `Peach Feedback - ${category}`,854 message,855 from_name: 'Peach User',856 857 botcheck: '',858 }),859 });860861 if (!res.ok) throw new Error(`HTTP ${res.status}`);862 const data = await res.json() as { success: boolean; message?: string };863 if (!data.success) throw new Error(data.message ?? 'unknown error');864865 setFeedbackState('sent');866 setFbMessage('');867 setFbCategory(FEEDBACK_CATEGORIES[0]);868 } catch (err) {869 console.error('[feedback]', err);870 setFeedbackState('error');871 setTimeout(() => setFeedbackState('idle'), 4000);872 }873 }874875 function renderSecurity() {876 return (877 <div class="peach-settings-body">878 <div class="peach-settings-group">879 <p class="peach-settings-sub-label">LOCKING</p>880 <div class="peach-settings-row">881 <span>Lock on close</span>882 <button883 class={`peach-toggle ${props.lockOnClose ? "is-on" : ""}`}884 onClick={() => void props.onLockOnCloseChange(!props.lockOnClose)}885 aria-label="Toggle lock on close"886 >887 <span class="peach-toggle-knob"></span>888 </button>889 </div>890 <div class="peach-settings-row">891 <span>Auto-lock after</span>892 <select893 class="peach-settings-select"894 value={String(props.autoLockMinutes)}895 onChange={(e) => void props.onAutoLockMinutesChange(Number(e.currentTarget.value))}896 >897 <option value="1">1 minute</option>898 <option value="5">5 minutes</option>899 <option value="15">15 minutes</option>900 <option value="30">30 minutes</option>901 <option value="0">Never</option>902 </select>903 </div>904905 <Show when={isNativePlatform() && biometricSupported()}>906 <p class="peach-settings-sub-label">BIOMETRICS</p>907 <div class="peach-settings-row">908 <span>Unlock with biometrics</span>909 <button910 class={`peach-toggle ${biometricStored() ? "is-on" : ""}`}911 onClick={() => void (biometricStored() ? handleDisableBiometrics() : handleEnableBiometrics())}912 disabled={biometricBusy()}913 aria-label="Toggle biometric unlock"914 >915 <span class="peach-toggle-knob"></span>916 </button>917 </div>918 <Show when={biometricError()}>919 <div class="peach-settings-row peach-settings-row--meta">920 <span class="peach-hint peach-hint-error">{biometricError()}</span>921 </div>922 </Show>923 </Show>924925 <p class="peach-settings-sub-label">BREACH PROTECTION</p>926 <div class="peach-settings-row">927 <span>Breach monitoring</span>928 <button929 class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}930 onClick={() => void props.onBreachMonitoringChange(!props.breachMonitoring)}931 aria-label="Toggle breach monitoring"932 >933 <span class="peach-toggle-knob"></span>934 </button>935 </div>936 <div class="peach-settings-row peach-settings-row--meta">937 <span>Check all passwords</span>938 <button939 class="peach-btn peach-btn--subtle peach-btn--sm"940 onClick={() => void handleCheckAllBreaches()}941 disabled={checkingBreaches()}942 >943 {checkingBreaches() ? "checking..." : "run"}944 </button>945 </div>946 <Show when={breachResult()}>947 <div class="peach-settings-row peach-settings-row--meta">948 <span>Last check</span>949 <span class="peach-settings-value">{breachResult()}</span>950 </div>951 </Show>952953 <p class="peach-settings-sub-label">MASTER PASSWORD</p>954 <div class="peach-settings-row peach-settings-row--meta">955 <span>Master password</span>956 <button957 class="peach-btn peach-btn--subtle peach-btn--sm"958 onClick={() => { setShowChangePw((v) => !v); setChangePwError(null); }}959 >960 {showChangePw() ? "cancel" : "change"}961 </button>962 </div>963 <Show when={showChangePw()}>964 <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">965 <input966 class="peach-settings-input"967 type="password"968 placeholder="current password"969 value={changePwCurrent()}970 onInput={(e) => setChangePwCurrent((e.target as HTMLInputElement).value)}971 autocomplete="current-password"972 />973 <input974 class="peach-settings-input"975 type="password"976 placeholder="new password (min 12 chars)"977 value={changePwNew()}978 onInput={(e) => setChangePwNew((e.target as HTMLInputElement).value)}979 autocomplete="new-password"980 />981 <input982 class="peach-settings-input"983 type="password"984 placeholder="confirm new password"985 value={changePwConfirm()}986 onInput={(e) => setChangePwConfirm((e.target as HTMLInputElement).value)}987 autocomplete="new-password"988 onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }}989 />990 <Show when={changePwError()}>991 <p class="peach-hint peach-hint-error" style="margin:0">{changePwError()}</p>992 </Show>993 <Show when={changePwSuccess()}>994 <p class="peach-hint" style="margin:0;color:var(--peach-accent)">password changed ✓</p>995 </Show>996 <button997 class="peach-btn peach-btn--accent peach-btn--sm"998 style="align-self:flex-start"999 onClick={() => void handleChangePassword()}1000 disabled={changePwSaving() || !changePwCurrent() || !changePwNew() || !changePwConfirm()}1001 >1002 {changePwSaving() ? "saving…" : "save new password"}1003 </button>1004 </div>1005 </Show>10061007 {}1008 <div class="peach-settings-row peach-settings-row--meta">1009 <span>Recovery words</span>1010 <button1011 class="peach-btn peach-btn--subtle peach-btn--sm"1012 onClick={() => {1013 if (showViewWords()) { closeViewWords(); } else { setShowViewWords(true); }1014 }}1015 >1016 {showViewWords() ? "cancel" : "view"}1017 </button>1018 </div>1019 <Show when={showViewWords()}>1020 <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1021 <Show1022 when={viewedWords()}1023 fallback={1024 <>1025 <p class="peach-hint" style="margin:0;color:var(--peach-t2)">1026 Enter your master password to reveal your recovery words.1027 </p>1028 <input1029 class="peach-settings-input"1030 type="password"1031 placeholder="master password"1032 value={viewWordsPassword()}1033 onInput={(e) => setViewWordsPassword((e.target as HTMLInputElement).value)}1034 autocomplete="current-password"1035 onKeyDown={(e) => { if (e.key === "Enter") void handleViewRecoveryWords(); }}1036 />1037 <Show when={viewWordsError()}>1038 <p class="peach-hint peach-hint-error" style="margin:0">{viewWordsError()}</p>1039 </Show>1040 <button1041 class="peach-btn peach-btn--accent peach-btn--sm"1042 style="align-self:flex-start"1043 onClick={() => void handleViewRecoveryWords()}1044 disabled={viewWordsBusy() || !viewWordsPassword()}1045 >1046 {viewWordsBusy() ? "checking…" : "reveal recovery words"}1047 </button>1048 </>1049 }1050 >1051 <div class="peach-recovery-words-grid">1052 <For each={viewedWords()!}>1053 {(word, i) => (1054 <span class="peach-recovery-word"><b>{i() + 1}</b> {word}</span>1055 )}1056 </For>1057 </div>1058 <button1059 class="peach-btn peach-btn--subtle peach-btn--sm"1060 style="align-self:flex-start"1061 onClick={closeViewWords}1062 >done</button>1063 </Show>1064 </div>1065 </Show>10661067 <p class="peach-settings-sub-label">1068 DURESS PASSWORD1069 <Show when={!isPro()}>1070 <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>1071 </Show>1072 </p>1073 <div class="peach-settings-row peach-settings-row--top">1074 <div class="peach-settings-row-main">1075 <span>1076 Duress password1077 <Show when={!isPro()}>1078 <span class="peach-pro-badge">Pro</span>1079 </Show>1080 </span>1081 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">1082 If entered under coercion, shows a decoy empty vault instead of your real one.1083 </span>1084 </div>1085 <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>1086 {duressEnabled() ? "on" : "off"}1087 </div>1088 </div>10891090 <Show when={duressEnabled() && !showDuressSetup()}>1091 <div class="peach-settings-row peach-settings-row--meta">1092 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1093 If someone forces you to unlock Peach, enter the duress password instead. A decoy empty vault opens; your real vault stays hidden. The encrypted storage is identical in size whether a hidden vault exists or not, so the hidden vault cannot be proven or disproven without both passwords. This protects against a coercer who demands your password — not against device malware or someone who already has your device unlocked.1094 </span>1095 </div>1096 </Show>10971098 <Show when={duressSuccess()}>1099 <div class="peach-settings-row peach-settings-row--meta">1100 <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>1101 </div>1102 </Show>11031104 <Show when={!showDuressSetup()}>1105 <div class="peach-settings-row peach-settings-row--meta">1106 <button1107 class="peach-btn peach-btn--subtle peach-btn--sm"1108 onClick={() => {1109 1110 1111 1112 1113 1114 if (!isPro() && !duressEnabled()) {1115 props.onProGate?.("duress_vault");1116 setActiveCard("pro");1117 return;1118 }1119 setShowDuressSetup(true);1120 setDuressError(null);1121 setDuressSuccess(null);1122 setDuressPassword("");1123 setDuressConfirm("");1124 setDuressMainPassword("");1125 }}1126 >1127 {duressEnabled() ? "Change / disable" : "Set up"}1128 </button>1129 </div>1130 </Show>11311132 <Show when={showDuressSetup()}>1133 <div class="peach-duress-form">1134 <label class="peach-label">main password (to confirm)</label>1135 <input1136 class="peach-settings-input"1137 type="password"1138 placeholder="your current master password"1139 autocomplete="current-password"1140 value={duressMainPassword()}1141 onInput={(e) => setDuressMainPassword(e.currentTarget.value)}1142 />1143 <Show when={!duressEnabled()}>1144 <label class="peach-label">new duress password</label>1145 <input1146 class="peach-settings-input"1147 type="password"1148 placeholder="duress password (min 8 chars)"1149 autocomplete="new-password"1150 value={duressPassword()}1151 onInput={(e) => setDuressPassword(e.currentTarget.value)}1152 />1153 <label class="peach-label">confirm duress password</label>1154 <input1155 class="peach-settings-input"1156 type="password"1157 placeholder="confirm duress password"1158 autocomplete="new-password"1159 value={duressConfirm()}1160 onInput={(e) => setDuressConfirm(e.currentTarget.value)}1161 />1162 </Show>11631164 <Show when={duressError()}>1165 <p class="peach-hint peach-hint-error">{duressError()}</p>1166 </Show>11671168 <div class="peach-duress-actions">1169 <button1170 class="peach-btn peach-btn--subtle peach-btn--sm"1171 onClick={() => { setShowDuressSetup(false); setDuressError(null); }}1172 >1173 cancel1174 </button>1175 <Show when={duressEnabled()}>1176 <button1177 class="peach-btn peach-btn--ghost peach-btn--sm"1178 style="color:var(--peach-breach)"1179 onClick={() => void handleDisableDuress()}1180 disabled={duressSaving()}1181 >1182 {duressSaving() ? "disabling..." : "disable duress"}1183 </button>1184 </Show>1185 <Show when={!duressEnabled()}>1186 <button1187 class="peach-btn peach-btn--accent peach-btn--sm"1188 onClick={() => void handleSetupDuress()}1189 disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}1190 >1191 {duressSaving() ? "saving..." : "save"}1192 </button>1193 </Show>1194 </div>1195 </div>1196 </Show>11971198 <p class="peach-settings-sub-label">1199 CANARY ENTRIES1200 <Show when={!isPro()}>1201 <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>1202 </Show>1203 </p>1204 <div class="peach-settings-row peach-settings-row--top">1205 <div class="peach-settings-row-main">1206 <span>1207 Canary entries1208 <Show when={!isPro()}>1209 <span class="peach-pro-badge">Pro</span>1210 </Show>1211 </span>1212 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1213 Silently pings a webhook when an entry is accessed. Configure canary on any entry in your vault.1214 </span>1215 </div>1216 <span class="peach-settings-value" style="flex-shrink:0">1217 {canaryEntries().length > 0 ? `${canaryEntries().length} active` : "none"}1218 </span>1219 </div>1220 <Show when={canaryEntries().length > 0}>1221 <For each={canaryEntries()}>1222 {(entry) => (1223 <div class="peach-settings-row peach-settings-row--meta">1224 <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0">{entry.title}</span>1225 <button1226 class="peach-btn peach-btn--subtle peach-btn--sm"1227 onClick={() => void handleTestCanary(entry)}1228 disabled={canaryTestingId() === entry.id}1229 style="flex-shrink:0"1230 >1231 {canaryTestingId() === entry.id1232 ? "sending…"1233 : canaryTestResult()?.id === entry.id1234 ? (canaryTestResult()!.ok ? "sent ✓" : "failed ✗")1235 : "test"}1236 </button>1237 </div>1238 )}1239 </For>1240 </Show>12411242 <p class="peach-settings-sub-label">PASSKEYS</p>1243 <div class="peach-settings-row peach-settings-row--top">1244 <div class="peach-settings-row-main">1245 <span>Intercept passkey requests</span>1246 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">1247 Peach can handle passkey sign-in and creation on supported Chromium browsers.1248 </span>1249 </div>1250 <button1251 class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}1252 onClick={() => void handleTogglePasskeys()}1253 aria-label="Toggle passkey interception"1254 disabled={!passkeyState()?.supported || passkeySaving()}1255 >1256 <span class="peach-toggle-knob"></span>1257 </button>1258 </div>1259 <div class="peach-settings-row peach-settings-row--meta">1260 <span>Status</span>1261 <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>1262 </div>1263 <div class="peach-settings-row peach-settings-row--meta">1264 <span>Browser</span>1265 <span class="peach-settings-value">1266 {passkeyState()?.browserLabel ?? "Unknown"}1267 {passkeyState()?.supported ? " ✓" : " (interception unavailable)"}1268 </span>1269 </div>1270 <div class="peach-info-notice" style="margin-top:8px">1271 Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.1272 </div>1273 <Show when={passkeyState()?.conflict}>1274 <div class="peach-settings-row peach-settings-row--meta">1275 <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>1276 </div>1277 </Show>1278 </div>1279 </div>1280 );1281 }12821283 function renderVault() {1284 return (1285 <div class="peach-settings-body">1286 <div class="peach-settings-group">1287 <p class="peach-settings-sub-label">ENTRIES</p>1288 <div class="peach-settings-row peach-settings-row--meta">1289 <span>Entries</span>1290 <span class="peach-settings-value">{props.entriesCount}</span>1291 </div>1292 <div class="peach-settings-row peach-settings-row--meta">1293 <span>Duplicates</span>1294 <Show when={dedupDone() !== null} fallback={1295 <button1296 class="peach-btn peach-btn--subtle peach-btn--sm"1297 onClick={() => void handleCountDuplicates()}1298 disabled={dedupRunning()}1299 >1300 {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}1301 </button>1302 }>1303 <span class="peach-settings-value" style="color:var(--peach-sync-ok)">1304 {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}1305 </span>1306 </Show>1307 </div>1308 <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>1309 <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">1310 <div class="peach-restore-warning">1311 {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.1312 </div>1313 <div style="display:flex;gap:6px">1314 <button1315 class="peach-btn peach-btn--subtle peach-btn--sm"1316 onClick={() => setDedupCount(null)}1317 >1318 cancel1319 </button>1320 <button1321 class="peach-btn peach-btn--accent peach-btn--sm"1322 onClick={() => void handleDeduplicate()}1323 disabled={dedupRunning()}1324 >1325 {dedupRunning() ? "removing..." : "remove duplicates"}1326 </button>1327 </div>1328 </div>1329 </Show>13301331 <p class="peach-settings-sub-label">MAINTENANCE</p>1332 <div class="peach-settings-row peach-settings-row--meta">1333 <span>Trash auto-delete</span>1334 <select1335 class="peach-settings-select"1336 value={String(props.trashRetentionDays)}1337 onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}1338 >1339 <option value="7">7 days</option>1340 <option value="30">30 days</option>1341 <option value="90">90 days</option>1342 <option value="0">Never</option>1343 </select>1344 </div>1345 <div class="peach-settings-row peach-settings-row--meta">1346 <span>Advanced Vault Health</span>1347 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>1348 open1349 </button>1350 </div>1351 <Show when={declinedCardCount() > 0}>1352 <div class="peach-settings-row peach-settings-row--meta">1353 <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>1354 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>1355 Clear1356 </button>1357 </div>1358 </Show>13591360 <p class="peach-settings-sub-label">DATA</p>1361 <div class="peach-settings-row peach-settings-row--meta">1362 <span>Import passwords</span>1363 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>1364 open1365 </button>1366 </div>1367 <div class="peach-settings-row peach-settings-row--meta">1368 <span>Export backup</span>1369 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onExport}>1370 open1371 </button>1372 </div>1373 <div class="peach-settings-row peach-settings-row--meta">1374 <span>1375 Peach Codex1376 <Show when={!isPro()}>1377 <span class="peach-pro-badge">Pro</span>1378 </Show>1379 </span>1380 <button1381 class="peach-btn peach-btn--subtle peach-btn--sm"1382 onClick={() => props.onOpenCodexGenerate?.()}1383 >1384 open1385 </button>1386 </div>1387 {1388}1389 <div class="peach-settings-row peach-settings-row--meta">1390 <span>Restore from Codex</span>1391 <button1392 class="peach-btn peach-btn--subtle peach-btn--sm"1393 onClick={() => props.onOpenCodexRestore?.()}1394 >1395 open1396 </button>1397 </div>13981399 <span class="peach-settings-sub-label">VAULT HISTORY</span>1400 <Show when={historyLoading()}>1401 <p class="peach-settings-hint">Loading…</p>1402 </Show>1403 <Show when={!historyLoading() && historySnapshots().length === 0}>1404 <p class="peach-settings-hint">1405 No snapshots yet. Peach saves automatically before every change —1406 saves, syncs, and imports.1407 </p>1408 </Show>1409 <Show when={!historyLoading() && historySnapshots().length > 0}>1410 <p class="peach-settings-hint">1411 Restoring locks your vault. Unlock with the password that1412 encrypted that snapshot to confirm.1413 </p>1414 <div class="peach-history-list">1415 <For each={historySnapshots().slice(0, historyShowAll() ? undefined : 3)}>1416 {(snap) => (1417 <div class="peach-history-row">1418 <div class="peach-history-meta">1419 <span class="peach-history-time">1420 {formatSnapshotTime(snap.savedAt)}1421 </span>1422 <span class="peach-history-sub">1423 {[1424 snap.entryCount !== null1425 ? `up to ${snap.entryCount} ${snap.entryCount === 1 ? "entry" : "entries"}`1426 : null,1427 reasonLabel(snap.reason),1428 ]1429 .filter(Boolean)1430 .join(" · ")}1431 </span>1432 </div>1433 <button1434 class={`peach-btn peach-btn--subtle peach-btn--sm peach-history-btn${1435 historyRestoring() === snap.id ? " is-busy" : ""1436 }`}1437 onClick={() => void handleRestoreVaultSnapshot(snap.id)}1438 disabled={historyRestoring() !== null}1439 >1440 <Show when={historyRestoring() === snap.id} fallback="Restore">1441 …1442 </Show>1443 </button>1444 </div>1445 )}1446 </For>1447 </div>1448 <Show when={historySnapshots().length > 3}>1449 <button1450 class="peach-settings-advanced-toggle"1451 onClick={() => setHistoryShowAll((v) => !v)}1452 aria-expanded={historyShowAll()}1453 >1454 <span class="peach-settings-hint" style="margin:0">1455 {historyShowAll() ? "show less" : `${historySnapshots().length - 3} older`}1456 </span>1457 <span class="peach-settings-advanced-chevron">1458 {historyShowAll() ? "▲" : "▼"}1459 </span>1460 </button>1461 </Show>1462 <p class="peach-history-footer">1463 {historySnapshots().length} of 10 snapshots1464 </p>1465 </Show>14661467 <button1468 class="peach-settings-advanced-toggle"1469 onClick={() => setShowVaultAdvanced((v) => !v)}1470 aria-expanded={showVaultAdvanced()}1471 >1472 <span class="peach-settings-sub-label" style="margin:0">ADVANCED</span>1473 <span class="peach-settings-advanced-chevron">1474 {showVaultAdvanced() ? "▲" : "▼"}1475 </span>1476 </button>14771478 <Show when={showVaultAdvanced()}>1479 <div class="peach-danger-card">1480 <div class="peach-danger-card-head">1481 <span class="peach-danger-card-glyph" aria-hidden="true">⟳</span>1482 <h3 class="peach-danger-card-title">Replace vault from backup</h3>1483 </div>1484 <p class="peach-danger-card-copy">1485 Wipes the current vault and restores from a backup file.1486 Recoverable only if your backup is good.1487 </p>1488 <button1489 class="peach-danger-card-button"1490 onClick={() => props.onOpenFileImport?.()}1491 >1492 Choose backup file1493 </button>1494 </div>14951496 <Show when={props.onOpenPanic}>1497 <div class="peach-danger-card peach-danger-card--hazard">1498 <div class="peach-danger-card-head">1499 <span class="peach-danger-card-glyph" aria-hidden="true">⚠</span>1500 <h3 class="peach-danger-card-title">Destroy vault</h3>1501 </div>1502 <p class="peach-danger-card-copy">1503 Permanently wipes every entry, setting, and license on this1504 device. There is no recovery.1505 </p>1506 <button1507 class="peach-danger-card-button"1508 onClick={() => props.onOpenPanic?.()}1509 >1510 Begin destruction1511 </button>1512 </div>1513 </Show>1514 </Show>1515 </div>1516 </div>1517 );1518 }15191520 function renderSync() {1521 return (1522 <div class="peach-settings-body">1523 <div class="peach-settings-group">1524 <p class="peach-settings-sub-label">PAIRING</p>1525 <div class="peach-settings-row peach-settings-row--top">1526 <div class="peach-settings-row-main">1527 <span>1528 Peach Sync1529 <Show when={!isPro()}>1530 <span class="peach-pro-badge">Pro</span>1531 </Show>1532 </span>1533 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1534 Pair nearby devices by chirp and keep encrypted vault blobs in step without a central account.1535 </span>1536 </div>1537 <span class={`stk-pip ${isPro() ? "is-on" : ""}`} aria-hidden="true" />1538 </div>1539 <div class="peach-settings-row peach-settings-row--meta">1540 <span>Status</span>1541 <span class="peach-settings-value">{isPro() ? "Ready" : "Requires Pro"}</span>1542 </div>15431544 {}1545 <p class="peach-settings-sub-label" style="margin-top:10px">PAIR A DEVICE</p>1546 <button1547 class="peach-pair-method"1548 disabled={!isPro()}1549 onClick={() => props.onOpenSyncPairing?.("chirp")}1550 >1551 <span class="peach-pair-method-icon" aria-hidden="true">1552 <svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">1553 <path d="M3 8 L6 8 L10.5 4 L10.5 16 L6 12 L3 12 Z" />1554 <path d="M14 7.5 Q15.6 10 14 12.5" />1555 <path d="M16.4 5.5 Q19 10 16.4 14.5" opacity="0.5" />1556 </svg>1557 </span>1558 <span class="peach-pair-method-text">1559 <span class="peach-pair-method-title">Same room</span>1560 <span class="peach-pair-method-sub">Pair a nearby phone or browser by chirp</span>1561 </span>1562 </button>1563 <button1564 class="peach-pair-method"1565 style="margin-top:8px"1566 disabled={!isPro()}1567 onClick={() => props.onOpenSyncPairing?.("desktop")}1568 >1569 <span class="peach-pair-method-icon" aria-hidden="true">1570 <svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">1571 <rect x="2" y="4" width="16" height="10" rx="1.4" />1572 <path d="M7 17 L13 17" />1573 <path d="M9 14 L9 17 M11 14 L11 17" />1574 </svg>1575 </span>1576 <span class="peach-pair-method-text">1577 <span class="peach-pair-method-title">Enter a code</span>1578 <span class="peach-pair-method-sub">Type the code from your desktop sync helper</span>1579 </span>1580 </button>15811582 {}1583 <Show when={pairedDevices().length > 0}>1584 <p class="peach-settings-sub-label" style="margin-top:12px">PAIRED DEVICES</p>1585 <For each={pairedDevices()}>1586 {(d) => (1587 <div class="peach-settings-row peach-settings-row--top">1588 <div class="peach-settings-row-main">1589 <span>{d.deviceName ?? "Unnamed device"}</span>1590 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1591 {syncRelativeTime(d.lastSyncedAt)}{isSyncStale(d) ? " · not seen recently" : ""}1592 </span>1593 </div>1594 <Show1595 when={confirmUnpair() === d.deviceId}1596 fallback={1597 <button1598 class="peach-btn peach-btn--subtle peach-btn--sm"1599 onClick={() => setConfirmUnpair(d.deviceId)}1600 >remove</button>1601 }1602 >1603 <div style="display:flex;gap:6px;flex-shrink:0">1604 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => setConfirmUnpair(null)}>cancel</button>1605 <button1606 class="peach-btn peach-btn--sm"1607 style="background:var(--peach-breach);border-color:var(--peach-breach)"1608 onClick={() => void unpairDevice(d.deviceId)}1609 >remove</button>1610 </div>1611 </Show>1612 </div>1613 )}1614 </For>1615 <div class="peach-settings-row peach-settings-row--meta">1616 <span class="peach-settings-value" style="color:var(--peach-t2)">{syncFlash() ?? ""}</span>1617 <button1618 class="peach-btn peach-btn--subtle peach-btn--sm"1619 disabled={syncStatus() === "syncing"}1620 onClick={() => void syncNow()}1621 >{syncStatus() === "syncing" ? "syncing…" : "sync now"}</button>1622 </div>1623 </Show>1624 {}1625 <Show when={pendingVouched().length > 0}>1626 <p class="peach-settings-sub-label">1627 PENDING DEVICES1628 <span class="peach-pro-badge" style="background:#C8A840;color:#1a1a1a">{pendingVouched().length}</span>1629 </p>1630 <For each={pendingVouched()}>1631 {(dev) => (1632 <div class="peach-settings-row peach-settings-row--top">1633 <div class="peach-settings-row-main">1634 <span>{dev.deviceName}</span>1635 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1636 Was added by another of your devices. Allow it to sync with this vault?1637 </span>1638 </div>1639 <div style="display:flex;gap:6px;flex-shrink:0">1640 <button1641 class="peach-btn peach-btn--sm"1642 disabled={vouchedBusy() === dev.deviceId}1643 onClick={() => void confirmVouched(dev.deviceId)}1644 >Allow</button>1645 <button1646 class="peach-btn peach-btn--subtle peach-btn--sm"1647 disabled={vouchedBusy() === dev.deviceId}1648 onClick={() => void rejectVouched(dev.deviceId)}1649 >Reject</button>1650 </div>1651 </div>1652 )}1653 </For>1654 </Show>16551656 <div class="peach-info-notice peach-info-notice--note" style="margin-top:8px">1657 Sync uses the same zero-knowledge vault format as backup and restore; pairing authorizes a device, not a cloud account.1658 </div>16591660 {}1661 <p class="peach-settings-sub-label">RECOVERY</p>1662 <Show1663 when={rotateConfirmOpen()}1664 fallback={1665 <div class="peach-settings-row peach-settings-row--top">1666 <div class="peach-settings-row-main">1667 <span>Rotate sync identity</span>1668 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1669 Use this if a paired device was lost or stolen. Re-keys the vault so the old device's key is useless.1670 </span>1671 </div>1672 <button1673 class="peach-btn peach-btn--subtle peach-btn--sm"1674 style="color:var(--peach-breach);border-color:color-mix(in srgb,var(--peach-breach) 40%,transparent)"1675 onClick={() => { setRotateError(null); setRotateConfirmOpen(true); }}1676 >rotate</button>1677 </div>1678 }1679 >1680 <div class="peach-info-notice" style="margin-top:8px;border-color:color-mix(in srgb,var(--peach-breach) 40%,transparent)">1681 This will disconnect all paired devices — they will need to pair again — and your 12 recovery words will change. You'll be shown the new words next.1682 <Show when={rotateError()}>1683 <div style="color:var(--peach-breach);margin-top:6px">{rotateError()}</div>1684 </Show>1685 <div style="display:flex;gap:6px;margin-top:10px">1686 <button1687 class="peach-btn peach-btn--subtle peach-btn--sm"1688 disabled={rotating()}1689 onClick={() => setRotateConfirmOpen(false)}1690 >Cancel</button>1691 <button1692 class="peach-btn peach-btn--sm"1693 style="background:var(--peach-breach);border-color:var(--peach-breach)"1694 disabled={rotating()}1695 onClick={() => void doRotateSyncIdentity()}1696 >{rotating() ? "Rotating…" : "Rotate sync identity"}</button>1697 </div>1698 </div>1699 </Show>1700 </div>1701 </div>1702 );1703 }17041705 function renderAppearance() {1706 const sizeOptions: Array<{ id: "medium" | "large"; label: string }> = [1707 { id: "medium", label: "Default" },1708 { id: "large", label: "Large" },1709 ];1710 return (1711 <div class="peach-settings-body">1712 <div class="peach-settings-group">1713 <p class="peach-settings-sub-label">THEME</p>1714 <div class="peach-settings-row peach-settings-row--meta">1715 <span>Theme</span>1716 <button1717 class="peach-btn peach-btn--subtle peach-btn--sm"1718 onClick={() =>1719 props.onThemeChange(props.theme === "dark" ? "light" : "dark")1720 }1721 >1722 {props.theme === "dark" ? "Switch to light" : "Switch to dark"}1723 </button>1724 </div>17251726 <p class="peach-settings-sub-label">ACCENT</p>1727 <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">1728 <span>Accent</span>1729 <div class="peach-accent-picker">1730 <For each={ACCENT_COLORS}>1731 {(color) => (1732 <button1733 class="peach-accent-swatch peach-swatch"1734 classList={{ "is-active": props.accentColor === color.name }}1735 style={{ background: color.hex }}1736 onClick={() => void props.onAccentChange(color.name)}1737 aria-label={color.name}1738 title={color.name}1739 />1740 )}1741 </For>1742 </div>1743 </div>17441745 <p class="peach-settings-sub-label">TEXT SIZE</p>1746 <div class="peach-settings-row peach-settings-row--meta">1747 <span>Size</span>1748 <div class="peach-pill-group">1749 <For each={sizeOptions}>1750 {(opt) => (1751 <button1752 class="peach-btn peach-btn--ghost peach-btn--sm"1753 classList={{ "is-active": props.textSize === opt.id }}1754 onClick={() => void props.onTextSizeChange(opt.id)}1755 >1756 {opt.label}1757 </button>1758 )}1759 </For>1760 </div>1761 </div>17621763 <p class="peach-settings-sub-label">MOTION</p>1764 <div class="peach-settings-row peach-settings-row--meta">1765 <span>Reduce motion</span>1766 <button1767 class="peach-btn peach-btn--subtle peach-btn--sm"1768 onClick={() => void props.onReduceMotionChange(!props.reduceMotion)}1769 >1770 {props.reduceMotion ? "On" : "Off"}1771 </button>1772 </div>1773 </div>1774 </div>1775 );1776 }17771778 function renderShortcuts() {1779 const [capturingAction, setCapturingAction] = createSignal<ShortcutAction | null>(null);1780 const [conflictMsg, setConflictMsg] = createSignal<string | null>(null);17811782 function startCapture(action: ShortcutAction) {1783 setConflictMsg(null);1784 setCapturingAction(action);1785 }1786 function cancelCapture() {1787 setCapturingAction(null);1788 setConflictMsg(null);1789 }1790 function commitBinding(action: ShortcutAction, binding: Keybinding) {1791 const conflict = findConflict(props.shortcuts, binding, action);1792 if (conflict) {1793 const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict);1794 setConflictMsg(`Already bound to "${other?.label ?? conflict}"`);1795 return;1796 }1797 const next = { ...props.shortcuts, [action]: binding };1798 setCapturingAction(null);1799 setConflictMsg(null);1800 void props.onShortcutsChange(next);1801 }1802 function resetAction(action: ShortcutAction) {1803 const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] };1804 void props.onShortcutsChange(next);1805 }1806 function resetAll() {1807 void props.onShortcutsChange({ ...DEFAULT_BINDINGS });1808 }18091810 1811 1812 function handleCaptureKey(e: KeyboardEvent) {1813 e.preventDefault();1814 e.stopPropagation();1815 if (e.key === "Escape") { cancelCapture(); return; }1816 const binding = keybindingFromEvent(e);1817 if (!binding) return; 1818 const action = capturingAction();1819 if (action) commitBinding(action, binding);1820 }18211822 return (1823 <div class="peach-settings-body">1824 <div class="peach-settings-group peach-shortcuts-group">1825 <p class="peach-settings-sub-label">ENTRY VIEW</p>1826 <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry")}>1827 {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1828 </For>1829 </div>18301831 <div class="peach-settings-group peach-shortcuts-group">1832 <p class="peach-settings-sub-label">ANYWHERE IN POPUP</p>1833 <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global")}>1834 {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1835 </For>1836 </div>18371838 <Show when={conflictMsg()}>1839 <div class="peach-shortcuts-conflict">{conflictMsg()}</div>1840 </Show>18411842 <div class="peach-settings-group" style="display:flex;flex-direction:column;gap:8px">1843 <div class="peach-settings-row peach-settings-row--meta">1844 <span>Inline autofill on page</span>1845 <a1846 class="peach-settings-link"1847 href="chrome://extensions/shortcuts"1848 onClick={(e) => {1849 e.preventDefault();1850 chrome.tabs.create({ url: "chrome://extensions/shortcuts" });1851 }}1852 >1853 ⌘⇧P — customize ↗1854 </a>1855 </div>1856 <button1857 class="peach-btn peach-btn--subtle peach-btn--sm"1858 style="align-self:flex-start"1859 onClick={resetAll}1860 >1861 Reset all to defaults1862 </button>1863 </div>1864 </div>1865 );18661867 function renderRow(1868 desc: typeof SHORTCUT_DESCRIPTORS[number],1869 capturing: () => ShortcutAction | null,1870 onStart: (a: ShortcutAction) => void,1871 onCancel: () => void,1872 onReset: (a: ShortcutAction) => void,1873 onKey: (e: KeyboardEvent) => void,1874 ) {1875 const isActive = () => capturing() === desc.action;1876 const binding = () => props.shortcuts[desc.action];1877 const isCustom = () => {1878 const b = binding();1879 const d = DEFAULT_BINDINGS[desc.action];1880 return b.key !== d.key1881 || Boolean(b.ctrl) !== Boolean(d.ctrl)1882 || Boolean(b.meta) !== Boolean(d.meta)1883 || Boolean(b.shift) !== Boolean(d.shift)1884 || Boolean(b.alt) !== Boolean(d.alt);1885 };1886 return (1887 <div class="peach-shortcut-row">1888 <span class="peach-shortcut-row-label">{desc.label}</span>1889 <button1890 class="peach-shortcut-chip"1891 classList={{ "is-capturing": isActive(), "is-custom": isCustom() }}1892 onClick={() => isActive() ? onCancel() : onStart(desc.action)}1893 onKeyDown={(e) => { if (isActive()) onKey(e); }}1894 ref={(el) => {1895 1896 if (el) {1897 queueMicrotask(() => { if (isActive()) el.focus(); });1898 }1899 }}1900 >1901 {isActive() ? "Press a key…" : formatKeybinding(binding())}1902 </button>1903 <Show when={isCustom() && !isActive()}>1904 <button1905 class="peach-shortcut-reset"1906 title="Reset to default"1907 onClick={() => onReset(desc.action)}1908 >1909 ↺1910 </button>1911 </Show>1912 </div>1913 );1914 }1915 }19161917 const PRO_FEATURES: Array<{1918 feature: ProFeature;1919 name: string;1920 desc: string;1921 }> = [1922 { feature: "sync", name: "Peach Sync", desc: "Encrypted · peer-to-peer" },1923 { feature: "codex", name: "Peach Codex", desc: "Restore from paper" },1924 { feature: "passkeys", name: "Passkeys", desc: "Zero-knowledge WebAuthn" },1925 { feature: "advanced_health", name: "Advanced Vault Health", desc: "Reuse · age · exposure" },1926 ];19271928 const PRO_SECURITY_FEATURES: Array<{1929 feature: ProFeature;1930 name: string;1931 desc: string;1932 }> = [1933 { feature: "canary_entry", name: "Canary entries", desc: "Detects vault compromise" },1934 { feature: "duress_vault", name: "Duress vault", desc: "Decoy under coercion" },1935 ];19361937 const [licenseKey, setLicenseKey] = createSignal<string | null>(null);1938 const [showKey, setShowKey] = createSignal(false);19391940 const [deactivating, setDeactivating] = createSignal(false);19411942 async function handleDeactivateLicense() {1943 setDeactivating(true);1944 await sendMessage({ type: "DEACTIVATE_LICENSE" });1945 setLicenseInfo(null);1946 await props.onLicenseChange?.();1947 setDeactivating(false);1948 }19491950 async function handleTestCanary(entry: {id: string; title: string; canary: Canary}) {1951 setCanaryTestingId(entry.id);1952 setCanaryTestResult(null);1953 const res = await sendMessage({1954 type: "FIRE_CANARY_PING",1955 payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger: "test" },1956 }) as { ok: boolean };1957 setCanaryTestResult({ id: entry.id, ok: res.ok });1958 setCanaryTestingId(null);1959 setTimeout(() => setCanaryTestResult((r) => r?.id === entry.id ? null : r), 3000);1960 }19611962 function renderLicense() {1963 async function onActivate() {1964 await handleActivateLicense();1965 if ((licenseInfo()?.valid ?? false) && licenseInfo()?.plan === "pro") {1966 await props.onLicenseChange?.();1967 }1968 }19691970 return (1971 <div class="peach-settings-body">1972 <Show1973 when={isPro()}1974 fallback={1975 <div class="peach-pro-upgrade">1976 <p class="peach-pro-eyebrow">PEACH PRO · ONE-TIME</p>19771978 <h2 class="peach-pro-headline">1979 Every<em>thing,</em><br />every<em>where.</em>1980 </h2>19811982 <p class="peach-pro-subtitle">1983 One key unlocks every Pro feature — for the lifetime of the app.1984 </p>19851986 <div class="peach-pro-grid">1987 <For each={PRO_FEATURES}>1988 {(item, i) => (1989 <div1990 class="peach-pro-grid-item"1991 classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}1992 >1993 <span class="peach-pro-grid-num">{String(i() + 1).padStart(2, "0")}</span>1994 <span class="peach-pro-grid-name">{item.name}</span>1995 <span class="peach-pro-grid-desc">{item.desc}</span>1996 </div>1997 )}1998 </For>1999 </div>20002001 <p class="peach-pro-section-label">ADVANCED SECURITY</p>2002 <div class="peach-pro-grid">2003 <For each={PRO_SECURITY_FEATURES}>2004 {(item, i) => (2005 <div2006 class="peach-pro-grid-item"2007 classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}2008 >2009 <span class="peach-pro-grid-num">{String(PRO_FEATURES.length + i() + 1).padStart(2, "0")}</span>2010 <span class="peach-pro-grid-name">{item.name}</span>2011 <span class="peach-pro-grid-desc">{item.desc}</span>2012 </div>2013 )}2014 </For>2015 </div>20162017 <div class="peach-pro-upgrade-actions">2018 <p class="peach-pro-pricing">2019 $39 <span class="peach-pro-pricing-sub">once · yours forever</span>2020 </p>2021 <a2022 href="https://buy.polar.sh/polar_cl_IXSFIPojx67rcOTksn8LkH5mnU3osPHtYqBIl1eyuIz"2023 target="_blank"2024 rel="noopener noreferrer"2025 class="peach-pro-upgrade-cta"2026 >2027 Get Peach Pro2028 </a>2029 <div class="peach-pro-key-divider"><span>ALREADY HAVE A KEY</span></div>2030 <div class="peach-pro-key-inline">2031 <input2032 class="peach-pro-key-inline-input"2033 type="text"2034 placeholder="PEACH-XXXX-XXXX-XXXX"2035 value={licenseKeyInput()}2036 onInput={(e) => setLicenseKeyInput(e.currentTarget.value)}2037 onKeyDown={(e) => { if (e.key === "Enter") void onActivate(); }}2038 autocomplete="off"2039 spellcheck={false}2040 />2041 <button2042 class="peach-pro-key-apply"2043 onClick={() => void onActivate()}2044 disabled={activatingLicense() || !licenseKeyInput().trim()}2045 >2046 {activatingLicense() ? "…" : "APPLY"}2047 </button>2048 </div>2049 <Show when={licenseError()}>2050 <p class="peach-pro-key-error">{licenseError()}</p>2051 </Show>2052 </div>2053 </div>2054 }2055 >2056 <div class="peach-pro-active">2057 <div class="peach-pro-active-hero">2058 <button2059 class="peach-pro-active-mark-wrap"2060 type="button"2061 aria-label="Celebrate"2062 onClick={handleProCelebrationClick}2063 >2064 <PeachMark class="peach-pro-active-mark" />2065 <Show when={justActivated()}>2066 <div class="peach-pro-sparks" aria-hidden="true">2067 <span class="peach-pro-spark" style="--spark-x:0px;--spark-y:-54px;--spark-delay:0ms">✦</span>2068 <span class="peach-pro-spark" style="--spark-x:38px;--spark-y:-38px;--spark-delay:50ms">✦</span>2069 <span class="peach-pro-spark" style="--spark-x:54px;--spark-y:0px;--spark-delay:110ms">·</span>2070 <span class="peach-pro-spark" style="--spark-x:38px;--spark-y:38px;--spark-delay:30ms">✦</span>2071 <span class="peach-pro-spark" style="--spark-x:0px;--spark-y:54px;--spark-delay:80ms">·</span>2072 <span class="peach-pro-spark" style="--spark-x:-38px;--spark-y:38px;--spark-delay:130ms">✦</span>2073 <span class="peach-pro-spark" style="--spark-x:-54px;--spark-y:0px;--spark-delay:55ms">·</span>2074 <span class="peach-pro-spark" style="--spark-x:-38px;--spark-y:-38px;--spark-delay:95ms">✦</span>2075 </div>2076 </Show>2077 </button>2078 <h2 class="peach-pro-active-title">Peach Pro</h2>2079 <p class="peach-pro-active-since">{formatActivatedSince(licenseInfo()?.issued)}</p>2080 <Show when={justActivated()}>2081 <p class="peach-pro-thankyou-text" aria-live="polite">thank you ✦</p>2082 </Show>2083 </div>20842085 <Show when={licenseKey()}>2086 <div class="peach-settings-license">2087 <span class="peach-settings-label">LICENSE KEY</span>2088 <div class="peach-settings-license-row">2089 <Show2090 when={showKey()}2091 fallback={2092 <span class="peach-settings-license-key peach-settings-license-key--masked" aria-label="License key hidden">2093 ••••••••••••••••••••••••••••2094 </span>2095 }2096 >2097 <span class="peach-settings-license-key">{licenseKey()!}</span>2098 </Show>2099 <div class="peach-settings-license-actions">2100 <button2101 class="peach-settings-license-copy"2102 title="Copy license key"2103 onClick={() => copyLicenseKey(licenseKey()!)}2104 >2105 {copied() ? "✓" : "copy"}2106 </button>2107 <button2108 class="peach-settings-license-toggle"2109 aria-label={showKey() ? "Hide license key" : "Reveal license key"}2110 title={showKey() ? "Hide" : "Reveal"}2111 onClick={() => setShowKey(v => !v)}2112 >2113 <Show2114 when={showKey()}2115 fallback={2116 <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">2117 <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />2118 <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />2119 </svg>2120 }2121 >2122 <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">2123 <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />2124 <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />2125 <line x1="2" y1="2" x2="12" y2="12" />2126 </svg>2127 </Show>2128 </button>2129 </div>2130 </div>2131 </div>2132 </Show>21332134 <div class="peach-pro-active-features">2135 <For each={[...PRO_FEATURES, ...PRO_SECURITY_FEATURES]}>2136 {(item) => (2137 <div class="peach-pro-active-feature">2138 <span class="peach-pro-active-feature-mark">✦</span>2139 <span class="peach-pro-active-feature-name">{item.name}</span>2140 </div>2141 )}2142 </For>2143 </div>21442145 <button2146 class="peach-pro-deactivate"2147 onClick={() => void handleDeactivateLicense()}2148 disabled={deactivating()}2149 >2150 {deactivating() ? "Deactivating…" : "Deactivate"}2151 </button>2152 </div>2153 </Show>2154 </div>2155 );2156 }21572158 function renderFeedback() {2159 return (2160 <div class="peach-settings-body">2161 <div class="peach-settings-group peach-feedback-group">2162 <Show2163 when={feedbackState() === 'sent'}2164 fallback={2165 <>2166 <p class="peach-feedback-intro">2167 Help us improve Peach. Every report goes directly to our inbox.2168 </p>21692170 <p class="peach-settings-sub-label">TYPE</p>2171 <div class="peach-feedback-chips">2172 <For each={FEEDBACK_CATEGORIES}>2173 {(cat) => {2174 const selected = () => fbCategory() === cat;2175 return (2176 <button2177 type="button"2178 class={`peach-feedback-chip ${selected() ? "is-active" : ""}`}2179 aria-pressed={selected()}2180 onClick={() => setFbCategory(cat)}2181 >2182 {cat}2183 </button>2184 );2185 }}2186 </For>2187 </div>21882189 <p class="peach-settings-sub-label">MESSAGE</p>2190 <textarea2191 class="peach-feedback-textarea"2192 placeholder="Describe the issue or idea..."2193 maxLength={MAX_FEEDBACK_MESSAGE_LENGTH}2194 value={fbMessage()}2195 onInput={(e) => setFbMessage(e.currentTarget.value)}2196 />21972198 <p class="peach-feedback-meta">2199 Version {version} · will be included automatically2200 </p>22012202 <button2203 type="button"2204 class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"2205 disabled={!fbMessage().trim() || feedbackState() === 'sending'}2206 onClick={() => void handleSubmitFeedback()}2207 >2208 <Switch>2209 <Match when={feedbackState() === 'idle'}>Send</Match>2210 <Match when={feedbackState() === 'sending'}>Sending...</Match>2211 <Match when={feedbackState() === 'error'}>Failed - try again</Match>2212 </Switch>2213 </button>22142215 <p class="peach-feedback-anon">2216 Sent anonymously · no account required2217 </p>2218 </>2219 }2220 >2221 <div class="peach-feedback-thanks" role="status" aria-live="polite">2222 <p class="peach-feedback-thanks-title">Thank you</p>2223 <p>2224 Your note is in the queue. Someone will process it soon.2225 </p>2226 </div>2227 </Show>2228 </div>2229 </div>2230 );2231 }22322233 const bodies: StackBodies = {2234 protection: renderSecurity(),2235 vault: renderVault(),2236 sync: renderSync(),2237 appearance: renderAppearance(),2238 shortcuts: renderShortcuts(),2239 pro: renderLicense(),2240 feedback: renderFeedback(),2241 };22422243 return (2244 <Show2245 when={showNewRecoveryWords()}2246 fallback={2247 <div2248 class="peach-overlay-view peach-settings-overlay"2249 classList={{ "ov-enter-right": !props.isExiting, "ov-exit-right": !!props.isExiting }}2250 style="display:flex;flex-direction:column"2251 >2252 <StackDeck2253 active={activeCard()}2254 onActiveChange={setActiveCard}2255 onBack={props.onBack}2256 cards={cards()}2257 bodies={bodies}2258 version={version}2259 isFanning={isFanning()}2260 celebratingCardId={justActivated() ? "pro" : null}2261 isPro={isPro()}2262 />2263 </div>2264 }2265 >2266 <RecoveryWordDisplayView onDone={() => setShowNewRecoveryWords(false)} />2267 </Show>2268 );2269}