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?: "code") => 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 with a code, 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 }288 function deviceTypeLabel(t: PairedDevice["deviceType"]): string {289 switch (t) {290 case "android": return "Android";291 case "ios": return "iPhone";292 case "extension": return "Browser";293 default: return "Device";294 }295 }296297 async function loadPairedDevices() {298 if (!isPro()) return;299 const r = (await sendMessage({ type: "LIST_PAIRED_DEVICES" })) as {300 ok: boolean;301 data?: { devices: PairedDevice[] };302 };303 if (r.ok) setPairedDevices(r.data?.devices ?? []);304 }305 async function unpairDevice(deviceId: string) {306 await sendMessage({ type: "UNPAIR_DEVICE", payload: { deviceId } });307 setConfirmUnpair(null);308 await loadPairedDevices();309 }310 async function syncNow() {311 setSyncStatus("syncing");312 setSyncFlash(null);313 const res = (await sendMessage({314 type: "TRIGGER_SYNC",315 payload: { reason: "manual", force: true },316 })) as { ok: boolean; error?: string };317 setTimeout(async () => {318 const state = (await sendMessage({ type: "GET_SYNC_STATE" })) as {319 ok: boolean;320 data?: { status: "idle" | "syncing" | "error"; entriesReceived?: number; error?: string };321 };322 const s = state.data;323 if (!s) return;324 setSyncStatus(s.status);325 if (s.status === "idle") {326 setSyncFlash((s.entriesReceived ?? 0) > 0 ? `Synced — ${s.entriesReceived} updated` : "Up to date");327 setTimeout(() => setSyncFlash(null), 3000);328 await loadPairedDevices();329 } else if (s.status === "error") {330 setSyncFlash(`Sync failed — ${s.error ?? "no host reachable"}`);331 setTimeout(() => setSyncFlash(null), 4000);332 }333 }, 250);334 if (!res.ok) {335 setSyncStatus("error");336 setSyncFlash(`Sync failed — ${res.error ?? "unknown"}`);337 setTimeout(() => setSyncFlash(null), 4000);338 }339 }340 341 const [rotateConfirmOpen, setRotateConfirmOpen] = createSignal(false);342 const [rotating, setRotating] = createSignal(false);343 const [rotateError, setRotateError] = createSignal<string | null>(null);344345 async function loadPendingVouched() {346 const r = (await sendMessage({ type: "GET_PENDING_VOUCHED_DEVICES" })) as {347 ok: boolean;348 data?: PendingVouched[];349 };350 if (r.ok) setPendingVouched(r.data ?? []);351 }352 async function confirmVouched(deviceId: string) {353 setVouchedBusy(deviceId);354 try {355 await sendMessage({ type: "CONFIRM_VOUCHED_DEVICE", payload: { deviceId } });356 await loadPendingVouched();357 } finally {358 setVouchedBusy(null);359 }360 }361 async function rejectVouched(deviceId: string) {362 setVouchedBusy(deviceId);363 try {364 await sendMessage({ type: "REJECT_VOUCHED_DEVICE", payload: { deviceId } });365 await loadPendingVouched();366 } finally {367 setVouchedBusy(null);368 }369 }370 async function doRotateSyncIdentity() {371 setRotateError(null);372 setRotating(true);373 try {374 const r = (await sendMessage({ type: "ROTATE_SYNC_IDENTITY" })) as {375 ok: boolean;376 error?: string;377 };378 if (!r.ok) {379 setRotateError(r.error ?? "Rotation failed.");380 return;381 }382 setRotateConfirmOpen(false);383 384 385 setShowNewRecoveryWords(true);386 } finally {387 setRotating(false);388 }389 }390391 const [dedupCount, setDedupCount] = createSignal<number | null>(null);392 const [dedupRunning, setDedupRunning] = createSignal(false);393 const [dedupDone, setDedupDone] = createSignal<number | null>(null);394395 const [declinedCardCount, setDeclinedCardCount] = createSignal(0);396397 398 399 400 const [historySnapshots, setHistorySnapshots] = createSignal<VaultSnapshot[]>([]);401 const [historyLoading, setHistoryLoading] = createSignal(false);402 const [historyRestoring, setHistoryRestoring] = createSignal<string | null>(null);403 const [historyShowAll, setHistoryShowAll] = createSignal(false);404405 async function loadVaultHistory() {406 setHistoryLoading(true);407 try {408 const res = await sendMessage({ type: "GET_VAULT_HISTORY" });409 const list = (res as { ok: boolean; data?: VaultSnapshot[] }).data ?? [];410 setHistorySnapshots(list);411 } finally {412 setHistoryLoading(false);413 }414 }415416 async function handleRestoreVaultSnapshot(id: string) {417 if (historyRestoring()) return;418 setHistoryRestoring(id);419 const res = await sendMessage({420 type: "RESTORE_VAULT_SNAPSHOT",421 payload: { snapshotId: id },422 });423 setHistoryRestoring(null);424 if ((res as { ok: boolean }).ok) {425 426 427 props.onBack();428 }429 }430431 function formatSnapshotTime(savedAt: number): string {432 const diff = Date.now() - savedAt;433 if (diff < 60_000) return "Just now";434 if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;435 const d = new Date(savedAt);436 const isToday = new Date().toDateString() === d.toDateString();437 const isYest =438 new Date(Date.now() - 86_400_000).toDateString() === d.toDateString();439 const t = d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });440 if (isToday) return `Today ${t}`;441 if (isYest) return `Yesterday ${t}`;442 return `${d.toLocaleDateString([], { month: "short", day: "numeric" })} ${t}`;443 }444445 function reasonLabel(reason: VaultSnapshot["reason"]): string {446 return (447 {448 local_save: "saved",449 sync_received: "synced",450 import: "imported",451 restore: "restored",452 } as const453 )[reason] ?? reason;454 }455456 const [showVaultAdvanced, setShowVaultAdvanced] = createSignal(false);457458 const [showChangePw, setShowChangePw] = createSignal(false);459 const [changePwCurrent, setChangePwCurrent] = createSignal("");460 const [changePwNew, setChangePwNew] = createSignal("");461 const [changePwConfirm, setChangePwConfirm] = createSignal("");462 const [changePwSaving, setChangePwSaving] = createSignal(false);463 const [changePwError, setChangePwError] = createSignal<string | null>(null);464 const [changePwSuccess] = createSignal(false);465 const [showNewRecoveryWords, setShowNewRecoveryWords] = createSignal(false);466467 468 const [showViewWords, setShowViewWords] = createSignal(false);469 const [viewWordsPassword, setViewWordsPassword] = createSignal("");470 const [viewWordsError, setViewWordsError] = createSignal<string | null>(null);471 const [viewedWords, setViewedWords] = createSignal<string[] | null>(null);472 const [viewWordsBusy, setViewWordsBusy] = createSignal(false);473474 async function handleViewRecoveryWords() {475 setViewWordsError(null);476 if (!viewWordsPassword()) {477 setViewWordsError("Enter your master password.");478 return;479 }480 setViewWordsBusy(true);481 try {482 const r = (await sendMessage({483 type: "GET_RECOVERY_WORDS",484 payload: { password: viewWordsPassword() },485 })) as { ok: boolean; data?: string[]; error?: string };486 if (r.ok) {487 setViewedWords(r.data ?? []);488 setViewWordsPassword("");489 return;490 }491 switch (r.error) {492 case "wrong_password":493 setViewWordsError("Incorrect password");494 break;495 case "password_required":496 setViewWordsError("Enter your master password.");497 break;498 case "recovery_not_setup":499 setViewWordsError("Recovery words aren't set up for this vault yet.");500 break;501 default:502 setViewWordsError("Couldn't retrieve recovery words.");503 }504 } finally {505 setViewWordsBusy(false);506 }507 }508 function closeViewWords() {509 setShowViewWords(false);510 setViewWordsPassword("");511 setViewWordsError(null);512 setViewedWords(null);513 }514515 const [duressEnabled, setDuressEnabled] = createSignal(false);516 const [showDuressSetup, setShowDuressSetup] = createSignal(false);517 const [duressPassword, setDuressPassword] = createSignal("");518 const [duressConfirm, setDuressConfirm] = createSignal("");519 const [duressMainPassword, setDuressMainPassword] = createSignal("");520 const [duressSaving, setDuressSaving] = createSignal(false);521 const [duressError, setDuressError] = createSignal<string | null>(null);522 const [duressSuccess, setDuressSuccess] = createSignal<string | null>(null);523524 const [canaryEntries, setCanaryEntries] = createSignal<Array<{id: string; title: string; canary: Canary}>>([]);525526 const [canaryTestingId, setCanaryTestingId] = createSignal<string | null>(null);527 const [canaryTestResult, setCanaryTestResult] = createSignal<{id: string; ok: boolean} | null>(null);528529 const [fbCategory, setFbCategory] = createSignal("Bug report");530 const [fbMessage, setFbMessage] = createSignal("");531 type FeedbackState = 'idle' | 'sending' | 'sent' | 'error';532 const [feedbackState, setFeedbackState] = createSignal<FeedbackState>('idle');533534 createEffect(() => {535 if (props.upgradeHighlight) {536 setActiveCard("pro");537 }538 });539540 onMount(async () => {541 const [licenseRes, passkeyRes, duressRes, settingsRes, keyRes] = await Promise.all([542 sendMessage({ type: "GET_LICENSE" }),543 sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }),544 sendMessage({ type: "IS_DURESS_ENABLED" }),545 sendMessage({ type: "GET_SETTINGS" }),546 sendMessage({ type: "GET_LICENSE_KEY" }),547 ]);548549 550 551 void loadVaultHistory();552553 554 555 void loadPendingVouched();556 void loadPairedDevices();557 const onBroadcast = (msg: unknown) => {558 if ((msg as { type?: string } | null)?.type === "VOUCHED_DEVICE_PENDING") {559 void loadPendingVouched();560 }561 };562 chrome.runtime?.onMessage?.addListener(onBroadcast);563 onCleanup(() => {564 try { chrome.runtime?.onMessage?.removeListener(onBroadcast); } catch { }565 });566567 if ((licenseRes as { ok: boolean }).ok) {568 setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null);569 }570571 if ((passkeyRes as { ok: boolean }).ok) {572 setPasskeyState(573 (passkeyRes as { ok: boolean; data?: PasskeyProviderState }).data ?? null574 );575 }576577 if ((duressRes as { ok: boolean }).ok) {578 setDuressEnabled((duressRes as { ok: boolean; data?: boolean }).data ?? false);579 }580581 if ((settingsRes as { ok: boolean }).ok) {582 const sData = (settingsRes as { ok: boolean; data?: { declinedCardFingerprints?: string[] } }).data;583 setDeclinedCardCount(sData?.declinedCardFingerprints?.length ?? 0);584 }585586 if ((keyRes as { ok: boolean }).ok) {587 setLicenseKey((keyRes as { ok: boolean; data?: string }).data ?? null);588 }589590 const entriesRes = await sendMessage({ type: "GET_ENTRIES" });591 if ((entriesRes as { ok: boolean }).ok) {592 const entries = (entriesRes as { ok: boolean; data?: Array<{id: string; title: string; canary?: Canary}> }).data ?? [];593 setCanaryEntries(594 entries595 .filter((e) => e.canary?.webhookUrl)596 .map((e) => ({ id: e.id, title: e.title, canary: e.canary! }))597 );598 }599600601 if (isNativePlatform()) {602 const biometric = nativePlugin<{ isAvailable: () => Promise<{ available: boolean }> }>("BiometricPlugin");603 const keystore = nativePlugin<{ hasMasterKey: () => Promise<{ exists: boolean }> }>("KeystorePlugin");604 try {605 if (biometric) {606 const a = await biometric.isAvailable();607 setBiometricSupported(a.available);608 }609 if (keystore) {610 const k = await keystore.hasMasterKey();611 setBiometricStored(k.exists);612 }613 } catch { }614 }615 });616617 async function handleEnableBiometrics() {618 setBiometricBusy(true);619 setBiometricError(null);620 try {621 const keyRes = (await sendMessage({ type: "GET_DERIVED_KEY" })) as {622 ok: boolean;623 data?: { masterKeyHex: string };624 error?: string;625 };626 if (!keyRes.ok || !keyRes.data?.masterKeyHex) {627 setBiometricError(keyRes.error === "vault_locked"628 ? "unlock the vault first"629 : "couldn't read derived key");630 return;631 }632 const keystore = nativePlugin<{ storeMasterKey: (p: { masterKeyHex: string }) => Promise<void> }>("KeystorePlugin");633 if (!keystore) {634 setBiometricError("biometric plugin unavailable");635 return;636 }637 await keystore.storeMasterKey({ masterKeyHex: keyRes.data.masterKeyHex });638 await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: true } });639 setBiometricStored(true);640 } catch (e) {641 setBiometricError(e instanceof Error ? e.message : "failed to enable biometrics");642 } finally {643 setBiometricBusy(false);644 }645 }646647 async function handleDisableBiometrics() {648 setBiometricBusy(true);649 setBiometricError(null);650 try {651 const keystore = nativePlugin<{ clearMasterKey: () => Promise<void> }>("KeystorePlugin");652 if (keystore) await keystore.clearMasterKey();653 await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: false } });654 setBiometricStored(false);655 } catch (e) {656 setBiometricError(e instanceof Error ? e.message : "failed to disable biometrics");657 } finally {658 setBiometricBusy(false);659 }660 }661662 async function handleChangePassword() {663 setChangePwError(null);664 if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; }665 if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; }666 if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; }667 if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; }668 setChangePwSaving(true);669 const res = await sendMessage({670 type: "CHANGE_PASSWORD",671 payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() },672 }) as { ok: boolean; error?: string };673 if (res.ok) {674 setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");675 setShowChangePw(false);676 677 678 679 680 await chrome.storage?.local?.set?.({ peach_recovery_pending: "1" });681 setShowNewRecoveryWords(true);682 } else {683 setChangePwError(res.error ?? "failed to change password");684 }685 setChangePwSaving(false);686 }687688 const [copied, setCopied] = createSignal(false);689690 async function copyLicenseKey(key: string) {691 await navigator.clipboard.writeText(key);692 setCopied(true);693 setTimeout(() => setCopied(false), 2000);694 }695696 async function handleActivateLicense() {697 const key = licenseKeyInput().trim();698 if (!key) return;699 setActivatingLicense(true);700 setLicenseError(null);701 setLicenseSuccess(null);702 const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } });703 setActivatingLicense(false);704 if ((res as { ok: boolean }).ok) {705 const data = (res as { ok: boolean; data?: LicenseInfo }).data;706 if (data?.valid) {707 setLicenseInfo(data);708 setLicenseKey(key);709 setLicenseSuccess("License activated!");710 setLicenseKeyInput("");711 } else {712 setLicenseError("Invalid license key");713 }714 } else {715 setLicenseError("Activation failed");716 }717 }718719 async function handleTogglePasskeys() {720 const current = passkeyState();721 if (!current || !current.supported) return;722 setPasskeySaving(true);723 const res = await sendMessage({724 type: "SET_PASSKEY_PROVIDER_ENABLED",725 payload: { enabled: !current.enabled },726 });727 setPasskeySaving(false);728 if ((res as { ok: boolean }).ok) {729 setPasskeyState(730 (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null731 );732 }733 }734735 async function handleCheckAllBreaches() {736 setCheckingBreaches(true);737 setBreachResult(null);738 const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });739 setCheckingBreaches(false);740 if ((res as { ok: boolean }).ok) {741 const data = (res as { ok: boolean; data?: Record<string, number> }).data ?? {};742 const breached = Object.values(data).filter((count) => count > 0).length;743 setBreachResult(744 breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean"745 );746 props.onBreachCheckDone();747 }748 }749750751 async function handleSetupDuress() {752 const mainPw = duressMainPassword().trim();753 const duressPw = duressPassword().trim();754 const confirmPw = duressConfirm().trim();755756 if (!mainPw) { setDuressError("enter your main password to confirm"); return; }757 if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; }758 if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; }759 if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; }760761 setDuressSaving(true);762 setDuressError(null);763764 const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };765 if (!verifyRes.ok || !verifyRes.data) {766 setDuressError("incorrect main password");767 setDuressSaving(false);768 return;769 }770771 const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string };772 setDuressSaving(false);773 if (res.ok) {774 setDuressEnabled(true);775 setShowDuressSetup(false);776 setDuressPassword("");777 setDuressConfirm("");778 setDuressMainPassword("");779 setDuressSuccess("Duress password set. If entered, a decoy vault will be shown.");780 setTimeout(() => setDuressSuccess(null), 5000);781 } else {782 setDuressError(783 res.error === "session_expired"784 ? "Vault session expired — lock and unlock Peach, then try again"785 : res.error ?? "failed to set duress password"786 );787 }788 }789790 async function handleClearDeclinedCards() {791 await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } });792 setDeclinedCardCount(0);793 }794795 async function handleCountDuplicates() {796 setDedupRunning(true);797 setDedupDone(null);798 const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number };799 setDedupRunning(false);800 if (res.ok) setDedupCount(res.data ?? 0);801 }802803 async function handleDeduplicate() {804 setDedupRunning(true);805 const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number };806 setDedupRunning(false);807 if (res.ok) {808 setDedupDone(res.data ?? 0);809 setDedupCount(0);810 }811 }812813 async function handleDisableDuress() {814 const mainPw = duressMainPassword().trim();815 if (!mainPw) { setDuressError("enter your main password to confirm"); return; }816817 setDuressSaving(true);818 setDuressError(null);819820 const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };821 if (!verifyRes.ok || !verifyRes.data) {822 setDuressError("incorrect main password");823 setDuressSaving(false);824 return;825 }826827 const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string };828 setDuressSaving(false);829 if (res.ok) {830 setDuressEnabled(false);831 setShowDuressSetup(false);832 setDuressMainPassword("");833 setDuressSuccess("Duress password disabled.");834 setTimeout(() => setDuressSuccess(null), 4000);835 } else {836 setDuressError(res.error ?? "failed to disable duress password");837 }838 }839840 async function handleSubmitFeedback() {841 const message = sanitizeFeedbackMessage(fbMessage());842 if (!message) return;843844 setFeedbackState('sending');845846 try {847 const hasDataPermission = await requestFeedbackDataPermission();848 if (!hasDataPermission) {849 setFeedbackState('idle');850 return;851 }852853 const category = FEEDBACK_CATEGORIES.includes(fbCategory())854 ? fbCategory()855 : FEEDBACK_CATEGORIES[0];856 const res = await fetch('https://api.web3forms.com/submit', {857 method: 'POST',858 headers: { 'Content-Type': 'application/json' },859 body: JSON.stringify({860 access_key: WEB3FORMS_KEY,861 subject: `Peach Feedback - ${category}`,862 message,863 from_name: 'Peach User',864 865 botcheck: '',866 }),867 });868869 if (!res.ok) throw new Error(`HTTP ${res.status}`);870 const data = await res.json() as { success: boolean; message?: string };871 if (!data.success) throw new Error(data.message ?? 'unknown error');872873 setFeedbackState('sent');874 setFbMessage('');875 setFbCategory(FEEDBACK_CATEGORIES[0]);876 } catch (err) {877 console.error('[feedback]', err);878 setFeedbackState('error');879 setTimeout(() => setFeedbackState('idle'), 4000);880 }881 }882883 function renderSecurity() {884 return (885 <div class="peach-settings-body">886 <div class="peach-settings-group">887 <p class="peach-settings-sub-label">LOCKING</p>888 <div class="peach-settings-row">889 <span>Lock on close</span>890 <button891 class={`peach-toggle ${props.lockOnClose ? "is-on" : ""}`}892 onClick={() => void props.onLockOnCloseChange(!props.lockOnClose)}893 aria-label="Toggle lock on close"894 >895 <span class="peach-toggle-knob"></span>896 </button>897 </div>898 <div class="peach-settings-row">899 <span>Auto-lock after</span>900 <select901 class="peach-settings-select"902 value={String(props.autoLockMinutes)}903 onChange={(e) => void props.onAutoLockMinutesChange(Number(e.currentTarget.value))}904 >905 <option value="1">1 minute</option>906 <option value="5">5 minutes</option>907 <option value="15">15 minutes</option>908 <option value="30">30 minutes</option>909 <option value="0">Never</option>910 </select>911 </div>912913 <Show when={isNativePlatform() && biometricSupported()}>914 <p class="peach-settings-sub-label">BIOMETRICS</p>915 <div class="peach-settings-row">916 <span>Unlock with biometrics</span>917 <button918 class={`peach-toggle ${biometricStored() ? "is-on" : ""}`}919 onClick={() => void (biometricStored() ? handleDisableBiometrics() : handleEnableBiometrics())}920 disabled={biometricBusy()}921 aria-label="Toggle biometric unlock"922 >923 <span class="peach-toggle-knob"></span>924 </button>925 </div>926 <Show when={biometricError()}>927 <div class="peach-settings-row peach-settings-row--meta">928 <span class="peach-hint peach-hint-error">{biometricError()}</span>929 </div>930 </Show>931 </Show>932933 <p class="peach-settings-sub-label">BREACH PROTECTION</p>934 <div class="peach-settings-row">935 <span>Breach monitoring</span>936 <button937 class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}938 onClick={() => void props.onBreachMonitoringChange(!props.breachMonitoring)}939 aria-label="Toggle breach monitoring"940 >941 <span class="peach-toggle-knob"></span>942 </button>943 </div>944 <div class="peach-settings-row peach-settings-row--meta">945 <span>Check all passwords</span>946 <button947 class="peach-btn peach-btn--subtle peach-btn--sm"948 onClick={() => void handleCheckAllBreaches()}949 disabled={checkingBreaches()}950 >951 {checkingBreaches() ? "checking..." : "run"}952 </button>953 </div>954 <Show when={breachResult()}>955 <div class="peach-settings-row peach-settings-row--meta">956 <span>Last check</span>957 <span class="peach-settings-value">{breachResult()}</span>958 </div>959 </Show>960961 <p class="peach-settings-sub-label">MASTER PASSWORD</p>962 <div class="peach-settings-row peach-settings-row--meta">963 <span>Master password</span>964 <button965 class="peach-btn peach-btn--subtle peach-btn--sm"966 onClick={() => { setShowChangePw((v) => !v); setChangePwError(null); }}967 >968 {showChangePw() ? "cancel" : "change"}969 </button>970 </div>971 <Show when={showChangePw()}>972 <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">973 <input974 class="peach-settings-input"975 type="password"976 placeholder="current password"977 value={changePwCurrent()}978 onInput={(e) => setChangePwCurrent((e.target as HTMLInputElement).value)}979 autocomplete="current-password"980 />981 <input982 class="peach-settings-input"983 type="password"984 placeholder="new password (min 12 chars)"985 value={changePwNew()}986 onInput={(e) => setChangePwNew((e.target as HTMLInputElement).value)}987 autocomplete="new-password"988 />989 <input990 class="peach-settings-input"991 type="password"992 placeholder="confirm new password"993 value={changePwConfirm()}994 onInput={(e) => setChangePwConfirm((e.target as HTMLInputElement).value)}995 autocomplete="new-password"996 onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }}997 />998 <Show when={changePwError()}>999 <p class="peach-hint peach-hint-error" style="margin:0">{changePwError()}</p>1000 </Show>1001 <Show when={changePwSuccess()}>1002 <p class="peach-hint" style="margin:0;color:var(--peach-accent)">password changed ✓</p>1003 </Show>1004 <button1005 class="peach-btn peach-btn--accent peach-btn--sm"1006 style="align-self:flex-start"1007 onClick={() => void handleChangePassword()}1008 disabled={changePwSaving() || !changePwCurrent() || !changePwNew() || !changePwConfirm()}1009 >1010 {changePwSaving() ? "saving…" : "save new password"}1011 </button>1012 </div>1013 </Show>10141015 {}1016 <div class="peach-settings-row peach-settings-row--meta">1017 <span>Recovery words</span>1018 <button1019 class="peach-btn peach-btn--subtle peach-btn--sm"1020 onClick={() => {1021 if (showViewWords()) { closeViewWords(); } else { setShowViewWords(true); }1022 }}1023 >1024 {showViewWords() ? "cancel" : "view"}1025 </button>1026 </div>1027 <Show when={showViewWords()}>1028 <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1029 <Show1030 when={viewedWords()}1031 fallback={1032 <>1033 <p class="peach-hint" style="margin:0;color:var(--peach-t2)">1034 Enter your master password to reveal your recovery words.1035 </p>1036 <input1037 class="peach-settings-input"1038 type="password"1039 placeholder="master password"1040 value={viewWordsPassword()}1041 onInput={(e) => setViewWordsPassword((e.target as HTMLInputElement).value)}1042 autocomplete="current-password"1043 onKeyDown={(e) => { if (e.key === "Enter") void handleViewRecoveryWords(); }}1044 />1045 <Show when={viewWordsError()}>1046 <p class="peach-hint peach-hint-error" style="margin:0">{viewWordsError()}</p>1047 </Show>1048 <button1049 class="peach-btn peach-btn--accent peach-btn--sm"1050 style="align-self:flex-start"1051 onClick={() => void handleViewRecoveryWords()}1052 disabled={viewWordsBusy() || !viewWordsPassword()}1053 >1054 {viewWordsBusy() ? "checking…" : "reveal recovery words"}1055 </button>1056 </>1057 }1058 >1059 <div class="peach-recovery-words-grid">1060 <For each={viewedWords()!}>1061 {(word, i) => (1062 <span class="peach-recovery-word"><b>{i() + 1}</b> {word}</span>1063 )}1064 </For>1065 </div>1066 <button1067 class="peach-btn peach-btn--subtle peach-btn--sm"1068 style="align-self:flex-start"1069 onClick={closeViewWords}1070 >done</button>1071 </Show>1072 </div>1073 </Show>10741075 <p class="peach-settings-sub-label">1076 DURESS PASSWORD1077 <Show when={!isPro()}>1078 <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>1079 </Show>1080 </p>1081 <div class="peach-settings-row peach-settings-row--top">1082 <div class="peach-settings-row-main">1083 <span>1084 Duress password1085 <Show when={!isPro()}>1086 <span class="peach-pro-badge">Pro</span>1087 </Show>1088 </span>1089 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">1090 If entered under coercion, shows a decoy empty vault instead of your real one.1091 </span>1092 </div>1093 <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>1094 {duressEnabled() ? "on" : "off"}1095 </div>1096 </div>10971098 <Show when={duressEnabled() && !showDuressSetup()}>1099 <div class="peach-settings-row peach-settings-row--meta">1100 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1101 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.1102 </span>1103 </div>1104 </Show>11051106 <Show when={duressSuccess()}>1107 <div class="peach-settings-row peach-settings-row--meta">1108 <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>1109 </div>1110 </Show>11111112 <Show when={!showDuressSetup()}>1113 <div class="peach-settings-row peach-settings-row--meta">1114 <button1115 class="peach-btn peach-btn--subtle peach-btn--sm"1116 onClick={() => {1117 1118 1119 1120 1121 1122 if (!isPro() && !duressEnabled()) {1123 props.onProGate?.("duress_vault");1124 setActiveCard("pro");1125 return;1126 }1127 setShowDuressSetup(true);1128 setDuressError(null);1129 setDuressSuccess(null);1130 setDuressPassword("");1131 setDuressConfirm("");1132 setDuressMainPassword("");1133 }}1134 >1135 {duressEnabled() ? "Change / disable" : "Set up"}1136 </button>1137 </div>1138 </Show>11391140 <Show when={showDuressSetup()}>1141 <div class="peach-duress-form">1142 <label class="peach-label">main password (to confirm)</label>1143 <input1144 class="peach-settings-input"1145 type="password"1146 placeholder="your current master password"1147 autocomplete="current-password"1148 value={duressMainPassword()}1149 onInput={(e) => setDuressMainPassword(e.currentTarget.value)}1150 />1151 <Show when={!duressEnabled()}>1152 <label class="peach-label">new duress password</label>1153 <input1154 class="peach-settings-input"1155 type="password"1156 placeholder="duress password (min 8 chars)"1157 autocomplete="new-password"1158 value={duressPassword()}1159 onInput={(e) => setDuressPassword(e.currentTarget.value)}1160 />1161 <label class="peach-label">confirm duress password</label>1162 <input1163 class="peach-settings-input"1164 type="password"1165 placeholder="confirm duress password"1166 autocomplete="new-password"1167 value={duressConfirm()}1168 onInput={(e) => setDuressConfirm(e.currentTarget.value)}1169 />1170 </Show>11711172 <Show when={duressError()}>1173 <p class="peach-hint peach-hint-error">{duressError()}</p>1174 </Show>11751176 <div class="peach-duress-actions">1177 <button1178 class="peach-btn peach-btn--subtle peach-btn--sm"1179 onClick={() => { setShowDuressSetup(false); setDuressError(null); }}1180 >1181 cancel1182 </button>1183 <Show when={duressEnabled()}>1184 <button1185 class="peach-btn peach-btn--ghost peach-btn--sm"1186 style="color:var(--peach-breach)"1187 onClick={() => void handleDisableDuress()}1188 disabled={duressSaving()}1189 >1190 {duressSaving() ? "disabling..." : "disable duress"}1191 </button>1192 </Show>1193 <Show when={!duressEnabled()}>1194 <button1195 class="peach-btn peach-btn--accent peach-btn--sm"1196 onClick={() => void handleSetupDuress()}1197 disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}1198 >1199 {duressSaving() ? "saving..." : "save"}1200 </button>1201 </Show>1202 </div>1203 </div>1204 </Show>12051206 <p class="peach-settings-sub-label">1207 CANARY ENTRIES1208 <Show when={!isPro()}>1209 <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>1210 </Show>1211 </p>1212 <div class="peach-settings-row peach-settings-row--top">1213 <div class="peach-settings-row-main">1214 <span>1215 Canary entries1216 <Show when={!isPro()}>1217 <span class="peach-pro-badge">Pro</span>1218 </Show>1219 </span>1220 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1221 Silently pings a webhook when an entry is accessed. Configure canary on any entry in your vault.1222 </span>1223 </div>1224 <span class="peach-settings-value" style="flex-shrink:0">1225 {canaryEntries().length > 0 ? `${canaryEntries().length} active` : "none"}1226 </span>1227 </div>1228 <Show when={canaryEntries().length > 0}>1229 <For each={canaryEntries()}>1230 {(entry) => (1231 <div class="peach-settings-row peach-settings-row--meta">1232 <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0">{entry.title}</span>1233 <button1234 class="peach-btn peach-btn--subtle peach-btn--sm"1235 onClick={() => void handleTestCanary(entry)}1236 disabled={canaryTestingId() === entry.id}1237 style="flex-shrink:0"1238 >1239 {canaryTestingId() === entry.id1240 ? "sending…"1241 : canaryTestResult()?.id === entry.id1242 ? (canaryTestResult()!.ok ? "sent ✓" : "failed ✗")1243 : "test"}1244 </button>1245 </div>1246 )}1247 </For>1248 </Show>12491250 <p class="peach-settings-sub-label">PASSKEYS</p>1251 <div class="peach-settings-row peach-settings-row--top">1252 <div class="peach-settings-row-main">1253 <span>Intercept passkey requests</span>1254 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">1255 Peach can handle passkey sign-in and creation on supported Chromium browsers.1256 </span>1257 </div>1258 <button1259 class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}1260 onClick={() => void handleTogglePasskeys()}1261 aria-label="Toggle passkey interception"1262 disabled={!passkeyState()?.supported || passkeySaving()}1263 >1264 <span class="peach-toggle-knob"></span>1265 </button>1266 </div>1267 <div class="peach-settings-row peach-settings-row--meta">1268 <span>Status</span>1269 <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>1270 </div>1271 <div class="peach-settings-row peach-settings-row--meta">1272 <span>Browser</span>1273 <span class="peach-settings-value">1274 {passkeyState()?.browserLabel ?? "Unknown"}1275 {passkeyState()?.supported ? " ✓" : " (interception unavailable)"}1276 </span>1277 </div>1278 <div class="peach-info-notice" style="margin-top:8px">1279 Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.1280 </div>1281 <Show when={passkeyState()?.conflict}>1282 <div class="peach-settings-row peach-settings-row--meta">1283 <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>1284 </div>1285 </Show>1286 </div>1287 </div>1288 );1289 }12901291 function renderVault() {1292 return (1293 <div class="peach-settings-body">1294 <div class="peach-settings-group">1295 <p class="peach-settings-sub-label">ENTRIES</p>1296 <div class="peach-settings-row peach-settings-row--meta">1297 <span>Entries</span>1298 <span class="peach-settings-value">{props.entriesCount}</span>1299 </div>1300 <div class="peach-settings-row peach-settings-row--meta">1301 <span>Duplicates</span>1302 <Show when={dedupDone() !== null} fallback={1303 <button1304 class="peach-btn peach-btn--subtle peach-btn--sm"1305 onClick={() => void handleCountDuplicates()}1306 disabled={dedupRunning()}1307 >1308 {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}1309 </button>1310 }>1311 <span class="peach-settings-value" style="color:var(--peach-sync-ok)">1312 {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}1313 </span>1314 </Show>1315 </div>1316 <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>1317 <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">1318 <div class="peach-restore-warning">1319 {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.1320 </div>1321 <div style="display:flex;gap:6px">1322 <button1323 class="peach-btn peach-btn--subtle peach-btn--sm"1324 onClick={() => setDedupCount(null)}1325 >1326 cancel1327 </button>1328 <button1329 class="peach-btn peach-btn--accent peach-btn--sm"1330 onClick={() => void handleDeduplicate()}1331 disabled={dedupRunning()}1332 >1333 {dedupRunning() ? "removing..." : "remove duplicates"}1334 </button>1335 </div>1336 </div>1337 </Show>13381339 <p class="peach-settings-sub-label">MAINTENANCE</p>1340 <div class="peach-settings-row peach-settings-row--meta">1341 <span>Trash auto-delete</span>1342 <select1343 class="peach-settings-select"1344 value={String(props.trashRetentionDays)}1345 onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}1346 >1347 <option value="7">7 days</option>1348 <option value="30">30 days</option>1349 <option value="90">90 days</option>1350 <option value="0">Never</option>1351 </select>1352 </div>1353 <div class="peach-settings-row peach-settings-row--meta">1354 <span>Advanced Vault Health</span>1355 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>1356 open1357 </button>1358 </div>1359 <Show when={declinedCardCount() > 0}>1360 <div class="peach-settings-row peach-settings-row--meta">1361 <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>1362 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>1363 Clear1364 </button>1365 </div>1366 </Show>13671368 <p class="peach-settings-sub-label">DATA</p>1369 <div class="peach-settings-row peach-settings-row--meta">1370 <span>Import passwords</span>1371 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>1372 open1373 </button>1374 </div>1375 <div class="peach-settings-row peach-settings-row--meta">1376 <span>Export backup</span>1377 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onExport}>1378 open1379 </button>1380 </div>1381 <div class="peach-settings-row peach-settings-row--meta">1382 <span>1383 Peach Codex1384 <Show when={!isPro()}>1385 <span class="peach-pro-badge">Pro</span>1386 </Show>1387 </span>1388 <button1389 class="peach-btn peach-btn--subtle peach-btn--sm"1390 onClick={() => props.onOpenCodexGenerate?.()}1391 >1392 open1393 </button>1394 </div>1395 {1396}1397 <div class="peach-settings-row peach-settings-row--meta">1398 <span>Restore from Codex</span>1399 <button1400 class="peach-btn peach-btn--subtle peach-btn--sm"1401 onClick={() => props.onOpenCodexRestore?.()}1402 >1403 open1404 </button>1405 </div>14061407 <span class="peach-settings-sub-label">VAULT HISTORY</span>1408 <Show when={historyLoading()}>1409 <p class="peach-settings-hint">Loading…</p>1410 </Show>1411 <Show when={!historyLoading() && historySnapshots().length === 0}>1412 <p class="peach-settings-hint">1413 No snapshots yet. Peach saves automatically before every change —1414 saves, syncs, and imports.1415 </p>1416 </Show>1417 <Show when={!historyLoading() && historySnapshots().length > 0}>1418 <p class="peach-settings-hint">1419 Restoring locks your vault. Unlock with the password that1420 encrypted that snapshot to confirm.1421 </p>1422 <div class="peach-history-list">1423 <For each={historySnapshots().slice(0, historyShowAll() ? undefined : 3)}>1424 {(snap) => (1425 <div class="peach-history-row">1426 <div class="peach-history-meta">1427 <span class="peach-history-time">1428 {formatSnapshotTime(snap.savedAt)}1429 </span>1430 <span class="peach-history-sub">1431 {[1432 snap.entryCount !== null1433 ? `up to ${snap.entryCount} ${snap.entryCount === 1 ? "entry" : "entries"}`1434 : null,1435 reasonLabel(snap.reason),1436 ]1437 .filter(Boolean)1438 .join(" · ")}1439 </span>1440 </div>1441 <button1442 class={`peach-btn peach-btn--subtle peach-btn--sm peach-history-btn${1443 historyRestoring() === snap.id ? " is-busy" : ""1444 }`}1445 onClick={() => void handleRestoreVaultSnapshot(snap.id)}1446 disabled={historyRestoring() !== null}1447 >1448 <Show when={historyRestoring() === snap.id} fallback="Restore">1449 …1450 </Show>1451 </button>1452 </div>1453 )}1454 </For>1455 </div>1456 <Show when={historySnapshots().length > 3}>1457 <button1458 class="peach-settings-advanced-toggle"1459 onClick={() => setHistoryShowAll((v) => !v)}1460 aria-expanded={historyShowAll()}1461 >1462 <span class="peach-settings-hint" style="margin:0">1463 {historyShowAll() ? "show less" : `${historySnapshots().length - 3} older`}1464 </span>1465 <span class="peach-settings-advanced-chevron">1466 {historyShowAll() ? "▲" : "▼"}1467 </span>1468 </button>1469 </Show>1470 <p class="peach-history-footer">1471 {historySnapshots().length} of 10 snapshots1472 </p>1473 </Show>14741475 <button1476 class="peach-settings-advanced-toggle"1477 onClick={() => setShowVaultAdvanced((v) => !v)}1478 aria-expanded={showVaultAdvanced()}1479 >1480 <span class="peach-settings-sub-label" style="margin:0">ADVANCED</span>1481 <span class="peach-settings-advanced-chevron">1482 {showVaultAdvanced() ? "▲" : "▼"}1483 </span>1484 </button>14851486 <Show when={showVaultAdvanced()}>1487 <div class="peach-danger-card">1488 <div class="peach-danger-card-head">1489 <span class="peach-danger-card-glyph" aria-hidden="true">⟳</span>1490 <h3 class="peach-danger-card-title">Replace vault from backup</h3>1491 </div>1492 <p class="peach-danger-card-copy">1493 Wipes the current vault and restores from a backup file.1494 Recoverable only if your backup is good.1495 </p>1496 <button1497 class="peach-danger-card-button"1498 onClick={() => props.onOpenFileImport?.()}1499 >1500 Choose backup file1501 </button>1502 </div>15031504 <Show when={props.onOpenPanic}>1505 <div class="peach-danger-card peach-danger-card--hazard">1506 <div class="peach-danger-card-head">1507 <span class="peach-danger-card-glyph" aria-hidden="true">⚠</span>1508 <h3 class="peach-danger-card-title">Destroy vault</h3>1509 </div>1510 <p class="peach-danger-card-copy">1511 Permanently wipes every entry, setting, and license on this1512 device. There is no recovery.1513 </p>1514 <button1515 class="peach-danger-card-button"1516 onClick={() => props.onOpenPanic?.()}1517 >1518 Begin destruction1519 </button>1520 </div>1521 </Show>1522 </Show>1523 </div>1524 </div>1525 );1526 }15271528 function renderSync() {1529 return (1530 <div class="peach-settings-body">1531 <div class="peach-settings-group">1532 <div class="peach-settings-row peach-settings-row--meta">1533 <span>Status</span>1534 <span class="peach-settings-value" style="display:inline-flex;align-items:center;gap:8px">1535 {isPro() ? "Pro" : "Free"}1536 <span class={`stk-pip ${isPro() ? "is-on" : ""}`} aria-hidden="true" />1537 </span>1538 </div>15391540 {}1541 <p class="peach-settings-sub-label" style="margin-top:10px">PAIR A DEVICE</p>1542 {15431544}1545 <button1546 class="peach-sync-pair-option"1547 onClick={() => props.onOpenSyncPairing?.("code")}1548 >1549 <span class="peach-sync-pair-option-icon" aria-hidden="true">1550 <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">1551 <rect x="6" y="2.5" width="8" height="15" rx="1.6" />1552 <path d="M9 15 L11 15" />1553 </svg>1554 </span>1555 <span class="peach-sync-pair-option-main">1556 <span class="peach-sync-pair-option-title">Phone or tablet</span>1557 <span class="peach-sync-pair-option-sub">Enter the pairing code your phone shows — both on the same Wi-Fi</span>1558 </span>1559 <span class="peach-sync-pair-option-chevron" aria-hidden="true">›</span>1560 </button>1561 <Show when={!isPro()}>1562 <p class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2);margin:8px 2px 0">1563 At least one device needs Peach Pro — pair with a Pro device and it carries over to here.1564 </p>1565 </Show>15661567 {}1568 <Show when={pairedDevices().length > 0}>1569 <p class="peach-settings-sub-label" style="margin-top:12px">PAIRED DEVICES</p>1570 <For each={pairedDevices()}>1571 {(d) => (1572 <div class="peach-settings-row peach-settings-row--top">1573 <div class="peach-settings-row-main">1574 <span>{d.deviceName ?? "Unnamed device"}</span>1575 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1576 {deviceTypeLabel(d.deviceType)} · {syncRelativeTime(d.lastSyncedAt)}{isSyncStale(d) ? " · not seen recently" : ""}1577 </span>1578 </div>1579 <Show1580 when={confirmUnpair() === d.deviceId}1581 fallback={1582 <button1583 class="peach-btn peach-btn--subtle peach-btn--sm"1584 onClick={() => setConfirmUnpair(d.deviceId)}1585 >remove</button>1586 }1587 >1588 <div style="display:flex;gap:6px;flex-shrink:0">1589 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => setConfirmUnpair(null)}>cancel</button>1590 <button1591 class="peach-btn peach-btn--sm"1592 style="background:var(--peach-breach);border-color:var(--peach-breach)"1593 onClick={() => void unpairDevice(d.deviceId)}1594 >remove</button>1595 </div>1596 </Show>1597 </div>1598 )}1599 </For>1600 <div class="peach-settings-row peach-settings-row--meta">1601 <span class="peach-settings-value" style="color:var(--peach-t2)">{syncFlash() ?? ""}</span>1602 <button1603 class="peach-btn peach-btn--subtle peach-btn--sm"1604 disabled={syncStatus() === "syncing"}1605 onClick={() => void syncNow()}1606 >{syncStatus() === "syncing" ? "syncing…" : "sync now"}</button>1607 </div>1608 </Show>1609 {}1610 <Show when={pendingVouched().length > 0}>1611 <p class="peach-settings-sub-label">1612 PENDING DEVICES1613 <span class="peach-pro-badge" style="background:#C8A840;color:#1a1a1a">{pendingVouched().length}</span>1614 </p>1615 <For each={pendingVouched()}>1616 {(dev) => (1617 <div class="peach-settings-row peach-settings-row--top">1618 <div class="peach-settings-row-main">1619 <span>{dev.deviceName}</span>1620 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1621 Was added by another of your devices. Allow it to sync with this vault?1622 </span>1623 </div>1624 <div style="display:flex;gap:6px;flex-shrink:0">1625 <button1626 class="peach-btn peach-btn--sm"1627 disabled={vouchedBusy() === dev.deviceId}1628 onClick={() => void confirmVouched(dev.deviceId)}1629 >Allow</button>1630 <button1631 class="peach-btn peach-btn--subtle peach-btn--sm"1632 disabled={vouchedBusy() === dev.deviceId}1633 onClick={() => void rejectVouched(dev.deviceId)}1634 >Reject</button>1635 </div>1636 </div>1637 )}1638 </For>1639 </Show>16401641 {}1642 <p class="peach-settings-sub-label" style="margin-top:12px">RECOVERY</p>1643 <Show1644 when={rotateConfirmOpen()}1645 fallback={1646 <div class="peach-settings-row peach-settings-row--top">1647 <div class="peach-settings-row-main">1648 <span>Rotate sync identity</span>1649 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1650 Use this if a paired device was lost or stolen. Re-keys the vault so the old device's key is useless.1651 </span>1652 </div>1653 <button1654 class="peach-btn peach-btn--subtle peach-btn--sm"1655 style="color:var(--peach-breach);border-color:color-mix(in srgb,var(--peach-breach) 40%,transparent)"1656 onClick={() => { setRotateError(null); setRotateConfirmOpen(true); }}1657 >rotate</button>1658 </div>1659 }1660 >1661 <div class="peach-info-notice" style="margin-top:8px;border-color:color-mix(in srgb,var(--peach-breach) 40%,transparent)">1662 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.1663 <Show when={rotateError()}>1664 <div style="color:var(--peach-breach);margin-top:6px">{rotateError()}</div>1665 </Show>1666 <div style="display:flex;gap:6px;margin-top:10px">1667 <button1668 class="peach-btn peach-btn--subtle peach-btn--sm"1669 disabled={rotating()}1670 onClick={() => setRotateConfirmOpen(false)}1671 >Cancel</button>1672 <button1673 class="peach-btn peach-btn--sm"1674 style="background:var(--peach-breach);border-color:var(--peach-breach)"1675 disabled={rotating()}1676 onClick={() => void doRotateSyncIdentity()}1677 >{rotating() ? "Rotating…" : "Rotate sync identity"}</button>1678 </div>1679 </div>1680 </Show>1681 </div>1682 </div>1683 );1684 }16851686 function renderAppearance() {1687 const sizeOptions: Array<{ id: "medium" | "large"; label: string }> = [1688 { id: "medium", label: "Default" },1689 { id: "large", label: "Large" },1690 ];1691 return (1692 <div class="peach-settings-body">1693 <div class="peach-settings-group">1694 <p class="peach-settings-sub-label">THEME</p>1695 <div class="peach-settings-row peach-settings-row--meta">1696 <span>Theme</span>1697 <button1698 class="peach-btn peach-btn--subtle peach-btn--sm"1699 onClick={() =>1700 props.onThemeChange(props.theme === "dark" ? "light" : "dark")1701 }1702 >1703 {props.theme === "dark" ? "Switch to light" : "Switch to dark"}1704 </button>1705 </div>17061707 <p class="peach-settings-sub-label">ACCENT</p>1708 <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">1709 <span>Accent</span>1710 <div class="peach-accent-picker">1711 <For each={ACCENT_COLORS}>1712 {(color) => (1713 <button1714 class="peach-accent-swatch peach-swatch"1715 classList={{ "is-active": props.accentColor === color.name }}1716 style={{ background: color.hex }}1717 onClick={() => void props.onAccentChange(color.name)}1718 aria-label={color.name}1719 title={color.name}1720 />1721 )}1722 </For>1723 </div>1724 </div>17251726 <p class="peach-settings-sub-label">TEXT SIZE</p>1727 <div class="peach-settings-row peach-settings-row--meta">1728 <span>Size</span>1729 <div class="peach-pill-group">1730 <For each={sizeOptions}>1731 {(opt) => (1732 <button1733 class="peach-btn peach-btn--ghost peach-btn--sm"1734 classList={{ "is-active": props.textSize === opt.id }}1735 onClick={() => void props.onTextSizeChange(opt.id)}1736 >1737 {opt.label}1738 </button>1739 )}1740 </For>1741 </div>1742 </div>17431744 <p class="peach-settings-sub-label">MOTION</p>1745 <div class="peach-settings-row peach-settings-row--meta">1746 <span>Reduce motion</span>1747 <button1748 class="peach-btn peach-btn--subtle peach-btn--sm"1749 onClick={() => void props.onReduceMotionChange(!props.reduceMotion)}1750 >1751 {props.reduceMotion ? "On" : "Off"}1752 </button>1753 </div>1754 </div>1755 </div>1756 );1757 }17581759 function renderShortcuts() {1760 const [capturingAction, setCapturingAction] = createSignal<ShortcutAction | null>(null);1761 const [conflictMsg, setConflictMsg] = createSignal<string | null>(null);17621763 function startCapture(action: ShortcutAction) {1764 setConflictMsg(null);1765 setCapturingAction(action);1766 }1767 function cancelCapture() {1768 setCapturingAction(null);1769 setConflictMsg(null);1770 }1771 function commitBinding(action: ShortcutAction, binding: Keybinding) {1772 const conflict = findConflict(props.shortcuts, binding, action);1773 if (conflict) {1774 const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict);1775 setConflictMsg(`Already bound to "${other?.label ?? conflict}"`);1776 return;1777 }1778 const next = { ...props.shortcuts, [action]: binding };1779 setCapturingAction(null);1780 setConflictMsg(null);1781 void props.onShortcutsChange(next);1782 }1783 function resetAction(action: ShortcutAction) {1784 const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] };1785 void props.onShortcutsChange(next);1786 }1787 function resetAll() {1788 void props.onShortcutsChange({ ...DEFAULT_BINDINGS });1789 }17901791 1792 1793 function handleCaptureKey(e: KeyboardEvent) {1794 e.preventDefault();1795 e.stopPropagation();1796 if (e.key === "Escape") { cancelCapture(); return; }1797 const binding = keybindingFromEvent(e);1798 if (!binding) return; 1799 const action = capturingAction();1800 if (action) commitBinding(action, binding);1801 }18021803 return (1804 <div class="peach-settings-body">1805 <div class="peach-settings-group peach-shortcuts-group">1806 <p class="peach-settings-sub-label">ENTRY VIEW</p>1807 <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry")}>1808 {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1809 </For>1810 </div>18111812 <div class="peach-settings-group peach-shortcuts-group">1813 <p class="peach-settings-sub-label">ANYWHERE IN POPUP</p>1814 <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global")}>1815 {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1816 </For>1817 </div>18181819 <Show when={conflictMsg()}>1820 <div class="peach-shortcuts-conflict">{conflictMsg()}</div>1821 </Show>18221823 <div class="peach-settings-group" style="display:flex;flex-direction:column;gap:8px">1824 <div class="peach-settings-row peach-settings-row--meta">1825 <span>Inline autofill on page</span>1826 <a1827 class="peach-settings-link"1828 href="chrome://extensions/shortcuts"1829 onClick={(e) => {1830 e.preventDefault();1831 chrome.tabs.create({ url: "chrome://extensions/shortcuts" });1832 }}1833 >1834 ⌘⇧P — customize ↗1835 </a>1836 </div>1837 <button1838 class="peach-btn peach-btn--subtle peach-btn--sm"1839 style="align-self:flex-start"1840 onClick={resetAll}1841 >1842 Reset all to defaults1843 </button>1844 </div>1845 </div>1846 );18471848 function renderRow(1849 desc: typeof SHORTCUT_DESCRIPTORS[number],1850 capturing: () => ShortcutAction | null,1851 onStart: (a: ShortcutAction) => void,1852 onCancel: () => void,1853 onReset: (a: ShortcutAction) => void,1854 onKey: (e: KeyboardEvent) => void,1855 ) {1856 const isActive = () => capturing() === desc.action;1857 const binding = () => props.shortcuts[desc.action];1858 const isCustom = () => {1859 const b = binding();1860 const d = DEFAULT_BINDINGS[desc.action];1861 return b.key !== d.key1862 || Boolean(b.ctrl) !== Boolean(d.ctrl)1863 || Boolean(b.meta) !== Boolean(d.meta)1864 || Boolean(b.shift) !== Boolean(d.shift)1865 || Boolean(b.alt) !== Boolean(d.alt);1866 };1867 return (1868 <div class="peach-shortcut-row">1869 <span class="peach-shortcut-row-label">{desc.label}</span>1870 <button1871 class="peach-shortcut-chip"1872 classList={{ "is-capturing": isActive(), "is-custom": isCustom() }}1873 onClick={() => isActive() ? onCancel() : onStart(desc.action)}1874 onKeyDown={(e) => { if (isActive()) onKey(e); }}1875 ref={(el) => {1876 1877 if (el) {1878 queueMicrotask(() => { if (isActive()) el.focus(); });1879 }1880 }}1881 >1882 {isActive() ? "Press a key…" : formatKeybinding(binding())}1883 </button>1884 <Show when={isCustom() && !isActive()}>1885 <button1886 class="peach-shortcut-reset"1887 title="Reset to default"1888 onClick={() => onReset(desc.action)}1889 >1890 ↺1891 </button>1892 </Show>1893 </div>1894 );1895 }1896 }18971898 const PRO_FEATURES: Array<{1899 feature: ProFeature;1900 name: string;1901 desc: string;1902 }> = [1903 { feature: "sync", name: "Peach Sync", desc: "Encrypted · peer-to-peer" },1904 { feature: "codex", name: "Peach Codex", desc: "Restore from paper" },1905 { feature: "passkeys", name: "Passkeys", desc: "Zero-knowledge WebAuthn" },1906 { feature: "advanced_health", name: "Advanced Vault Health", desc: "Reuse · age · exposure" },1907 ];19081909 const PRO_SECURITY_FEATURES: Array<{1910 feature: ProFeature;1911 name: string;1912 desc: string;1913 }> = [1914 { feature: "canary_entry", name: "Canary entries", desc: "Detects vault compromise" },1915 { feature: "duress_vault", name: "Duress vault", desc: "Decoy under coercion" },1916 ];19171918 const [licenseKey, setLicenseKey] = createSignal<string | null>(null);1919 const [showKey, setShowKey] = createSignal(false);19201921 const [deactivating, setDeactivating] = createSignal(false);19221923 async function handleDeactivateLicense() {1924 setDeactivating(true);1925 await sendMessage({ type: "DEACTIVATE_LICENSE" });1926 setLicenseInfo(null);1927 await props.onLicenseChange?.();1928 setDeactivating(false);1929 }19301931 async function handleTestCanary(entry: {id: string; title: string; canary: Canary}) {1932 setCanaryTestingId(entry.id);1933 setCanaryTestResult(null);1934 const res = await sendMessage({1935 type: "FIRE_CANARY_PING",1936 payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger: "test" },1937 }) as { ok: boolean };1938 setCanaryTestResult({ id: entry.id, ok: res.ok });1939 setCanaryTestingId(null);1940 setTimeout(() => setCanaryTestResult((r) => r?.id === entry.id ? null : r), 3000);1941 }19421943 function renderLicense() {1944 async function onActivate() {1945 await handleActivateLicense();1946 if ((licenseInfo()?.valid ?? false) && licenseInfo()?.plan === "pro") {1947 await props.onLicenseChange?.();1948 }1949 }19501951 return (1952 <div class="peach-settings-body">1953 <Show1954 when={isPro()}1955 fallback={1956 <div class="peach-pro-upgrade">1957 <p class="peach-pro-eyebrow">PEACH PRO · ONE-TIME</p>19581959 <h2 class="peach-pro-headline">1960 Every<em>thing,</em><br />every<em>where.</em>1961 </h2>19621963 <p class="peach-pro-subtitle">1964 One key unlocks every Pro feature — for the lifetime of the app.1965 </p>19661967 <div class="peach-pro-grid">1968 <For each={PRO_FEATURES}>1969 {(item, i) => (1970 <div1971 class="peach-pro-grid-item"1972 classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}1973 >1974 <span class="peach-pro-grid-num">{String(i() + 1).padStart(2, "0")}</span>1975 <span class="peach-pro-grid-name">{item.name}</span>1976 <span class="peach-pro-grid-desc">{item.desc}</span>1977 </div>1978 )}1979 </For>1980 </div>19811982 <p class="peach-pro-section-label">ADVANCED SECURITY</p>1983 <div class="peach-pro-grid">1984 <For each={PRO_SECURITY_FEATURES}>1985 {(item, i) => (1986 <div1987 class="peach-pro-grid-item"1988 classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}1989 >1990 <span class="peach-pro-grid-num">{String(PRO_FEATURES.length + i() + 1).padStart(2, "0")}</span>1991 <span class="peach-pro-grid-name">{item.name}</span>1992 <span class="peach-pro-grid-desc">{item.desc}</span>1993 </div>1994 )}1995 </For>1996 </div>19971998 <div class="peach-pro-upgrade-actions">1999 <p class="peach-pro-pricing">2000 $39 <span class="peach-pro-pricing-sub">once · yours forever</span>2001 </p>2002 <a2003 href="https://buy.polar.sh/polar_cl_IXSFIPojx67rcOTksn8LkH5mnU3osPHtYqBIl1eyuIz"2004 target="_blank"2005 rel="noopener noreferrer"2006 class="peach-pro-upgrade-cta"2007 >2008 Get Peach Pro2009 </a>2010 <div class="peach-pro-key-divider"><span>ALREADY HAVE A KEY</span></div>2011 <div class="peach-pro-key-inline">2012 <input2013 class="peach-pro-key-inline-input"2014 type="text"2015 placeholder="PEACH-XXXX-XXXX-XXXX"2016 value={licenseKeyInput()}2017 onInput={(e) => setLicenseKeyInput(e.currentTarget.value)}2018 onKeyDown={(e) => { if (e.key === "Enter") void onActivate(); }}2019 autocomplete="off"2020 spellcheck={false}2021 />2022 <button2023 class="peach-pro-key-apply"2024 onClick={() => void onActivate()}2025 disabled={activatingLicense() || !licenseKeyInput().trim()}2026 >2027 {activatingLicense() ? "…" : "APPLY"}2028 </button>2029 </div>2030 <Show when={licenseError()}>2031 <p class="peach-pro-key-error">{licenseError()}</p>2032 </Show>2033 </div>2034 </div>2035 }2036 >2037 <div class="peach-pro-active">2038 <div class="peach-pro-active-hero">2039 <button2040 class="peach-pro-active-mark-wrap"2041 type="button"2042 aria-label="Celebrate"2043 onClick={handleProCelebrationClick}2044 >2045 <PeachMark class="peach-pro-active-mark" />2046 <Show when={justActivated()}>2047 <div class="peach-pro-sparks" aria-hidden="true">2048 <span class="peach-pro-spark" style="--spark-x:0px;--spark-y:-54px;--spark-delay:0ms">✦</span>2049 <span class="peach-pro-spark" style="--spark-x:38px;--spark-y:-38px;--spark-delay:50ms">✦</span>2050 <span class="peach-pro-spark" style="--spark-x:54px;--spark-y:0px;--spark-delay:110ms">·</span>2051 <span class="peach-pro-spark" style="--spark-x:38px;--spark-y:38px;--spark-delay:30ms">✦</span>2052 <span class="peach-pro-spark" style="--spark-x:0px;--spark-y:54px;--spark-delay:80ms">·</span>2053 <span class="peach-pro-spark" style="--spark-x:-38px;--spark-y:38px;--spark-delay:130ms">✦</span>2054 <span class="peach-pro-spark" style="--spark-x:-54px;--spark-y:0px;--spark-delay:55ms">·</span>2055 <span class="peach-pro-spark" style="--spark-x:-38px;--spark-y:-38px;--spark-delay:95ms">✦</span>2056 </div>2057 </Show>2058 </button>2059 <h2 class="peach-pro-active-title">Peach Pro</h2>2060 <p class="peach-pro-active-since">{formatActivatedSince(licenseInfo()?.issued)}</p>2061 <Show when={justActivated()}>2062 <p class="peach-pro-thankyou-text" aria-live="polite">thank you ✦</p>2063 </Show>2064 </div>20652066 <Show when={licenseKey()}>2067 <div class="peach-settings-license">2068 <span class="peach-settings-label">LICENSE KEY</span>2069 <div class="peach-settings-license-row">2070 <Show2071 when={showKey()}2072 fallback={2073 <span class="peach-settings-license-key peach-settings-license-key--masked" aria-label="License key hidden">2074 ••••••••••••••••••••••••••••2075 </span>2076 }2077 >2078 <span class="peach-settings-license-key">{licenseKey()!}</span>2079 </Show>2080 <div class="peach-settings-license-actions">2081 <button2082 class="peach-settings-license-copy"2083 title="Copy license key"2084 onClick={() => copyLicenseKey(licenseKey()!)}2085 >2086 {copied() ? "✓" : "copy"}2087 </button>2088 <button2089 class="peach-settings-license-toggle"2090 aria-label={showKey() ? "Hide license key" : "Reveal license key"}2091 title={showKey() ? "Hide" : "Reveal"}2092 onClick={() => setShowKey(v => !v)}2093 >2094 <Show2095 when={showKey()}2096 fallback={2097 <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">2098 <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />2099 <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />2100 </svg>2101 }2102 >2103 <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">2104 <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />2105 <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />2106 <line x1="2" y1="2" x2="12" y2="12" />2107 </svg>2108 </Show>2109 </button>2110 </div>2111 </div>2112 </div>2113 </Show>21142115 <div class="peach-pro-active-features">2116 <For each={[...PRO_FEATURES, ...PRO_SECURITY_FEATURES]}>2117 {(item) => (2118 <div class="peach-pro-active-feature">2119 <span class="peach-pro-active-feature-mark">✦</span>2120 <span class="peach-pro-active-feature-name">{item.name}</span>2121 </div>2122 )}2123 </For>2124 </div>21252126 <button2127 class="peach-pro-deactivate"2128 onClick={() => void handleDeactivateLicense()}2129 disabled={deactivating()}2130 >2131 {deactivating() ? "Deactivating…" : "Deactivate"}2132 </button>2133 </div>2134 </Show>2135 </div>2136 );2137 }21382139 function renderFeedback() {2140 return (2141 <div class="peach-settings-body">2142 <div class="peach-settings-group peach-feedback-group">2143 <Show2144 when={feedbackState() === 'sent'}2145 fallback={2146 <>2147 <p class="peach-feedback-intro">2148 Help us improve Peach. Every report goes directly to our inbox.2149 </p>21502151 <p class="peach-settings-sub-label">TYPE</p>2152 <div class="peach-feedback-chips">2153 <For each={FEEDBACK_CATEGORIES}>2154 {(cat) => {2155 const selected = () => fbCategory() === cat;2156 return (2157 <button2158 type="button"2159 class={`peach-feedback-chip ${selected() ? "is-active" : ""}`}2160 aria-pressed={selected()}2161 onClick={() => setFbCategory(cat)}2162 >2163 {cat}2164 </button>2165 );2166 }}2167 </For>2168 </div>21692170 <p class="peach-settings-sub-label">MESSAGE</p>2171 <textarea2172 class="peach-feedback-textarea"2173 placeholder="Describe the issue or idea..."2174 maxLength={MAX_FEEDBACK_MESSAGE_LENGTH}2175 value={fbMessage()}2176 onInput={(e) => setFbMessage(e.currentTarget.value)}2177 />21782179 <p class="peach-feedback-meta">2180 Version {version} · will be included automatically2181 </p>21822183 <button2184 type="button"2185 class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"2186 disabled={!fbMessage().trim() || feedbackState() === 'sending'}2187 onClick={() => void handleSubmitFeedback()}2188 >2189 <Switch>2190 <Match when={feedbackState() === 'idle'}>Send</Match>2191 <Match when={feedbackState() === 'sending'}>Sending...</Match>2192 <Match when={feedbackState() === 'error'}>Failed - try again</Match>2193 </Switch>2194 </button>21952196 <p class="peach-feedback-anon">2197 Sent anonymously · no account required2198 </p>2199 </>2200 }2201 >2202 <div class="peach-feedback-thanks" role="status" aria-live="polite">2203 <p class="peach-feedback-thanks-title">Thank you</p>2204 <p>2205 Your note is in the queue. Someone will process it soon.2206 </p>2207 </div>2208 </Show>2209 </div>2210 </div>2211 );2212 }22132214 const bodies: StackBodies = {2215 protection: renderSecurity(),2216 vault: renderVault(),2217 sync: renderSync(),2218 appearance: renderAppearance(),2219 shortcuts: renderShortcuts(),2220 pro: renderLicense(),2221 feedback: renderFeedback(),2222 };22232224 return (2225 <Show2226 when={showNewRecoveryWords()}2227 fallback={2228 <div2229 class="peach-overlay-view peach-settings-overlay"2230 classList={{ "ov-enter-right": !props.isExiting, "ov-exit-right": !!props.isExiting }}2231 style="display:flex;flex-direction:column"2232 >2233 <StackDeck2234 active={activeCard()}2235 onActiveChange={setActiveCard}2236 onBack={props.onBack}2237 cards={cards()}2238 bodies={bodies}2239 version={version}2240 isFanning={isFanning()}2241 celebratingCardId={justActivated() ? "pro" : null}2242 isPro={isPro()}2243 />2244 </div>2245 }2246 >2247 <RecoveryWordDisplayView onDone={() => setShowNewRecoveryWords(false)} />2248 </Show>2249 );2250}