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);272 const [relayUrl, setRelayUrl] = createSignal("");273 const [lanOnly, setLanOnly] = createSignal(false);274 const [relaySaving, setRelaySaving] = createSignal(false);275 const [relayStatus, setRelayStatus] = createSignal<string | null>(null);276277 const STALE_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000;278 function syncRelativeTime(when: number | undefined): string {279 if (!when) return "Not synced yet";280 const elapsed = Date.now() - when;281 if (elapsed < 60_000) return "Just now";282 const minutes = Math.floor(elapsed / 60_000);283 if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"} ago`;284 const hours = Math.floor(minutes / 60);285 if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;286 const days = Math.floor(hours / 24);287 return days < 7 ? `${days} day${days === 1 ? "" : "s"} ago` : "Not seen recently";288 }289 function isSyncStale(d: PairedDevice): boolean {290 return d.lastSyncedAt !== undefined && Date.now() - d.lastSyncedAt > STALE_THRESHOLD_MS;291 }292 function deviceTypeLabel(t: PairedDevice["deviceType"]): string {293 switch (t) {294 case "android": return "Android";295 case "ios": return "iPhone";296 case "extension": return "Browser";297 default: return "Device";298 }299 }300301 async function loadPairedDevices() {302 if (!isPro()) return;303 const r = (await sendMessage({ type: "LIST_PAIRED_DEVICES" })) as {304 ok: boolean;305 data?: { devices: PairedDevice[] };306 };307 if (r.ok) setPairedDevices(r.data?.devices ?? []);308 }309 async function unpairDevice(deviceId: string) {310 await sendMessage({ type: "UNPAIR_DEVICE", payload: { deviceId } });311 setConfirmUnpair(null);312 await loadPairedDevices();313 }314 async function syncNow() {315 setSyncStatus("syncing");316 setSyncFlash(null);317 const res = (await sendMessage({318 type: "TRIGGER_SYNC",319 payload: { reason: "manual", force: true },320 })) as { ok: boolean; error?: string };321 setTimeout(async () => {322 const state = (await sendMessage({ type: "GET_SYNC_STATE" })) as {323 ok: boolean;324 data?: { status: "idle" | "syncing" | "error"; entriesReceived?: number; error?: string };325 };326 const s = state.data;327 if (!s) return;328 setSyncStatus(s.status);329 if (s.status === "idle") {330 setSyncFlash((s.entriesReceived ?? 0) > 0 ? `Synced — ${s.entriesReceived} updated` : "Up to date");331 setTimeout(() => setSyncFlash(null), 3000);332 await loadPairedDevices();333 } else if (s.status === "error") {334 setSyncFlash(`Sync failed — ${s.error ?? "no host reachable"}`);335 setTimeout(() => setSyncFlash(null), 4000);336 }337 }, 250);338 if (!res.ok) {339 setSyncStatus("error");340 setSyncFlash(`Sync failed — ${res.error ?? "unknown"}`);341 setTimeout(() => setSyncFlash(null), 4000);342 }343 }344 async function loadRelaySettings() {345 const result = (await sendMessage({ type: "GET_RELAY_SETTINGS" })) as {346 ok: boolean;347 data?: { relayUrl: string; lanOnly: boolean };348 };349 if (result.ok && result.data) {350 setRelayUrl(result.data.relayUrl);351 setLanOnly(result.data.lanOnly);352 }353 }354 async function saveRelaySettings(nextLanOnly = lanOnly()) {355 setRelaySaving(true);356 setRelayStatus(null);357 const result = (await sendMessage({358 type: "SET_RELAY_SETTINGS",359 payload: { relayUrl: relayUrl(), lanOnly: nextLanOnly },360 })) as {361 ok: boolean;362 error?: string;363 data?: { relayUrl: string; lanOnly: boolean };364 };365 if (result.ok && result.data) {366 setRelayUrl(result.data.relayUrl);367 setLanOnly(result.data.lanOnly);368 setRelayStatus("Saved");369 } else {370 setRelayStatus("Use a valid wss:// relay URL");371 }372 setRelaySaving(false);373 }374 375 const [rotateConfirmOpen, setRotateConfirmOpen] = createSignal(false);376 const [rotating, setRotating] = createSignal(false);377 const [rotateError, setRotateError] = createSignal<string | null>(null);378379 async function loadPendingVouched() {380 const r = (await sendMessage({ type: "GET_PENDING_VOUCHED_DEVICES" })) as {381 ok: boolean;382 data?: PendingVouched[];383 };384 if (r.ok) setPendingVouched(r.data ?? []);385 }386 async function confirmVouched(deviceId: string) {387 setVouchedBusy(deviceId);388 try {389 await sendMessage({ type: "CONFIRM_VOUCHED_DEVICE", payload: { deviceId } });390 await loadPendingVouched();391 } finally {392 setVouchedBusy(null);393 }394 }395 async function rejectVouched(deviceId: string) {396 setVouchedBusy(deviceId);397 try {398 await sendMessage({ type: "REJECT_VOUCHED_DEVICE", payload: { deviceId } });399 await loadPendingVouched();400 } finally {401 setVouchedBusy(null);402 }403 }404 async function doRotateSyncIdentity() {405 setRotateError(null);406 setRotating(true);407 try {408 const r = (await sendMessage({ type: "ROTATE_SYNC_IDENTITY" })) as {409 ok: boolean;410 error?: string;411 };412 if (!r.ok) {413 setRotateError(r.error ?? "Rotation failed.");414 return;415 }416 setRotateConfirmOpen(false);417 418 419 setShowNewRecoveryWords(true);420 } finally {421 setRotating(false);422 }423 }424425 const [dedupCount, setDedupCount] = createSignal<number | null>(null);426 const [dedupRunning, setDedupRunning] = createSignal(false);427 const [dedupDone, setDedupDone] = createSignal<number | null>(null);428429 const [declinedCardCount, setDeclinedCardCount] = createSignal(0);430431 432 433 434 const [historySnapshots, setHistorySnapshots] = createSignal<VaultSnapshot[]>([]);435 const [historyLoading, setHistoryLoading] = createSignal(false);436 const [historyRestoring, setHistoryRestoring] = createSignal<string | null>(null);437 const [historyShowAll, setHistoryShowAll] = createSignal(false);438439 async function loadVaultHistory() {440 setHistoryLoading(true);441 try {442 const res = await sendMessage({ type: "GET_VAULT_HISTORY" });443 const list = (res as { ok: boolean; data?: VaultSnapshot[] }).data ?? [];444 setHistorySnapshots(list);445 } finally {446 setHistoryLoading(false);447 }448 }449450 async function handleRestoreVaultSnapshot(id: string) {451 if (historyRestoring()) return;452 setHistoryRestoring(id);453 const res = await sendMessage({454 type: "RESTORE_VAULT_SNAPSHOT",455 payload: { snapshotId: id },456 });457 setHistoryRestoring(null);458 if ((res as { ok: boolean }).ok) {459 460 461 props.onBack();462 }463 }464465 function formatSnapshotTime(savedAt: number): string {466 const diff = Date.now() - savedAt;467 if (diff < 60_000) return "Just now";468 if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;469 const d = new Date(savedAt);470 const isToday = new Date().toDateString() === d.toDateString();471 const isYest =472 new Date(Date.now() - 86_400_000).toDateString() === d.toDateString();473 const t = d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });474 if (isToday) return `Today ${t}`;475 if (isYest) return `Yesterday ${t}`;476 return `${d.toLocaleDateString([], { month: "short", day: "numeric" })} ${t}`;477 }478479 function reasonLabel(reason: VaultSnapshot["reason"]): string {480 return (481 {482 local_save: "saved",483 sync_received: "synced",484 import: "imported",485 restore: "restored",486 } as const487 )[reason] ?? reason;488 }489490 const [showVaultAdvanced, setShowVaultAdvanced] = createSignal(false);491492 const [showChangePw, setShowChangePw] = createSignal(false);493 const [changePwCurrent, setChangePwCurrent] = createSignal("");494 const [changePwNew, setChangePwNew] = createSignal("");495 const [changePwConfirm, setChangePwConfirm] = createSignal("");496 const [changePwSaving, setChangePwSaving] = createSignal(false);497 const [changePwError, setChangePwError] = createSignal<string | null>(null);498 const [changePwSuccess] = createSignal(false);499 const [showNewRecoveryWords, setShowNewRecoveryWords] = createSignal(false);500501 502 const [showViewWords, setShowViewWords] = createSignal(false);503 const [viewWordsPassword, setViewWordsPassword] = createSignal("");504 const [viewWordsError, setViewWordsError] = createSignal<string | null>(null);505 const [viewedWords, setViewedWords] = createSignal<string[] | null>(null);506 const [viewWordsBusy, setViewWordsBusy] = createSignal(false);507508 async function handleViewRecoveryWords() {509 setViewWordsError(null);510 if (!viewWordsPassword()) {511 setViewWordsError("Enter your master password.");512 return;513 }514 setViewWordsBusy(true);515 try {516 const r = (await sendMessage({517 type: "GET_RECOVERY_WORDS",518 payload: { password: viewWordsPassword() },519 })) as { ok: boolean; data?: string[]; error?: string };520 if (r.ok) {521 setViewedWords(r.data ?? []);522 setViewWordsPassword("");523 return;524 }525 switch (r.error) {526 case "wrong_password":527 setViewWordsError("Incorrect password");528 break;529 case "password_required":530 setViewWordsError("Enter your master password.");531 break;532 case "recovery_not_setup":533 setViewWordsError("Recovery words aren't set up for this vault yet.");534 break;535 default:536 setViewWordsError("Couldn't retrieve recovery words.");537 }538 } finally {539 setViewWordsBusy(false);540 }541 }542 function closeViewWords() {543 setShowViewWords(false);544 setViewWordsPassword("");545 setViewWordsError(null);546 setViewedWords(null);547 }548549 const [duressEnabled, setDuressEnabled] = createSignal(false);550 const [showDuressSetup, setShowDuressSetup] = createSignal(false);551 const [duressPassword, setDuressPassword] = createSignal("");552 const [duressConfirm, setDuressConfirm] = createSignal("");553 const [duressMainPassword, setDuressMainPassword] = createSignal("");554 const [duressSaving, setDuressSaving] = createSignal(false);555 const [duressError, setDuressError] = createSignal<string | null>(null);556 const [duressSuccess, setDuressSuccess] = createSignal<string | null>(null);557558 const [canaryEntries, setCanaryEntries] = createSignal<Array<{id: string; title: string; canary: Canary}>>([]);559560 const [canaryTestingId, setCanaryTestingId] = createSignal<string | null>(null);561 const [canaryTestResult, setCanaryTestResult] = createSignal<{id: string; ok: boolean} | null>(null);562563 const [fbCategory, setFbCategory] = createSignal("Bug report");564 const [fbMessage, setFbMessage] = createSignal("");565 type FeedbackState = 'idle' | 'sending' | 'sent' | 'error';566 const [feedbackState, setFeedbackState] = createSignal<FeedbackState>('idle');567568 createEffect(() => {569 if (props.upgradeHighlight) {570 setActiveCard("pro");571 }572 });573574 onMount(async () => {575 const [licenseRes, passkeyRes, duressRes, settingsRes, keyRes] = await Promise.all([576 sendMessage({ type: "GET_LICENSE" }),577 sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }),578 sendMessage({ type: "IS_DURESS_ENABLED" }),579 sendMessage({ type: "GET_SETTINGS" }),580 sendMessage({ type: "GET_LICENSE_KEY" }),581 ]);582583 584 585 void loadVaultHistory();586587 588 589 void loadPendingVouched();590 void loadPairedDevices();591 void loadRelaySettings();592 const onBroadcast = (msg: unknown) => {593 if ((msg as { type?: string } | null)?.type === "VOUCHED_DEVICE_PENDING") {594 void loadPendingVouched();595 }596 };597 chrome.runtime?.onMessage?.addListener(onBroadcast);598 onCleanup(() => {599 try { chrome.runtime?.onMessage?.removeListener(onBroadcast); } catch { }600 });601602 if ((licenseRes as { ok: boolean }).ok) {603 setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null);604 }605606 if ((passkeyRes as { ok: boolean }).ok) {607 setPasskeyState(608 (passkeyRes as { ok: boolean; data?: PasskeyProviderState }).data ?? null609 );610 }611612 if ((duressRes as { ok: boolean }).ok) {613 setDuressEnabled((duressRes as { ok: boolean; data?: boolean }).data ?? false);614 }615616 if ((settingsRes as { ok: boolean }).ok) {617 const sData = (settingsRes as { ok: boolean; data?: { declinedCardFingerprints?: string[] } }).data;618 setDeclinedCardCount(sData?.declinedCardFingerprints?.length ?? 0);619 }620621 if ((keyRes as { ok: boolean }).ok) {622 setLicenseKey((keyRes as { ok: boolean; data?: string }).data ?? null);623 }624625 const entriesRes = await sendMessage({ type: "GET_ENTRIES" });626 if ((entriesRes as { ok: boolean }).ok) {627 const entries = (entriesRes as { ok: boolean; data?: Array<{id: string; title: string; canary?: Canary}> }).data ?? [];628 setCanaryEntries(629 entries630 .filter((e) => e.canary?.webhookUrl)631 .map((e) => ({ id: e.id, title: e.title, canary: e.canary! }))632 );633 }634635636 if (isNativePlatform()) {637 const biometric = nativePlugin<{ isAvailable: () => Promise<{ available: boolean }> }>("BiometricPlugin");638 const keystore = nativePlugin<{ hasMasterKey: () => Promise<{ exists: boolean }> }>("KeystorePlugin");639 try {640 if (biometric) {641 const a = await biometric.isAvailable();642 setBiometricSupported(a.available);643 }644 if (keystore) {645 const k = await keystore.hasMasterKey();646 setBiometricStored(k.exists);647 }648 } catch { }649 }650 });651652 async function handleEnableBiometrics() {653 setBiometricBusy(true);654 setBiometricError(null);655 try {656 const keyRes = (await sendMessage({ type: "GET_DERIVED_KEY" })) as {657 ok: boolean;658 data?: { masterKeyHex: string };659 error?: string;660 };661 if (!keyRes.ok || !keyRes.data?.masterKeyHex) {662 setBiometricError(keyRes.error === "vault_locked"663 ? "unlock the vault first"664 : "couldn't read derived key");665 return;666 }667 const keystore = nativePlugin<{ storeMasterKey: (p: { masterKeyHex: string }) => Promise<void> }>("KeystorePlugin");668 if (!keystore) {669 setBiometricError("biometric plugin unavailable");670 return;671 }672 await keystore.storeMasterKey({ masterKeyHex: keyRes.data.masterKeyHex });673 await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: true } });674 setBiometricStored(true);675 } catch (e) {676 setBiometricError(e instanceof Error ? e.message : "failed to enable biometrics");677 } finally {678 setBiometricBusy(false);679 }680 }681682 async function handleDisableBiometrics() {683 setBiometricBusy(true);684 setBiometricError(null);685 try {686 const keystore = nativePlugin<{ clearMasterKey: () => Promise<void> }>("KeystorePlugin");687 if (keystore) await keystore.clearMasterKey();688 await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: false } });689 setBiometricStored(false);690 } catch (e) {691 setBiometricError(e instanceof Error ? e.message : "failed to disable biometrics");692 } finally {693 setBiometricBusy(false);694 }695 }696697 async function handleChangePassword() {698 setChangePwError(null);699 if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; }700 if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; }701 if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; }702 if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; }703 setChangePwSaving(true);704 const res = await sendMessage({705 type: "CHANGE_PASSWORD",706 payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() },707 }) as { ok: boolean; error?: string };708 if (res.ok) {709 setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");710 setShowChangePw(false);711 712 713 714 715 await chrome.storage?.local?.set?.({ peach_recovery_pending: "1" });716 setShowNewRecoveryWords(true);717 } else {718 setChangePwError(res.error ?? "failed to change password");719 }720 setChangePwSaving(false);721 }722723 const [copied, setCopied] = createSignal(false);724725 async function copyLicenseKey(key: string) {726 await navigator.clipboard.writeText(key);727 setCopied(true);728 setTimeout(() => setCopied(false), 2000);729 }730731 async function handleActivateLicense() {732 const key = licenseKeyInput().trim();733 if (!key) return;734 setActivatingLicense(true);735 setLicenseError(null);736 setLicenseSuccess(null);737 const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } });738 setActivatingLicense(false);739 if ((res as { ok: boolean }).ok) {740 const data = (res as { ok: boolean; data?: LicenseInfo }).data;741 if (data?.valid) {742 setLicenseInfo(data);743 setLicenseKey(key);744 setLicenseSuccess("License activated!");745 setLicenseKeyInput("");746 } else {747 setLicenseError("Invalid license key");748 }749 } else {750 setLicenseError("Activation failed");751 }752 }753754 async function handleTogglePasskeys() {755 const current = passkeyState();756 if (!current || !current.supported) return;757 setPasskeySaving(true);758 const res = await sendMessage({759 type: "SET_PASSKEY_PROVIDER_ENABLED",760 payload: { enabled: !current.enabled },761 });762 setPasskeySaving(false);763 if ((res as { ok: boolean }).ok) {764 setPasskeyState(765 (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null766 );767 }768 }769770 async function handleCheckAllBreaches() {771 setCheckingBreaches(true);772 setBreachResult(null);773 const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });774 setCheckingBreaches(false);775 if ((res as { ok: boolean }).ok) {776 const data = (res as { ok: boolean; data?: Record<string, number> }).data ?? {};777 const breached = Object.values(data).filter((count) => count > 0).length;778 setBreachResult(779 breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean"780 );781 props.onBreachCheckDone();782 }783 }784785786 async function handleSetupDuress() {787 const mainPw = duressMainPassword().trim();788 const duressPw = duressPassword().trim();789 const confirmPw = duressConfirm().trim();790791 if (!mainPw) { setDuressError("enter your main password to confirm"); return; }792 if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; }793 if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; }794 if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; }795796 setDuressSaving(true);797 setDuressError(null);798799 const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };800 if (!verifyRes.ok || !verifyRes.data) {801 setDuressError("incorrect main password");802 setDuressSaving(false);803 return;804 }805806 const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string };807 setDuressSaving(false);808 if (res.ok) {809 setDuressEnabled(true);810 setShowDuressSetup(false);811 setDuressPassword("");812 setDuressConfirm("");813 setDuressMainPassword("");814 setDuressSuccess("Duress password set. If entered, a decoy vault will be shown.");815 setTimeout(() => setDuressSuccess(null), 5000);816 } else {817 setDuressError(818 res.error === "session_expired"819 ? "Vault session expired — lock and unlock Peach, then try again"820 : res.error ?? "failed to set duress password"821 );822 }823 }824825 async function handleClearDeclinedCards() {826 await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } });827 setDeclinedCardCount(0);828 }829830 async function handleCountDuplicates() {831 setDedupRunning(true);832 setDedupDone(null);833 const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number };834 setDedupRunning(false);835 if (res.ok) setDedupCount(res.data ?? 0);836 }837838 async function handleDeduplicate() {839 setDedupRunning(true);840 const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number };841 setDedupRunning(false);842 if (res.ok) {843 setDedupDone(res.data ?? 0);844 setDedupCount(0);845 }846 }847848 async function handleDisableDuress() {849 const mainPw = duressMainPassword().trim();850 if (!mainPw) { setDuressError("enter your main password to confirm"); return; }851852 setDuressSaving(true);853 setDuressError(null);854855 const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };856 if (!verifyRes.ok || !verifyRes.data) {857 setDuressError("incorrect main password");858 setDuressSaving(false);859 return;860 }861862 const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string };863 setDuressSaving(false);864 if (res.ok) {865 setDuressEnabled(false);866 setShowDuressSetup(false);867 setDuressMainPassword("");868 setDuressSuccess("Duress password disabled.");869 setTimeout(() => setDuressSuccess(null), 4000);870 } else {871 setDuressError(res.error ?? "failed to disable duress password");872 }873 }874875 async function handleSubmitFeedback() {876 const message = sanitizeFeedbackMessage(fbMessage());877 if (!message) return;878879 setFeedbackState('sending');880881 try {882 const hasDataPermission = await requestFeedbackDataPermission();883 if (!hasDataPermission) {884 setFeedbackState('idle');885 return;886 }887888 const category = FEEDBACK_CATEGORIES.includes(fbCategory())889 ? fbCategory()890 : FEEDBACK_CATEGORIES[0];891 const res = await fetch('https://api.web3forms.com/submit', {892 method: 'POST',893 headers: { 'Content-Type': 'application/json' },894 body: JSON.stringify({895 access_key: WEB3FORMS_KEY,896 subject: `Peach Feedback - ${category}`,897 message,898 from_name: 'Peach User',899 900 botcheck: '',901 }),902 });903904 if (!res.ok) throw new Error(`HTTP ${res.status}`);905 const data = await res.json() as { success: boolean; message?: string };906 if (!data.success) throw new Error(data.message ?? 'unknown error');907908 setFeedbackState('sent');909 setFbMessage('');910 setFbCategory(FEEDBACK_CATEGORIES[0]);911 } catch (err) {912 console.error('[feedback]', err);913 setFeedbackState('error');914 setTimeout(() => setFeedbackState('idle'), 4000);915 }916 }917918 function renderSecurity() {919 return (920 <div class="peach-settings-body">921 <div class="peach-settings-group">922 <p class="peach-settings-sub-label">LOCKING</p>923 <div class="peach-settings-row">924 <span>Lock on close</span>925 <button926 class={`peach-toggle ${props.lockOnClose ? "is-on" : ""}`}927 onClick={() => void props.onLockOnCloseChange(!props.lockOnClose)}928 aria-label="Toggle lock on close"929 >930 <span class="peach-toggle-knob"></span>931 </button>932 </div>933 <div class="peach-settings-row">934 <span>Auto-lock after</span>935 <select936 class="peach-settings-select"937 value={String(props.autoLockMinutes)}938 onChange={(e) => void props.onAutoLockMinutesChange(Number(e.currentTarget.value))}939 >940 <option value="1">1 minute</option>941 <option value="5">5 minutes</option>942 <option value="15">15 minutes</option>943 <option value="30">30 minutes</option>944 <option value="0">Never</option>945 </select>946 </div>947948 <Show when={isNativePlatform() && biometricSupported()}>949 <p class="peach-settings-sub-label">BIOMETRICS</p>950 <div class="peach-settings-row">951 <span>Unlock with biometrics</span>952 <button953 class={`peach-toggle ${biometricStored() ? "is-on" : ""}`}954 onClick={() => void (biometricStored() ? handleDisableBiometrics() : handleEnableBiometrics())}955 disabled={biometricBusy()}956 aria-label="Toggle biometric unlock"957 >958 <span class="peach-toggle-knob"></span>959 </button>960 </div>961 <Show when={biometricError()}>962 <div class="peach-settings-row peach-settings-row--meta">963 <span class="peach-hint peach-hint-error">{biometricError()}</span>964 </div>965 </Show>966 </Show>967968 <p class="peach-settings-sub-label">BREACH PROTECTION</p>969 <div class="peach-settings-row">970 <span>Breach monitoring</span>971 <button972 class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}973 onClick={() => void props.onBreachMonitoringChange(!props.breachMonitoring)}974 aria-label="Toggle breach monitoring"975 >976 <span class="peach-toggle-knob"></span>977 </button>978 </div>979 <div class="peach-settings-row peach-settings-row--meta">980 <span>Check all passwords</span>981 <button982 class="peach-btn peach-btn--subtle peach-btn--sm"983 onClick={() => void handleCheckAllBreaches()}984 disabled={checkingBreaches()}985 >986 {checkingBreaches() ? "checking..." : "run"}987 </button>988 </div>989 <Show when={breachResult()}>990 <div class="peach-settings-row peach-settings-row--meta">991 <span>Last check</span>992 <span class="peach-settings-value">{breachResult()}</span>993 </div>994 </Show>995996 <p class="peach-settings-sub-label">MASTER PASSWORD</p>997 <div class="peach-settings-row peach-settings-row--meta">998 <span>Master password</span>999 <button1000 class="peach-btn peach-btn--subtle peach-btn--sm"1001 onClick={() => { setShowChangePw((v) => !v); setChangePwError(null); }}1002 >1003 {showChangePw() ? "cancel" : "change"}1004 </button>1005 </div>1006 <Show when={showChangePw()}>1007 <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1008 <input1009 class="peach-settings-input"1010 type="password"1011 placeholder="current password"1012 value={changePwCurrent()}1013 onInput={(e) => setChangePwCurrent((e.target as HTMLInputElement).value)}1014 autocomplete="current-password"1015 />1016 <input1017 class="peach-settings-input"1018 type="password"1019 placeholder="new password (min 12 chars)"1020 value={changePwNew()}1021 onInput={(e) => setChangePwNew((e.target as HTMLInputElement).value)}1022 autocomplete="new-password"1023 />1024 <input1025 class="peach-settings-input"1026 type="password"1027 placeholder="confirm new password"1028 value={changePwConfirm()}1029 onInput={(e) => setChangePwConfirm((e.target as HTMLInputElement).value)}1030 autocomplete="new-password"1031 onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }}1032 />1033 <Show when={changePwError()}>1034 <p class="peach-hint peach-hint-error" style="margin:0">{changePwError()}</p>1035 </Show>1036 <Show when={changePwSuccess()}>1037 <p class="peach-hint" style="margin:0;color:var(--peach-accent)">password changed ✓</p>1038 </Show>1039 <button1040 class="peach-btn peach-btn--accent peach-btn--sm"1041 style="align-self:flex-start"1042 onClick={() => void handleChangePassword()}1043 disabled={changePwSaving() || !changePwCurrent() || !changePwNew() || !changePwConfirm()}1044 >1045 {changePwSaving() ? "saving…" : "save new password"}1046 </button>1047 </div>1048 </Show>10491050 {}1051 <div class="peach-settings-row peach-settings-row--meta">1052 <span>Recovery words</span>1053 <button1054 class="peach-btn peach-btn--subtle peach-btn--sm"1055 onClick={() => {1056 if (showViewWords()) { closeViewWords(); } else { setShowViewWords(true); }1057 }}1058 >1059 {showViewWords() ? "cancel" : "view"}1060 </button>1061 </div>1062 <Show when={showViewWords()}>1063 <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1064 <Show1065 when={viewedWords()}1066 fallback={1067 <>1068 <p class="peach-hint" style="margin:0;color:var(--peach-t2)">1069 Enter your master password to reveal your recovery words.1070 </p>1071 <input1072 class="peach-settings-input"1073 type="password"1074 placeholder="master password"1075 value={viewWordsPassword()}1076 onInput={(e) => setViewWordsPassword((e.target as HTMLInputElement).value)}1077 autocomplete="current-password"1078 onKeyDown={(e) => { if (e.key === "Enter") void handleViewRecoveryWords(); }}1079 />1080 <Show when={viewWordsError()}>1081 <p class="peach-hint peach-hint-error" style="margin:0">{viewWordsError()}</p>1082 </Show>1083 <button1084 class="peach-btn peach-btn--accent peach-btn--sm"1085 style="align-self:flex-start"1086 onClick={() => void handleViewRecoveryWords()}1087 disabled={viewWordsBusy() || !viewWordsPassword()}1088 >1089 {viewWordsBusy() ? "checking…" : "reveal recovery words"}1090 </button>1091 </>1092 }1093 >1094 <div class="peach-recovery-words-grid">1095 <For each={viewedWords()!}>1096 {(word, i) => (1097 <span class="peach-recovery-word"><b>{i() + 1}</b> {word}</span>1098 )}1099 </For>1100 </div>1101 <button1102 class="peach-btn peach-btn--subtle peach-btn--sm"1103 style="align-self:flex-start"1104 onClick={closeViewWords}1105 >done</button>1106 </Show>1107 </div>1108 </Show>11091110 <p class="peach-settings-sub-label">1111 DURESS PASSWORD1112 <Show when={!isPro()}>1113 <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>1114 </Show>1115 </p>1116 <div class="peach-settings-row peach-settings-row--top">1117 <div class="peach-settings-row-main">1118 <span>1119 Duress password1120 <Show when={!isPro()}>1121 <span class="peach-pro-badge">Pro</span>1122 </Show>1123 </span>1124 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">1125 If entered under coercion, shows a decoy empty vault instead of your real one.1126 </span>1127 </div>1128 <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>1129 {duressEnabled() ? "on" : "off"}1130 </div>1131 </div>11321133 <Show when={duressEnabled() && !showDuressSetup()}>1134 <div class="peach-settings-row peach-settings-row--meta">1135 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1136 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.1137 </span>1138 </div>1139 </Show>11401141 <Show when={duressSuccess()}>1142 <div class="peach-settings-row peach-settings-row--meta">1143 <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>1144 </div>1145 </Show>11461147 <Show when={!showDuressSetup()}>1148 <div class="peach-settings-row peach-settings-row--meta">1149 <button1150 class="peach-btn peach-btn--subtle peach-btn--sm"1151 onClick={() => {1152 1153 1154 1155 1156 1157 if (!isPro() && !duressEnabled()) {1158 props.onProGate?.("duress_vault");1159 setActiveCard("pro");1160 return;1161 }1162 setShowDuressSetup(true);1163 setDuressError(null);1164 setDuressSuccess(null);1165 setDuressPassword("");1166 setDuressConfirm("");1167 setDuressMainPassword("");1168 }}1169 >1170 {duressEnabled() ? "Change / disable" : "Set up"}1171 </button>1172 </div>1173 </Show>11741175 <Show when={showDuressSetup()}>1176 <div class="peach-duress-form">1177 <label class="peach-label">main password (to confirm)</label>1178 <input1179 class="peach-settings-input"1180 type="password"1181 placeholder="your current master password"1182 autocomplete="current-password"1183 value={duressMainPassword()}1184 onInput={(e) => setDuressMainPassword(e.currentTarget.value)}1185 />1186 <Show when={!duressEnabled()}>1187 <label class="peach-label">new duress password</label>1188 <input1189 class="peach-settings-input"1190 type="password"1191 placeholder="duress password (min 8 chars)"1192 autocomplete="new-password"1193 value={duressPassword()}1194 onInput={(e) => setDuressPassword(e.currentTarget.value)}1195 />1196 <label class="peach-label">confirm duress password</label>1197 <input1198 class="peach-settings-input"1199 type="password"1200 placeholder="confirm duress password"1201 autocomplete="new-password"1202 value={duressConfirm()}1203 onInput={(e) => setDuressConfirm(e.currentTarget.value)}1204 />1205 </Show>12061207 <Show when={duressError()}>1208 <p class="peach-hint peach-hint-error">{duressError()}</p>1209 </Show>12101211 <div class="peach-duress-actions">1212 <button1213 class="peach-btn peach-btn--subtle peach-btn--sm"1214 onClick={() => { setShowDuressSetup(false); setDuressError(null); }}1215 >1216 cancel1217 </button>1218 <Show when={duressEnabled()}>1219 <button1220 class="peach-btn peach-btn--ghost peach-btn--sm"1221 style="color:var(--peach-breach)"1222 onClick={() => void handleDisableDuress()}1223 disabled={duressSaving()}1224 >1225 {duressSaving() ? "disabling..." : "disable duress"}1226 </button>1227 </Show>1228 <Show when={!duressEnabled()}>1229 <button1230 class="peach-btn peach-btn--accent peach-btn--sm"1231 onClick={() => void handleSetupDuress()}1232 disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}1233 >1234 {duressSaving() ? "saving..." : "save"}1235 </button>1236 </Show>1237 </div>1238 </div>1239 </Show>12401241 <p class="peach-settings-sub-label">1242 CANARY ENTRIES1243 <Show when={!isPro()}>1244 <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>1245 </Show>1246 </p>1247 <div class="peach-settings-row peach-settings-row--top">1248 <div class="peach-settings-row-main">1249 <span>1250 Canary entries1251 <Show when={!isPro()}>1252 <span class="peach-pro-badge">Pro</span>1253 </Show>1254 </span>1255 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1256 Silently pings a webhook when an entry is accessed. Configure canary on any entry in your vault.1257 </span>1258 </div>1259 <span class="peach-settings-value" style="flex-shrink:0">1260 {canaryEntries().length > 0 ? `${canaryEntries().length} active` : "none"}1261 </span>1262 </div>1263 <Show when={canaryEntries().length > 0}>1264 <For each={canaryEntries()}>1265 {(entry) => (1266 <div class="peach-settings-row peach-settings-row--meta">1267 <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0">{entry.title}</span>1268 <button1269 class="peach-btn peach-btn--subtle peach-btn--sm"1270 onClick={() => void handleTestCanary(entry)}1271 disabled={canaryTestingId() === entry.id}1272 style="flex-shrink:0"1273 >1274 {canaryTestingId() === entry.id1275 ? "sending…"1276 : canaryTestResult()?.id === entry.id1277 ? (canaryTestResult()!.ok ? "sent ✓" : "failed ✗")1278 : "test"}1279 </button>1280 </div>1281 )}1282 </For>1283 </Show>12841285 <p class="peach-settings-sub-label">PASSKEYS</p>1286 <div class="peach-settings-row peach-settings-row--top">1287 <div class="peach-settings-row-main">1288 <span>Intercept passkey requests</span>1289 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">1290 Peach can handle passkey sign-in and creation on supported Chromium browsers.1291 </span>1292 </div>1293 <button1294 class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}1295 onClick={() => void handleTogglePasskeys()}1296 aria-label="Toggle passkey interception"1297 disabled={!passkeyState()?.supported || passkeySaving()}1298 >1299 <span class="peach-toggle-knob"></span>1300 </button>1301 </div>1302 <div class="peach-settings-row peach-settings-row--meta">1303 <span>Status</span>1304 <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>1305 </div>1306 <div class="peach-settings-row peach-settings-row--meta">1307 <span>Browser</span>1308 <span class="peach-settings-value">1309 {passkeyState()?.browserLabel ?? "Unknown"}1310 {passkeyState()?.supported ? " ✓" : " (interception unavailable)"}1311 </span>1312 </div>1313 <div class="peach-info-notice" style="margin-top:8px">1314 Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.1315 </div>1316 <Show when={passkeyState()?.conflict}>1317 <div class="peach-settings-row peach-settings-row--meta">1318 <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>1319 </div>1320 </Show>1321 </div>1322 </div>1323 );1324 }13251326 function renderVault() {1327 return (1328 <div class="peach-settings-body">1329 <div class="peach-settings-group">1330 <p class="peach-settings-sub-label">ENTRIES</p>1331 <div class="peach-settings-row peach-settings-row--meta">1332 <span>Entries</span>1333 <span class="peach-settings-value">{props.entriesCount}</span>1334 </div>1335 <div class="peach-settings-row peach-settings-row--meta">1336 <span>Duplicates</span>1337 <Show when={dedupDone() !== null} fallback={1338 <button1339 class="peach-btn peach-btn--subtle peach-btn--sm"1340 onClick={() => void handleCountDuplicates()}1341 disabled={dedupRunning()}1342 >1343 {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}1344 </button>1345 }>1346 <span class="peach-settings-value" style="color:var(--peach-sync-ok)">1347 {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}1348 </span>1349 </Show>1350 </div>1351 <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>1352 <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">1353 <div class="peach-restore-warning">1354 {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.1355 </div>1356 <div style="display:flex;gap:6px">1357 <button1358 class="peach-btn peach-btn--subtle peach-btn--sm"1359 onClick={() => setDedupCount(null)}1360 >1361 cancel1362 </button>1363 <button1364 class="peach-btn peach-btn--accent peach-btn--sm"1365 onClick={() => void handleDeduplicate()}1366 disabled={dedupRunning()}1367 >1368 {dedupRunning() ? "removing..." : "remove duplicates"}1369 </button>1370 </div>1371 </div>1372 </Show>13731374 <p class="peach-settings-sub-label">MAINTENANCE</p>1375 <div class="peach-settings-row peach-settings-row--meta">1376 <span>Trash auto-delete</span>1377 <select1378 class="peach-settings-select"1379 value={String(props.trashRetentionDays)}1380 onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}1381 >1382 <option value="7">7 days</option>1383 <option value="30">30 days</option>1384 <option value="90">90 days</option>1385 <option value="0">Never</option>1386 </select>1387 </div>1388 <div class="peach-settings-row peach-settings-row--meta">1389 <span>Advanced Vault Health</span>1390 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>1391 open1392 </button>1393 </div>1394 <Show when={declinedCardCount() > 0}>1395 <div class="peach-settings-row peach-settings-row--meta">1396 <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>1397 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>1398 Clear1399 </button>1400 </div>1401 </Show>14021403 <p class="peach-settings-sub-label">DATA</p>1404 <div class="peach-settings-row peach-settings-row--meta">1405 <span>Import passwords</span>1406 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>1407 open1408 </button>1409 </div>1410 <div class="peach-settings-row peach-settings-row--meta">1411 <span>Export backup</span>1412 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onExport}>1413 open1414 </button>1415 </div>1416 <div class="peach-settings-row peach-settings-row--meta">1417 <span>1418 Peach Codex1419 <Show when={!isPro()}>1420 <span class="peach-pro-badge">Pro</span>1421 </Show>1422 </span>1423 <button1424 class="peach-btn peach-btn--subtle peach-btn--sm"1425 onClick={() => props.onOpenCodexGenerate?.()}1426 >1427 open1428 </button>1429 </div>1430 {1431}1432 <div class="peach-settings-row peach-settings-row--meta">1433 <span>Restore from Codex</span>1434 <button1435 class="peach-btn peach-btn--subtle peach-btn--sm"1436 onClick={() => props.onOpenCodexRestore?.()}1437 >1438 open1439 </button>1440 </div>14411442 <span class="peach-settings-sub-label">VAULT HISTORY</span>1443 <Show when={historyLoading()}>1444 <p class="peach-settings-hint">Loading…</p>1445 </Show>1446 <Show when={!historyLoading() && historySnapshots().length === 0}>1447 <p class="peach-settings-hint">1448 No snapshots yet. Peach saves automatically before every change —1449 saves, syncs, and imports.1450 </p>1451 </Show>1452 <Show when={!historyLoading() && historySnapshots().length > 0}>1453 <p class="peach-settings-hint">1454 Restoring locks your vault. Unlock with the password that1455 encrypted that snapshot to confirm.1456 </p>1457 <div class="peach-history-list">1458 <For each={historySnapshots().slice(0, historyShowAll() ? undefined : 3)}>1459 {(snap) => (1460 <div class="peach-history-row">1461 <div class="peach-history-meta">1462 <span class="peach-history-time">1463 {formatSnapshotTime(snap.savedAt)}1464 </span>1465 <span class="peach-history-sub">1466 {[1467 snap.entryCount !== null1468 ? `up to ${snap.entryCount} ${snap.entryCount === 1 ? "entry" : "entries"}`1469 : null,1470 reasonLabel(snap.reason),1471 ]1472 .filter(Boolean)1473 .join(" · ")}1474 </span>1475 </div>1476 <button1477 class={`peach-btn peach-btn--subtle peach-btn--sm peach-history-btn${1478 historyRestoring() === snap.id ? " is-busy" : ""1479 }`}1480 onClick={() => void handleRestoreVaultSnapshot(snap.id)}1481 disabled={historyRestoring() !== null}1482 >1483 <Show when={historyRestoring() === snap.id} fallback="Restore">1484 …1485 </Show>1486 </button>1487 </div>1488 )}1489 </For>1490 </div>1491 <Show when={historySnapshots().length > 3}>1492 <button1493 class="peach-settings-advanced-toggle"1494 onClick={() => setHistoryShowAll((v) => !v)}1495 aria-expanded={historyShowAll()}1496 >1497 <span class="peach-settings-hint" style="margin:0">1498 {historyShowAll() ? "show less" : `${historySnapshots().length - 3} older`}1499 </span>1500 <span class="peach-settings-advanced-chevron">1501 {historyShowAll() ? "▲" : "▼"}1502 </span>1503 </button>1504 </Show>1505 <p class="peach-history-footer">1506 {historySnapshots().length} of 10 snapshots1507 </p>1508 </Show>15091510 <button1511 class="peach-settings-advanced-toggle"1512 onClick={() => setShowVaultAdvanced((v) => !v)}1513 aria-expanded={showVaultAdvanced()}1514 >1515 <span class="peach-settings-sub-label" style="margin:0">ADVANCED</span>1516 <span class="peach-settings-advanced-chevron">1517 {showVaultAdvanced() ? "▲" : "▼"}1518 </span>1519 </button>15201521 <Show when={showVaultAdvanced()}>1522 <div class="peach-danger-card">1523 <div class="peach-danger-card-head">1524 <span class="peach-danger-card-glyph" aria-hidden="true">⟳</span>1525 <h3 class="peach-danger-card-title">Replace vault from backup</h3>1526 </div>1527 <p class="peach-danger-card-copy">1528 Wipes the current vault and restores from a backup file.1529 Recoverable only if your backup is good.1530 </p>1531 <button1532 class="peach-danger-card-button"1533 onClick={() => props.onOpenFileImport?.()}1534 >1535 Choose backup file1536 </button>1537 </div>15381539 <Show when={props.onOpenPanic}>1540 <div class="peach-danger-card peach-danger-card--hazard">1541 <div class="peach-danger-card-head">1542 <span class="peach-danger-card-glyph" aria-hidden="true">⚠</span>1543 <h3 class="peach-danger-card-title">Destroy vault</h3>1544 </div>1545 <p class="peach-danger-card-copy">1546 Permanently wipes every entry, setting, and license on this1547 device. There is no recovery.1548 </p>1549 <button1550 class="peach-danger-card-button"1551 onClick={() => props.onOpenPanic?.()}1552 >1553 Begin destruction1554 </button>1555 </div>1556 </Show>1557 </Show>1558 </div>1559 </div>1560 );1561 }15621563 function renderSync() {1564 return (1565 <div class="peach-settings-body">1566 <div class="peach-settings-group">1567 <div class="peach-settings-row peach-settings-row--meta">1568 <span>Status</span>1569 <span class="peach-settings-value" style="display:inline-flex;align-items:center;gap:8px">1570 {isPro() ? "Pro" : "Free"}1571 <span class={`stk-pip ${isPro() ? "is-on" : ""}`} aria-hidden="true" />1572 </span>1573 </div>15741575 {}1576 <p class="peach-settings-sub-label" style="margin-top:10px">PAIR A DEVICE</p>1577 {15781579}1580 <button1581 class="peach-sync-pair-option"1582 onClick={() => props.onOpenSyncPairing?.("code")}1583 >1584 <span class="peach-sync-pair-option-icon" aria-hidden="true">1585 <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">1586 <rect x="6" y="2.5" width="8" height="15" rx="1.6" />1587 <path d="M9 15 L11 15" />1588 </svg>1589 </span>1590 <span class="peach-sync-pair-option-main">1591 <span class="peach-sync-pair-option-title">Phone or tablet</span>1592 <span class="peach-sync-pair-option-sub">Enter the pairing code your phone shows — both on the same Wi-Fi</span>1593 </span>1594 <span class="peach-sync-pair-option-chevron" aria-hidden="true">›</span>1595 </button>1596 <Show when={!isPro()}>1597 <p class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2);margin:8px 2px 0">1598 At least one device needs Peach Pro — pair with a Pro device and it carries over to here.1599 </p>1600 </Show>16011602 <p class="peach-settings-sub-label" style="margin-top:12px">CONNECTION</p>1603 <div class="peach-settings-row peach-settings-row--top">1604 <div class="peach-settings-row-main">1605 <span>Use local network only (no relay)</span>1606 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1607 Sync will only work when your devices share the same local network.1608 It won't work over VPN or when your devices are in different locations.1609 </span>1610 </div>1611 <label class="peach-switch">1612 <input1613 type="checkbox"1614 checked={lanOnly()}1615 disabled={relaySaving()}1616 onChange={(event) => {1617 const enabled = (event.target as HTMLInputElement).checked;1618 setLanOnly(enabled);1619 void saveRelaySettings(enabled);1620 }}1621 />1622 <span class="peach-switch-track" />1623 </label>1624 </div>1625 <label class="peach-label" for="sync-relay-url">mesh relay URL</label>1626 <input1627 id="sync-relay-url"1628 class="peach-underline-input"1629 type="url"1630 inputmode="url"1631 autocomplete="off"1632 spellcheck={false}1633 value={relayUrl()}1634 disabled={relaySaving()}1635 onInput={(event) => setRelayUrl((event.target as HTMLInputElement).value)}1636 onKeyDown={(event) => {1637 if (event.key === "Enter") void saveRelaySettings();1638 }}1639 />1640 <div class="peach-settings-row peach-settings-row--meta">1641 <span class="peach-settings-value" style={`color:${relayStatus()?.startsWith("Use") ? "var(--peach-breach)" : "var(--peach-t2)"}`}>1642 {relayStatus() ?? "Only secure wss:// relays are accepted."}1643 </span>1644 <button1645 class="peach-btn peach-btn--subtle peach-btn--sm"1646 disabled={relaySaving()}1647 onClick={() => void saveRelaySettings()}1648 >{relaySaving() ? "saving…" : "save"}</button>1649 </div>16501651 {}1652 <Show when={pairedDevices().length > 0}>1653 <p class="peach-settings-sub-label" style="margin-top:12px">PAIRED DEVICES</p>1654 <For each={pairedDevices()}>1655 {(d) => (1656 <div class="peach-settings-row peach-settings-row--top">1657 <div class="peach-settings-row-main">1658 <span>{d.deviceName ?? "Unnamed device"}</span>1659 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1660 {deviceTypeLabel(d.deviceType)} · {syncRelativeTime(d.lastSyncedAt)}{isSyncStale(d) ? " · not seen recently" : ""}1661 </span>1662 </div>1663 <Show1664 when={confirmUnpair() === d.deviceId}1665 fallback={1666 <button1667 class="peach-btn peach-btn--subtle peach-btn--sm"1668 onClick={() => setConfirmUnpair(d.deviceId)}1669 >remove</button>1670 }1671 >1672 <div style="display:flex;gap:6px;flex-shrink:0">1673 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => setConfirmUnpair(null)}>cancel</button>1674 <button1675 class="peach-btn peach-btn--sm"1676 style="background:var(--peach-breach);border-color:var(--peach-breach)"1677 onClick={() => void unpairDevice(d.deviceId)}1678 >remove</button>1679 </div>1680 </Show>1681 </div>1682 )}1683 </For>1684 <div class="peach-settings-row peach-settings-row--meta">1685 <span class="peach-settings-value" style="color:var(--peach-t2)">{syncFlash() ?? ""}</span>1686 <button1687 class="peach-btn peach-btn--subtle peach-btn--sm"1688 disabled={syncStatus() === "syncing"}1689 onClick={() => void syncNow()}1690 >{syncStatus() === "syncing" ? "syncing…" : "sync now"}</button>1691 </div>1692 </Show>1693 {}1694 <Show when={pendingVouched().length > 0}>1695 <p class="peach-settings-sub-label">1696 PENDING DEVICES1697 <span class="peach-pro-badge" style="background:#C8A840;color:#1a1a1a">{pendingVouched().length}</span>1698 </p>1699 <For each={pendingVouched()}>1700 {(dev) => (1701 <div class="peach-settings-row peach-settings-row--top">1702 <div class="peach-settings-row-main">1703 <span>{dev.deviceName}</span>1704 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1705 Was added by another of your devices. Allow it to sync with this vault?1706 </span>1707 </div>1708 <div style="display:flex;gap:6px;flex-shrink:0">1709 <button1710 class="peach-btn peach-btn--sm"1711 disabled={vouchedBusy() === dev.deviceId}1712 onClick={() => void confirmVouched(dev.deviceId)}1713 >Allow</button>1714 <button1715 class="peach-btn peach-btn--subtle peach-btn--sm"1716 disabled={vouchedBusy() === dev.deviceId}1717 onClick={() => void rejectVouched(dev.deviceId)}1718 >Reject</button>1719 </div>1720 </div>1721 )}1722 </For>1723 </Show>17241725 {}1726 <p class="peach-settings-sub-label" style="margin-top:12px">RECOVERY</p>1727 <Show1728 when={rotateConfirmOpen()}1729 fallback={1730 <div class="peach-settings-row peach-settings-row--top">1731 <div class="peach-settings-row-main">1732 <span>Rotate sync identity</span>1733 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1734 Use this if a paired device was lost or stolen. Re-keys the vault so the old device's key is useless.1735 </span>1736 </div>1737 <button1738 class="peach-btn peach-btn--subtle peach-btn--sm"1739 style="color:var(--peach-breach);border-color:color-mix(in srgb,var(--peach-breach) 40%,transparent)"1740 onClick={() => { setRotateError(null); setRotateConfirmOpen(true); }}1741 >rotate</button>1742 </div>1743 }1744 >1745 <div class="peach-info-notice" style="margin-top:8px;border-color:color-mix(in srgb,var(--peach-breach) 40%,transparent)">1746 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.1747 <Show when={rotateError()}>1748 <div style="color:var(--peach-breach);margin-top:6px">{rotateError()}</div>1749 </Show>1750 <div style="display:flex;gap:6px;margin-top:10px">1751 <button1752 class="peach-btn peach-btn--subtle peach-btn--sm"1753 disabled={rotating()}1754 onClick={() => setRotateConfirmOpen(false)}1755 >Cancel</button>1756 <button1757 class="peach-btn peach-btn--sm"1758 style="background:var(--peach-breach);border-color:var(--peach-breach)"1759 disabled={rotating()}1760 onClick={() => void doRotateSyncIdentity()}1761 >{rotating() ? "Rotating…" : "Rotate sync identity"}</button>1762 </div>1763 </div>1764 </Show>1765 </div>1766 </div>1767 );1768 }17691770 function renderAppearance() {1771 const sizeOptions: Array<{ id: "medium" | "large"; label: string }> = [1772 { id: "medium", label: "Default" },1773 { id: "large", label: "Large" },1774 ];1775 return (1776 <div class="peach-settings-body">1777 <div class="peach-settings-group">1778 <p class="peach-settings-sub-label">THEME</p>1779 <div class="peach-settings-row peach-settings-row--meta">1780 <span>Theme</span>1781 <button1782 class="peach-btn peach-btn--subtle peach-btn--sm"1783 onClick={() =>1784 props.onThemeChange(props.theme === "dark" ? "light" : "dark")1785 }1786 >1787 {props.theme === "dark" ? "Switch to light" : "Switch to dark"}1788 </button>1789 </div>17901791 <p class="peach-settings-sub-label">ACCENT</p>1792 <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">1793 <span>Accent</span>1794 <div class="peach-accent-picker">1795 <For each={ACCENT_COLORS}>1796 {(color) => (1797 <button1798 class="peach-accent-swatch peach-swatch"1799 classList={{ "is-active": props.accentColor === color.name }}1800 style={{ background: color.hex }}1801 onClick={() => void props.onAccentChange(color.name)}1802 aria-label={color.name}1803 title={color.name}1804 />1805 )}1806 </For>1807 </div>1808 </div>18091810 <p class="peach-settings-sub-label">TEXT SIZE</p>1811 <div class="peach-settings-row peach-settings-row--meta">1812 <span>Size</span>1813 <div class="peach-pill-group">1814 <For each={sizeOptions}>1815 {(opt) => (1816 <button1817 class="peach-btn peach-btn--ghost peach-btn--sm"1818 classList={{ "is-active": props.textSize === opt.id }}1819 onClick={() => void props.onTextSizeChange(opt.id)}1820 >1821 {opt.label}1822 </button>1823 )}1824 </For>1825 </div>1826 </div>18271828 <p class="peach-settings-sub-label">MOTION</p>1829 <div class="peach-settings-row peach-settings-row--meta">1830 <span>Reduce motion</span>1831 <button1832 class="peach-btn peach-btn--subtle peach-btn--sm"1833 onClick={() => void props.onReduceMotionChange(!props.reduceMotion)}1834 >1835 {props.reduceMotion ? "On" : "Off"}1836 </button>1837 </div>1838 </div>1839 </div>1840 );1841 }18421843 function renderShortcuts() {1844 const [capturingAction, setCapturingAction] = createSignal<ShortcutAction | null>(null);1845 const [conflictMsg, setConflictMsg] = createSignal<string | null>(null);18461847 function startCapture(action: ShortcutAction) {1848 setConflictMsg(null);1849 setCapturingAction(action);1850 }1851 function cancelCapture() {1852 setCapturingAction(null);1853 setConflictMsg(null);1854 }1855 function commitBinding(action: ShortcutAction, binding: Keybinding) {1856 const conflict = findConflict(props.shortcuts, binding, action);1857 if (conflict) {1858 const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict);1859 setConflictMsg(`Already bound to "${other?.label ?? conflict}"`);1860 return;1861 }1862 const next = { ...props.shortcuts, [action]: binding };1863 setCapturingAction(null);1864 setConflictMsg(null);1865 void props.onShortcutsChange(next);1866 }1867 function resetAction(action: ShortcutAction) {1868 const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] };1869 void props.onShortcutsChange(next);1870 }1871 function resetAll() {1872 void props.onShortcutsChange({ ...DEFAULT_BINDINGS });1873 }18741875 1876 1877 function handleCaptureKey(e: KeyboardEvent) {1878 e.preventDefault();1879 e.stopPropagation();1880 if (e.key === "Escape") { cancelCapture(); return; }1881 const binding = keybindingFromEvent(e);1882 if (!binding) return; 1883 const action = capturingAction();1884 if (action) commitBinding(action, binding);1885 }18861887 return (1888 <div class="peach-settings-body">1889 <div class="peach-settings-group peach-shortcuts-group">1890 <p class="peach-settings-sub-label">ENTRY VIEW</p>1891 <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry")}>1892 {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1893 </For>1894 </div>18951896 <div class="peach-settings-group peach-shortcuts-group">1897 <p class="peach-settings-sub-label">ANYWHERE IN POPUP</p>1898 <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global")}>1899 {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1900 </For>1901 </div>19021903 <Show when={conflictMsg()}>1904 <div class="peach-shortcuts-conflict">{conflictMsg()}</div>1905 </Show>19061907 <div class="peach-settings-group" style="display:flex;flex-direction:column;gap:8px">1908 <div class="peach-settings-row peach-settings-row--meta">1909 <span>Inline autofill on page</span>1910 <a1911 class="peach-settings-link"1912 href="chrome://extensions/shortcuts"1913 onClick={(e) => {1914 e.preventDefault();1915 chrome.tabs.create({ url: "chrome://extensions/shortcuts" });1916 }}1917 >1918 ⌘⇧P — customize ↗1919 </a>1920 </div>1921 <button1922 class="peach-btn peach-btn--subtle peach-btn--sm"1923 style="align-self:flex-start"1924 onClick={resetAll}1925 >1926 Reset all to defaults1927 </button>1928 </div>1929 </div>1930 );19311932 function renderRow(1933 desc: typeof SHORTCUT_DESCRIPTORS[number],1934 capturing: () => ShortcutAction | null,1935 onStart: (a: ShortcutAction) => void,1936 onCancel: () => void,1937 onReset: (a: ShortcutAction) => void,1938 onKey: (e: KeyboardEvent) => void,1939 ) {1940 const isActive = () => capturing() === desc.action;1941 const binding = () => props.shortcuts[desc.action];1942 const isCustom = () => {1943 const b = binding();1944 const d = DEFAULT_BINDINGS[desc.action];1945 return b.key !== d.key1946 || Boolean(b.ctrl) !== Boolean(d.ctrl)1947 || Boolean(b.meta) !== Boolean(d.meta)1948 || Boolean(b.shift) !== Boolean(d.shift)1949 || Boolean(b.alt) !== Boolean(d.alt);1950 };1951 return (1952 <div class="peach-shortcut-row">1953 <span class="peach-shortcut-row-label">{desc.label}</span>1954 <button1955 class="peach-shortcut-chip"1956 classList={{ "is-capturing": isActive(), "is-custom": isCustom() }}1957 onClick={() => isActive() ? onCancel() : onStart(desc.action)}1958 onKeyDown={(e) => { if (isActive()) onKey(e); }}1959 ref={(el) => {1960 1961 if (el) {1962 queueMicrotask(() => { if (isActive()) el.focus(); });1963 }1964 }}1965 >1966 {isActive() ? "Press a key…" : formatKeybinding(binding())}1967 </button>1968 <Show when={isCustom() && !isActive()}>1969 <button1970 class="peach-shortcut-reset"1971 title="Reset to default"1972 onClick={() => onReset(desc.action)}1973 >1974 ↺1975 </button>1976 </Show>1977 </div>1978 );1979 }1980 }19811982 const PRO_FEATURES: Array<{1983 feature: ProFeature;1984 name: string;1985 desc: string;1986 }> = [1987 { feature: "sync", name: "Peach Sync", desc: "Encrypted · peer-to-peer" },1988 { feature: "codex", name: "Peach Codex", desc: "Restore from paper" },1989 { feature: "passkeys", name: "Passkeys", desc: "Zero-knowledge WebAuthn" },1990 { feature: "advanced_health", name: "Advanced Vault Health", desc: "Reuse · age · exposure" },1991 ];19921993 const PRO_SECURITY_FEATURES: Array<{1994 feature: ProFeature;1995 name: string;1996 desc: string;1997 }> = [1998 { feature: "canary_entry", name: "Canary entries", desc: "Detects vault compromise" },1999 { feature: "duress_vault", name: "Duress vault", desc: "Decoy under coercion" },2000 ];20012002 const [licenseKey, setLicenseKey] = createSignal<string | null>(null);2003 const [showKey, setShowKey] = createSignal(false);20042005 const [deactivating, setDeactivating] = createSignal(false);20062007 async function handleDeactivateLicense() {2008 setDeactivating(true);2009 await sendMessage({ type: "DEACTIVATE_LICENSE" });2010 setLicenseInfo(null);2011 await props.onLicenseChange?.();2012 setDeactivating(false);2013 }20142015 async function handleTestCanary(entry: {id: string; title: string; canary: Canary}) {2016 setCanaryTestingId(entry.id);2017 setCanaryTestResult(null);2018 const res = await sendMessage({2019 type: "FIRE_CANARY_PING",2020 payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger: "test" },2021 }) as { ok: boolean };2022 setCanaryTestResult({ id: entry.id, ok: res.ok });2023 setCanaryTestingId(null);2024 setTimeout(() => setCanaryTestResult((r) => r?.id === entry.id ? null : r), 3000);2025 }20262027 function renderLicense() {2028 async function onActivate() {2029 await handleActivateLicense();2030 if ((licenseInfo()?.valid ?? false) && licenseInfo()?.plan === "pro") {2031 await props.onLicenseChange?.();2032 }2033 }20342035 return (2036 <div class="peach-settings-body">2037 <Show2038 when={isPro()}2039 fallback={2040 <div class="peach-pro-upgrade">2041 <p class="peach-pro-eyebrow">PEACH PRO · ONE-TIME</p>20422043 <h2 class="peach-pro-headline">2044 Every<em>thing,</em><br />every<em>where.</em>2045 </h2>20462047 <p class="peach-pro-subtitle">2048 One key unlocks every Pro feature — for the lifetime of the app.2049 </p>20502051 <div class="peach-pro-grid">2052 <For each={PRO_FEATURES}>2053 {(item, i) => (2054 <div2055 class="peach-pro-grid-item"2056 classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}2057 >2058 <span class="peach-pro-grid-num">{String(i() + 1).padStart(2, "0")}</span>2059 <span class="peach-pro-grid-name">{item.name}</span>2060 <span class="peach-pro-grid-desc">{item.desc}</span>2061 </div>2062 )}2063 </For>2064 </div>20652066 <p class="peach-pro-section-label">ADVANCED SECURITY</p>2067 <div class="peach-pro-grid">2068 <For each={PRO_SECURITY_FEATURES}>2069 {(item, i) => (2070 <div2071 class="peach-pro-grid-item"2072 classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}2073 >2074 <span class="peach-pro-grid-num">{String(PRO_FEATURES.length + i() + 1).padStart(2, "0")}</span>2075 <span class="peach-pro-grid-name">{item.name}</span>2076 <span class="peach-pro-grid-desc">{item.desc}</span>2077 </div>2078 )}2079 </For>2080 </div>20812082 <div class="peach-pro-upgrade-actions">2083 <p class="peach-pro-pricing">2084 $39 <span class="peach-pro-pricing-sub">once · yours forever</span>2085 </p>2086 <a2087 href="https://buy.polar.sh/polar_cl_IXSFIPojx67rcOTksn8LkH5mnU3osPHtYqBIl1eyuIz"2088 target="_blank"2089 rel="noopener noreferrer"2090 class="peach-pro-upgrade-cta"2091 >2092 Get Peach Pro2093 </a>2094 <div class="peach-pro-key-divider"><span>ALREADY HAVE A KEY</span></div>2095 <div class="peach-pro-key-inline">2096 <input2097 class="peach-pro-key-inline-input"2098 type="text"2099 placeholder="PEACH-XXXX-XXXX-XXXX"2100 value={licenseKeyInput()}2101 onInput={(e) => setLicenseKeyInput(e.currentTarget.value)}2102 onKeyDown={(e) => { if (e.key === "Enter") void onActivate(); }}2103 autocomplete="off"2104 spellcheck={false}2105 />2106 <button2107 class="peach-pro-key-apply"2108 onClick={() => void onActivate()}2109 disabled={activatingLicense() || !licenseKeyInput().trim()}2110 >2111 {activatingLicense() ? "…" : "APPLY"}2112 </button>2113 </div>2114 <Show when={licenseError()}>2115 <p class="peach-pro-key-error">{licenseError()}</p>2116 </Show>2117 </div>2118 </div>2119 }2120 >2121 <div class="peach-pro-active">2122 <div class="peach-pro-active-hero">2123 <button2124 class="peach-pro-active-mark-wrap"2125 type="button"2126 aria-label="Celebrate"2127 onClick={handleProCelebrationClick}2128 >2129 <PeachMark class="peach-pro-active-mark" />2130 <Show when={justActivated()}>2131 <div class="peach-pro-sparks" aria-hidden="true">2132 <span class="peach-pro-spark" style="--spark-x:0px;--spark-y:-54px;--spark-delay:0ms">✦</span>2133 <span class="peach-pro-spark" style="--spark-x:38px;--spark-y:-38px;--spark-delay:50ms">✦</span>2134 <span class="peach-pro-spark" style="--spark-x:54px;--spark-y:0px;--spark-delay:110ms">·</span>2135 <span class="peach-pro-spark" style="--spark-x:38px;--spark-y:38px;--spark-delay:30ms">✦</span>2136 <span class="peach-pro-spark" style="--spark-x:0px;--spark-y:54px;--spark-delay:80ms">·</span>2137 <span class="peach-pro-spark" style="--spark-x:-38px;--spark-y:38px;--spark-delay:130ms">✦</span>2138 <span class="peach-pro-spark" style="--spark-x:-54px;--spark-y:0px;--spark-delay:55ms">·</span>2139 <span class="peach-pro-spark" style="--spark-x:-38px;--spark-y:-38px;--spark-delay:95ms">✦</span>2140 </div>2141 </Show>2142 </button>2143 <h2 class="peach-pro-active-title">Peach Pro</h2>2144 <p class="peach-pro-active-since">{formatActivatedSince(licenseInfo()?.issued)}</p>2145 <Show when={justActivated()}>2146 <p class="peach-pro-thankyou-text" aria-live="polite">thank you ✦</p>2147 </Show>2148 </div>21492150 <Show when={licenseKey()}>2151 <div class="peach-settings-license">2152 <span class="peach-settings-label">LICENSE KEY</span>2153 <div class="peach-settings-license-row">2154 <Show2155 when={showKey()}2156 fallback={2157 <span class="peach-settings-license-key peach-settings-license-key--masked" aria-label="License key hidden">2158 ••••••••••••••••••••••••••••2159 </span>2160 }2161 >2162 <span class="peach-settings-license-key">{licenseKey()!}</span>2163 </Show>2164 <div class="peach-settings-license-actions">2165 <button2166 class="peach-settings-license-copy"2167 title="Copy license key"2168 onClick={() => copyLicenseKey(licenseKey()!)}2169 >2170 {copied() ? "✓" : "copy"}2171 </button>2172 <button2173 class="peach-settings-license-toggle"2174 aria-label={showKey() ? "Hide license key" : "Reveal license key"}2175 title={showKey() ? "Hide" : "Reveal"}2176 onClick={() => setShowKey(v => !v)}2177 >2178 <Show2179 when={showKey()}2180 fallback={2181 <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">2182 <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />2183 <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />2184 </svg>2185 }2186 >2187 <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">2188 <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />2189 <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />2190 <line x1="2" y1="2" x2="12" y2="12" />2191 </svg>2192 </Show>2193 </button>2194 </div>2195 </div>2196 </div>2197 </Show>21982199 <div class="peach-pro-active-features">2200 <For each={[...PRO_FEATURES, ...PRO_SECURITY_FEATURES]}>2201 {(item) => (2202 <div class="peach-pro-active-feature">2203 <span class="peach-pro-active-feature-mark">✦</span>2204 <span class="peach-pro-active-feature-name">{item.name}</span>2205 </div>2206 )}2207 </For>2208 </div>22092210 <button2211 class="peach-pro-deactivate"2212 onClick={() => void handleDeactivateLicense()}2213 disabled={deactivating()}2214 >2215 {deactivating() ? "Deactivating…" : "Deactivate"}2216 </button>2217 </div>2218 </Show>2219 </div>2220 );2221 }22222223 function renderFeedback() {2224 return (2225 <div class="peach-settings-body">2226 <div class="peach-settings-group peach-feedback-group">2227 <Show2228 when={feedbackState() === 'sent'}2229 fallback={2230 <>2231 <p class="peach-feedback-intro">2232 Help us improve Peach. Every report goes directly to our inbox.2233 </p>22342235 <p class="peach-settings-sub-label">TYPE</p>2236 <div class="peach-feedback-chips">2237 <For each={FEEDBACK_CATEGORIES}>2238 {(cat) => {2239 const selected = () => fbCategory() === cat;2240 return (2241 <button2242 type="button"2243 class={`peach-feedback-chip ${selected() ? "is-active" : ""}`}2244 aria-pressed={selected()}2245 onClick={() => setFbCategory(cat)}2246 >2247 {cat}2248 </button>2249 );2250 }}2251 </For>2252 </div>22532254 <p class="peach-settings-sub-label">MESSAGE</p>2255 <textarea2256 class="peach-feedback-textarea"2257 placeholder="Describe the issue or idea..."2258 maxLength={MAX_FEEDBACK_MESSAGE_LENGTH}2259 value={fbMessage()}2260 onInput={(e) => setFbMessage(e.currentTarget.value)}2261 />22622263 <p class="peach-feedback-meta">2264 Version {version} · will be included automatically2265 </p>22662267 <button2268 type="button"2269 class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"2270 disabled={!fbMessage().trim() || feedbackState() === 'sending'}2271 onClick={() => void handleSubmitFeedback()}2272 >2273 <Switch>2274 <Match when={feedbackState() === 'idle'}>Send</Match>2275 <Match when={feedbackState() === 'sending'}>Sending...</Match>2276 <Match when={feedbackState() === 'error'}>Failed - try again</Match>2277 </Switch>2278 </button>22792280 <p class="peach-feedback-anon">2281 Sent anonymously · no account required2282 </p>2283 </>2284 }2285 >2286 <div class="peach-feedback-thanks" role="status" aria-live="polite">2287 <p class="peach-feedback-thanks-title">Thank you</p>2288 <p>2289 Your note is in the queue. Someone will process it soon.2290 </p>2291 </div>2292 </Show>2293 </div>2294 </div>2295 );2296 }22972298 const bodies: StackBodies = {2299 protection: renderSecurity(),2300 vault: renderVault(),2301 sync: renderSync(),2302 appearance: renderAppearance(),2303 shortcuts: renderShortcuts(),2304 pro: renderLicense(),2305 feedback: renderFeedback(),2306 };23072308 return (2309 <Show2310 when={showNewRecoveryWords()}2311 fallback={2312 <div2313 class="peach-overlay-view peach-settings-overlay"2314 classList={{ "ov-enter-right": !props.isExiting, "ov-exit-right": !!props.isExiting }}2315 style="display:flex;flex-direction:column"2316 >2317 <StackDeck2318 active={activeCard()}2319 onActiveChange={setActiveCard}2320 onBack={props.onBack}2321 cards={cards()}2322 bodies={bodies}2323 version={version}2324 isFanning={isFanning()}2325 celebratingCardId={justActivated() ? "pro" : null}2326 isPro={isPro()}2327 />2328 </div>2329 }2330 >2331 <RecoveryWordDisplayView onDone={() => setShowNewRecoveryWords(false)} />2332 </Show>2333 );2334}