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 [syncMode, setSyncMode] = createSignal<"public" | "private" | "off">("public");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_SYNC_SETTINGS" })) as {346 ok: boolean;347 data?: { relayUrl: string; mode: "public" | "private" | "off" };348 };349 if (result.ok && result.data) {350 setRelayUrl(result.data.relayUrl);351 setSyncMode(result.data.mode);352 }353 }354 355 async function saveSyncSettings(mode?: "public" | "private" | "off") {356 setRelaySaving(true);357 setRelayStatus(null);358 const result = (await sendMessage({359 type: "SET_SYNC_SETTINGS",360 payload: mode ? { mode } : { relayUrl: relayUrl() },361 })) as {362 ok: boolean;363 error?: string;364 data?: { relayUrl: string; mode: "public" | "private" | "off" };365 };366 if (result.ok && result.data) {367 setRelayUrl(result.data.relayUrl);368 setSyncMode(result.data.mode);369 setRelayStatus(mode ? null : "Saved");370 } else {371 setRelayStatus("Use a valid wss:// relay URL");372 }373 setRelaySaving(false);374 }375 376 const [rotateConfirmOpen, setRotateConfirmOpen] = createSignal(false);377 const [rotating, setRotating] = createSignal(false);378 const [rotateError, setRotateError] = createSignal<string | null>(null);379380 async function loadPendingVouched() {381 const r = (await sendMessage({ type: "GET_PENDING_VOUCHED_DEVICES" })) as {382 ok: boolean;383 data?: PendingVouched[];384 };385 if (r.ok) setPendingVouched(r.data ?? []);386 }387 async function confirmVouched(deviceId: string) {388 setVouchedBusy(deviceId);389 try {390 await sendMessage({ type: "CONFIRM_VOUCHED_DEVICE", payload: { deviceId } });391 await loadPendingVouched();392 } finally {393 setVouchedBusy(null);394 }395 }396 async function rejectVouched(deviceId: string) {397 setVouchedBusy(deviceId);398 try {399 await sendMessage({ type: "REJECT_VOUCHED_DEVICE", payload: { deviceId } });400 await loadPendingVouched();401 } finally {402 setVouchedBusy(null);403 }404 }405 async function doRotateSyncIdentity() {406 setRotateError(null);407 setRotating(true);408 try {409 const r = (await sendMessage({ type: "ROTATE_SYNC_IDENTITY" })) as {410 ok: boolean;411 error?: string;412 };413 if (!r.ok) {414 setRotateError(r.error ?? "Rotation failed.");415 return;416 }417 setRotateConfirmOpen(false);418 419 420 setShowNewRecoveryWords(true);421 } finally {422 setRotating(false);423 }424 }425426 const [dedupCount, setDedupCount] = createSignal<number | null>(null);427 const [dedupRunning, setDedupRunning] = createSignal(false);428 const [dedupDone, setDedupDone] = createSignal<number | null>(null);429430 const [declinedCardCount, setDeclinedCardCount] = createSignal(0);431432 433 434 435 const [historySnapshots, setHistorySnapshots] = createSignal<VaultSnapshot[]>([]);436 const [historyLoading, setHistoryLoading] = createSignal(false);437 const [historyRestoring, setHistoryRestoring] = createSignal<string | null>(null);438 const [historyShowAll, setHistoryShowAll] = createSignal(false);439440 async function loadVaultHistory() {441 setHistoryLoading(true);442 try {443 const res = await sendMessage({ type: "GET_VAULT_HISTORY" });444 const list = (res as { ok: boolean; data?: VaultSnapshot[] }).data ?? [];445 setHistorySnapshots(list);446 } finally {447 setHistoryLoading(false);448 }449 }450451 async function handleRestoreVaultSnapshot(id: string) {452 if (historyRestoring()) return;453 setHistoryRestoring(id);454 const res = await sendMessage({455 type: "RESTORE_VAULT_SNAPSHOT",456 payload: { snapshotId: id },457 });458 setHistoryRestoring(null);459 if ((res as { ok: boolean }).ok) {460 461 462 props.onBack();463 }464 }465466 function formatSnapshotTime(savedAt: number): string {467 const diff = Date.now() - savedAt;468 if (diff < 60_000) return "Just now";469 if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;470 const d = new Date(savedAt);471 const isToday = new Date().toDateString() === d.toDateString();472 const isYest =473 new Date(Date.now() - 86_400_000).toDateString() === d.toDateString();474 const t = d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });475 if (isToday) return `Today ${t}`;476 if (isYest) return `Yesterday ${t}`;477 return `${d.toLocaleDateString([], { month: "short", day: "numeric" })} ${t}`;478 }479480 function reasonLabel(reason: VaultSnapshot["reason"]): string {481 return (482 {483 local_save: "saved",484 sync_received: "synced",485 import: "imported",486 restore: "restored",487 } as const488 )[reason] ?? reason;489 }490491 const [showVaultAdvanced, setShowVaultAdvanced] = createSignal(false);492493 const [showChangePw, setShowChangePw] = createSignal(false);494 const [changePwCurrent, setChangePwCurrent] = createSignal("");495 const [changePwNew, setChangePwNew] = createSignal("");496 const [changePwConfirm, setChangePwConfirm] = createSignal("");497 const [changePwSaving, setChangePwSaving] = createSignal(false);498 const [changePwError, setChangePwError] = createSignal<string | null>(null);499 const [changePwSuccess] = createSignal(false);500 const [showNewRecoveryWords, setShowNewRecoveryWords] = createSignal(false);501502 503 const [showViewWords, setShowViewWords] = createSignal(false);504 const [viewWordsPassword, setViewWordsPassword] = createSignal("");505 const [viewWordsError, setViewWordsError] = createSignal<string | null>(null);506 const [viewedWords, setViewedWords] = createSignal<string[] | null>(null);507 const [viewWordsBusy, setViewWordsBusy] = createSignal(false);508509 async function handleViewRecoveryWords() {510 setViewWordsError(null);511 if (!viewWordsPassword()) {512 setViewWordsError("Enter your master password.");513 return;514 }515 setViewWordsBusy(true);516 try {517 const r = (await sendMessage({518 type: "GET_RECOVERY_WORDS",519 payload: { password: viewWordsPassword() },520 })) as { ok: boolean; data?: string[]; error?: string };521 if (r.ok) {522 setViewedWords(r.data ?? []);523 setViewWordsPassword("");524 return;525 }526 switch (r.error) {527 case "wrong_password":528 setViewWordsError("Incorrect password");529 break;530 case "password_required":531 setViewWordsError("Enter your master password.");532 break;533 case "recovery_not_setup":534 setViewWordsError("Recovery words aren't set up for this vault yet.");535 break;536 default:537 setViewWordsError("Couldn't retrieve recovery words.");538 }539 } finally {540 setViewWordsBusy(false);541 }542 }543 function closeViewWords() {544 setShowViewWords(false);545 setViewWordsPassword("");546 setViewWordsError(null);547 setViewedWords(null);548 }549550 const [duressEnabled, setDuressEnabled] = createSignal(false);551 const [showDuressSetup, setShowDuressSetup] = createSignal(false);552 const [duressPassword, setDuressPassword] = createSignal("");553 const [duressConfirm, setDuressConfirm] = createSignal("");554 const [duressMainPassword, setDuressMainPassword] = createSignal("");555 const [duressSaving, setDuressSaving] = createSignal(false);556 const [duressError, setDuressError] = createSignal<string | null>(null);557 const [duressSuccess, setDuressSuccess] = createSignal<string | null>(null);558559 const [canaryEntries, setCanaryEntries] = createSignal<Array<{id: string; title: string; canary: Canary}>>([]);560561 const [canaryTestingId, setCanaryTestingId] = createSignal<string | null>(null);562 const [canaryTestResult, setCanaryTestResult] = createSignal<{id: string; ok: boolean} | null>(null);563564 const [fbCategory, setFbCategory] = createSignal("Bug report");565 const [fbMessage, setFbMessage] = createSignal("");566 type FeedbackState = 'idle' | 'sending' | 'sent' | 'error';567 const [feedbackState, setFeedbackState] = createSignal<FeedbackState>('idle');568569 createEffect(() => {570 if (props.upgradeHighlight) {571 setActiveCard("pro");572 }573 });574575 onMount(async () => {576 const [licenseRes, passkeyRes, duressRes, settingsRes, keyRes] = await Promise.all([577 sendMessage({ type: "GET_LICENSE" }),578 sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }),579 sendMessage({ type: "IS_DURESS_ENABLED" }),580 sendMessage({ type: "GET_SETTINGS" }),581 sendMessage({ type: "GET_LICENSE_KEY" }),582 ]);583584 585 586 void loadVaultHistory();587588 589 590 void loadPendingVouched();591 void loadPairedDevices();592 void loadRelaySettings();593 const onBroadcast = (msg: unknown) => {594 if ((msg as { type?: string } | null)?.type === "VOUCHED_DEVICE_PENDING") {595 void loadPendingVouched();596 }597 };598 chrome.runtime?.onMessage?.addListener(onBroadcast);599 onCleanup(() => {600 try { chrome.runtime?.onMessage?.removeListener(onBroadcast); } catch { }601 });602603 if ((licenseRes as { ok: boolean }).ok) {604 setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null);605 }606607 if ((passkeyRes as { ok: boolean }).ok) {608 setPasskeyState(609 (passkeyRes as { ok: boolean; data?: PasskeyProviderState }).data ?? null610 );611 }612613 if ((duressRes as { ok: boolean }).ok) {614 setDuressEnabled((duressRes as { ok: boolean; data?: boolean }).data ?? false);615 }616617 if ((settingsRes as { ok: boolean }).ok) {618 const sData = (settingsRes as { ok: boolean; data?: { declinedCardFingerprints?: string[] } }).data;619 setDeclinedCardCount(sData?.declinedCardFingerprints?.length ?? 0);620 }621622 if ((keyRes as { ok: boolean }).ok) {623 setLicenseKey((keyRes as { ok: boolean; data?: string }).data ?? null);624 }625626 const entriesRes = await sendMessage({ type: "GET_ENTRIES" });627 if ((entriesRes as { ok: boolean }).ok) {628 const entries = (entriesRes as { ok: boolean; data?: Array<{id: string; title: string; canary?: Canary}> }).data ?? [];629 setCanaryEntries(630 entries631 .filter((e) => e.canary?.webhookUrl)632 .map((e) => ({ id: e.id, title: e.title, canary: e.canary! }))633 );634 }635636637 if (isNativePlatform()) {638 const biometric = nativePlugin<{ isAvailable: () => Promise<{ available: boolean }> }>("BiometricPlugin");639 const keystore = nativePlugin<{ hasMasterKey: () => Promise<{ exists: boolean }> }>("KeystorePlugin");640 try {641 if (biometric) {642 const a = await biometric.isAvailable();643 setBiometricSupported(a.available);644 }645 if (keystore) {646 const k = await keystore.hasMasterKey();647 setBiometricStored(k.exists);648 }649 } catch { }650 }651 });652653 async function handleEnableBiometrics() {654 setBiometricBusy(true);655 setBiometricError(null);656 try {657 const keyRes = (await sendMessage({ type: "GET_DERIVED_KEY" })) as {658 ok: boolean;659 data?: { masterKeyHex: string };660 error?: string;661 };662 if (!keyRes.ok || !keyRes.data?.masterKeyHex) {663 setBiometricError(keyRes.error === "vault_locked"664 ? "unlock the vault first"665 : "couldn't read derived key");666 return;667 }668 const keystore = nativePlugin<{ storeMasterKey: (p: { masterKeyHex: string }) => Promise<void> }>("KeystorePlugin");669 if (!keystore) {670 setBiometricError("biometric plugin unavailable");671 return;672 }673 await keystore.storeMasterKey({ masterKeyHex: keyRes.data.masterKeyHex });674 await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: true } });675 setBiometricStored(true);676 } catch (e) {677 setBiometricError(e instanceof Error ? e.message : "failed to enable biometrics");678 } finally {679 setBiometricBusy(false);680 }681 }682683 async function handleDisableBiometrics() {684 setBiometricBusy(true);685 setBiometricError(null);686 try {687 const keystore = nativePlugin<{ clearMasterKey: () => Promise<void> }>("KeystorePlugin");688 if (keystore) await keystore.clearMasterKey();689 await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: false } });690 setBiometricStored(false);691 } catch (e) {692 setBiometricError(e instanceof Error ? e.message : "failed to disable biometrics");693 } finally {694 setBiometricBusy(false);695 }696 }697698 async function handleChangePassword() {699 setChangePwError(null);700 if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; }701 if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; }702 if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; }703 if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; }704 setChangePwSaving(true);705 const res = await sendMessage({706 type: "CHANGE_PASSWORD",707 payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() },708 }) as { ok: boolean; error?: string };709 if (res.ok) {710 setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");711 setShowChangePw(false);712 713 714 715 716 await chrome.storage?.local?.set?.({ peach_recovery_pending: "1" });717 setShowNewRecoveryWords(true);718 } else {719 setChangePwError(res.error ?? "failed to change password");720 }721 setChangePwSaving(false);722 }723724 const [copied, setCopied] = createSignal(false);725726 async function copyLicenseKey(key: string) {727 await navigator.clipboard.writeText(key);728 setCopied(true);729 setTimeout(() => setCopied(false), 2000);730 }731732 async function handleActivateLicense() {733 const key = licenseKeyInput().trim();734 if (!key) return;735 setActivatingLicense(true);736 setLicenseError(null);737 setLicenseSuccess(null);738 const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } });739 setActivatingLicense(false);740 if ((res as { ok: boolean }).ok) {741 const data = (res as { ok: boolean; data?: LicenseInfo }).data;742 if (data?.valid) {743 setLicenseInfo(data);744 setLicenseKey(key);745 setLicenseSuccess("License activated!");746 setLicenseKeyInput("");747 } else {748 setLicenseError("Invalid license key");749 }750 } else {751 setLicenseError("Activation failed");752 }753 }754755 async function handleTogglePasskeys() {756 const current = passkeyState();757 if (!current || !current.supported) return;758 setPasskeySaving(true);759 const res = await sendMessage({760 type: "SET_PASSKEY_PROVIDER_ENABLED",761 payload: { enabled: !current.enabled },762 });763 setPasskeySaving(false);764 if ((res as { ok: boolean }).ok) {765 setPasskeyState(766 (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null767 );768 }769 }770771 async function handleCheckAllBreaches() {772 setCheckingBreaches(true);773 setBreachResult(null);774 const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });775 setCheckingBreaches(false);776 if ((res as { ok: boolean }).ok) {777 const data = (res as { ok: boolean; data?: Record<string, number> }).data ?? {};778 const breached = Object.values(data).filter((count) => count > 0).length;779 setBreachResult(780 breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean"781 );782 props.onBreachCheckDone();783 }784 }785786787 async function handleSetupDuress() {788 const mainPw = duressMainPassword().trim();789 const duressPw = duressPassword().trim();790 const confirmPw = duressConfirm().trim();791792 if (!mainPw) { setDuressError("enter your main password to confirm"); return; }793 if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; }794 if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; }795 if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; }796797 setDuressSaving(true);798 setDuressError(null);799800 const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };801 if (!verifyRes.ok || !verifyRes.data) {802 setDuressError("incorrect main password");803 setDuressSaving(false);804 return;805 }806807 const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string };808 setDuressSaving(false);809 if (res.ok) {810 setDuressEnabled(true);811 setShowDuressSetup(false);812 setDuressPassword("");813 setDuressConfirm("");814 setDuressMainPassword("");815 setDuressSuccess("Duress password set. If entered, a decoy vault will be shown.");816 setTimeout(() => setDuressSuccess(null), 5000);817 } else {818 setDuressError(819 res.error === "session_expired"820 ? "Vault session expired — lock and unlock Peach, then try again"821 : res.error ?? "failed to set duress password"822 );823 }824 }825826 async function handleClearDeclinedCards() {827 await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } });828 setDeclinedCardCount(0);829 }830831 async function handleCountDuplicates() {832 setDedupRunning(true);833 setDedupDone(null);834 const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number };835 setDedupRunning(false);836 if (res.ok) setDedupCount(res.data ?? 0);837 }838839 async function handleDeduplicate() {840 setDedupRunning(true);841 const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number };842 setDedupRunning(false);843 if (res.ok) {844 setDedupDone(res.data ?? 0);845 setDedupCount(0);846 }847 }848849 async function handleDisableDuress() {850 const mainPw = duressMainPassword().trim();851 if (!mainPw) { setDuressError("enter your main password to confirm"); return; }852853 setDuressSaving(true);854 setDuressError(null);855856 const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };857 if (!verifyRes.ok || !verifyRes.data) {858 setDuressError("incorrect main password");859 setDuressSaving(false);860 return;861 }862863 const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string };864 setDuressSaving(false);865 if (res.ok) {866 setDuressEnabled(false);867 setShowDuressSetup(false);868 setDuressMainPassword("");869 setDuressSuccess("Duress password disabled.");870 setTimeout(() => setDuressSuccess(null), 4000);871 } else {872 setDuressError(res.error ?? "failed to disable duress password");873 }874 }875876 async function handleSubmitFeedback() {877 const message = sanitizeFeedbackMessage(fbMessage());878 if (!message) return;879880 setFeedbackState('sending');881882 try {883 const hasDataPermission = await requestFeedbackDataPermission();884 if (!hasDataPermission) {885 setFeedbackState('idle');886 return;887 }888889 const category = FEEDBACK_CATEGORIES.includes(fbCategory())890 ? fbCategory()891 : FEEDBACK_CATEGORIES[0];892 const res = await fetch('https://api.web3forms.com/submit', {893 method: 'POST',894 headers: { 'Content-Type': 'application/json' },895 body: JSON.stringify({896 access_key: WEB3FORMS_KEY,897 subject: `Peach Feedback - ${category}`,898 message,899 from_name: 'Peach User',900 901 botcheck: '',902 }),903 });904905 if (!res.ok) throw new Error(`HTTP ${res.status}`);906 const data = await res.json() as { success: boolean; message?: string };907 if (!data.success) throw new Error(data.message ?? 'unknown error');908909 setFeedbackState('sent');910 setFbMessage('');911 setFbCategory(FEEDBACK_CATEGORIES[0]);912 } catch (err) {913 console.error('[feedback]', err);914 setFeedbackState('error');915 setTimeout(() => setFeedbackState('idle'), 4000);916 }917 }918919 function renderSecurity() {920 return (921 <div class="peach-settings-body">922 <div class="peach-settings-group">923 <p class="peach-settings-sub-label">LOCKING</p>924 <div class="peach-settings-row">925 <span>Lock on close</span>926 <button927 class={`peach-toggle ${props.lockOnClose ? "is-on" : ""}`}928 onClick={() => void props.onLockOnCloseChange(!props.lockOnClose)}929 aria-label="Toggle lock on close"930 >931 <span class="peach-toggle-knob"></span>932 </button>933 </div>934 <div class="peach-settings-row">935 <span>Auto-lock after</span>936 <select937 class="peach-settings-select"938 value={String(props.autoLockMinutes)}939 onChange={(e) => void props.onAutoLockMinutesChange(Number(e.currentTarget.value))}940 >941 <option value="1">1 minute</option>942 <option value="5">5 minutes</option>943 <option value="15">15 minutes</option>944 <option value="30">30 minutes</option>945 <option value="0">Never</option>946 </select>947 </div>948949 <Show when={isNativePlatform() && biometricSupported()}>950 <p class="peach-settings-sub-label">BIOMETRICS</p>951 <div class="peach-settings-row">952 <span>Unlock with biometrics</span>953 <button954 class={`peach-toggle ${biometricStored() ? "is-on" : ""}`}955 onClick={() => void (biometricStored() ? handleDisableBiometrics() : handleEnableBiometrics())}956 disabled={biometricBusy()}957 aria-label="Toggle biometric unlock"958 >959 <span class="peach-toggle-knob"></span>960 </button>961 </div>962 <Show when={biometricError()}>963 <div class="peach-settings-row peach-settings-row--meta">964 <span class="peach-hint peach-hint-error">{biometricError()}</span>965 </div>966 </Show>967 </Show>968969 <p class="peach-settings-sub-label">BREACH PROTECTION</p>970 <div class="peach-settings-row">971 <span>Breach monitoring</span>972 <button973 class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}974 onClick={() => void props.onBreachMonitoringChange(!props.breachMonitoring)}975 aria-label="Toggle breach monitoring"976 >977 <span class="peach-toggle-knob"></span>978 </button>979 </div>980 <div class="peach-settings-row peach-settings-row--meta">981 <span>Check all passwords</span>982 <button983 class="peach-btn peach-btn--subtle peach-btn--sm"984 onClick={() => void handleCheckAllBreaches()}985 disabled={checkingBreaches()}986 >987 {checkingBreaches() ? "checking..." : "run"}988 </button>989 </div>990 <Show when={breachResult()}>991 <div class="peach-settings-row peach-settings-row--meta">992 <span>Last check</span>993 <span class="peach-settings-value">{breachResult()}</span>994 </div>995 </Show>996997 <p class="peach-settings-sub-label">MASTER PASSWORD</p>998 <div class="peach-settings-row peach-settings-row--meta">999 <span>Master password</span>1000 <button1001 class="peach-btn peach-btn--subtle peach-btn--sm"1002 onClick={() => { setShowChangePw((v) => !v); setChangePwError(null); }}1003 >1004 {showChangePw() ? "cancel" : "change"}1005 </button>1006 </div>1007 <Show when={showChangePw()}>1008 <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1009 <input1010 class="peach-settings-input"1011 type="password"1012 placeholder="current password"1013 value={changePwCurrent()}1014 onInput={(e) => setChangePwCurrent((e.target as HTMLInputElement).value)}1015 autocomplete="current-password"1016 />1017 <input1018 class="peach-settings-input"1019 type="password"1020 placeholder="new password (min 12 chars)"1021 value={changePwNew()}1022 onInput={(e) => setChangePwNew((e.target as HTMLInputElement).value)}1023 autocomplete="new-password"1024 />1025 <input1026 class="peach-settings-input"1027 type="password"1028 placeholder="confirm new password"1029 value={changePwConfirm()}1030 onInput={(e) => setChangePwConfirm((e.target as HTMLInputElement).value)}1031 autocomplete="new-password"1032 onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }}1033 />1034 <Show when={changePwError()}>1035 <p class="peach-hint peach-hint-error" style="margin:0">{changePwError()}</p>1036 </Show>1037 <Show when={changePwSuccess()}>1038 <p class="peach-hint" style="margin:0;color:var(--peach-accent)">password changed ✓</p>1039 </Show>1040 <button1041 class="peach-btn peach-btn--accent peach-btn--sm"1042 style="align-self:flex-start"1043 onClick={() => void handleChangePassword()}1044 disabled={changePwSaving() || !changePwCurrent() || !changePwNew() || !changePwConfirm()}1045 >1046 {changePwSaving() ? "saving…" : "save new password"}1047 </button>1048 </div>1049 </Show>10501051 {}1052 <div class="peach-settings-row peach-settings-row--meta">1053 <span>Recovery words</span>1054 <button1055 class="peach-btn peach-btn--subtle peach-btn--sm"1056 onClick={() => {1057 if (showViewWords()) { closeViewWords(); } else { setShowViewWords(true); }1058 }}1059 >1060 {showViewWords() ? "cancel" : "view"}1061 </button>1062 </div>1063 <Show when={showViewWords()}>1064 <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1065 <Show1066 when={viewedWords()}1067 fallback={1068 <>1069 <p class="peach-hint" style="margin:0;color:var(--peach-t2)">1070 Enter your master password to reveal your recovery words.1071 </p>1072 <input1073 class="peach-settings-input"1074 type="password"1075 placeholder="master password"1076 value={viewWordsPassword()}1077 onInput={(e) => setViewWordsPassword((e.target as HTMLInputElement).value)}1078 autocomplete="current-password"1079 onKeyDown={(e) => { if (e.key === "Enter") void handleViewRecoveryWords(); }}1080 />1081 <Show when={viewWordsError()}>1082 <p class="peach-hint peach-hint-error" style="margin:0">{viewWordsError()}</p>1083 </Show>1084 <button1085 class="peach-btn peach-btn--accent peach-btn--sm"1086 style="align-self:flex-start"1087 onClick={() => void handleViewRecoveryWords()}1088 disabled={viewWordsBusy() || !viewWordsPassword()}1089 >1090 {viewWordsBusy() ? "checking…" : "reveal recovery words"}1091 </button>1092 </>1093 }1094 >1095 <div class="peach-recovery-words-grid">1096 <For each={viewedWords()!}>1097 {(word, i) => (1098 <span class="peach-recovery-word"><b>{i() + 1}</b> {word}</span>1099 )}1100 </For>1101 </div>1102 <button1103 class="peach-btn peach-btn--subtle peach-btn--sm"1104 style="align-self:flex-start"1105 onClick={closeViewWords}1106 >done</button>1107 </Show>1108 </div>1109 </Show>11101111 <p class="peach-settings-sub-label">1112 DURESS PASSWORD1113 <Show when={!isPro()}>1114 <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>1115 </Show>1116 </p>1117 <div class="peach-settings-row peach-settings-row--top">1118 <div class="peach-settings-row-main">1119 <span>1120 Duress password1121 <Show when={!isPro()}>1122 <span class="peach-pro-badge">Pro</span>1123 </Show>1124 </span>1125 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">1126 If entered under coercion, shows a decoy empty vault instead of your real one.1127 </span>1128 </div>1129 <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>1130 {duressEnabled() ? "on" : "off"}1131 </div>1132 </div>11331134 <Show when={duressEnabled() && !showDuressSetup()}>1135 <div class="peach-settings-row peach-settings-row--meta">1136 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1137 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.1138 </span>1139 </div>1140 </Show>11411142 <Show when={duressSuccess()}>1143 <div class="peach-settings-row peach-settings-row--meta">1144 <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>1145 </div>1146 </Show>11471148 <Show when={!showDuressSetup()}>1149 <div class="peach-settings-row peach-settings-row--meta">1150 <button1151 class="peach-btn peach-btn--subtle peach-btn--sm"1152 onClick={() => {1153 1154 1155 1156 1157 1158 if (!isPro() && !duressEnabled()) {1159 props.onProGate?.("duress_vault");1160 setActiveCard("pro");1161 return;1162 }1163 setShowDuressSetup(true);1164 setDuressError(null);1165 setDuressSuccess(null);1166 setDuressPassword("");1167 setDuressConfirm("");1168 setDuressMainPassword("");1169 }}1170 >1171 {duressEnabled() ? "Change / disable" : "Set up"}1172 </button>1173 </div>1174 </Show>11751176 <Show when={showDuressSetup()}>1177 <div class="peach-duress-form">1178 <label class="peach-label">main password (to confirm)</label>1179 <input1180 class="peach-settings-input"1181 type="password"1182 placeholder="your current master password"1183 autocomplete="current-password"1184 value={duressMainPassword()}1185 onInput={(e) => setDuressMainPassword(e.currentTarget.value)}1186 />1187 <Show when={!duressEnabled()}>1188 <label class="peach-label">new duress password</label>1189 <input1190 class="peach-settings-input"1191 type="password"1192 placeholder="duress password (min 8 chars)"1193 autocomplete="new-password"1194 value={duressPassword()}1195 onInput={(e) => setDuressPassword(e.currentTarget.value)}1196 />1197 <label class="peach-label">confirm duress password</label>1198 <input1199 class="peach-settings-input"1200 type="password"1201 placeholder="confirm duress password"1202 autocomplete="new-password"1203 value={duressConfirm()}1204 onInput={(e) => setDuressConfirm(e.currentTarget.value)}1205 />1206 </Show>12071208 <Show when={duressError()}>1209 <p class="peach-hint peach-hint-error">{duressError()}</p>1210 </Show>12111212 <div class="peach-duress-actions">1213 <button1214 class="peach-btn peach-btn--subtle peach-btn--sm"1215 onClick={() => { setShowDuressSetup(false); setDuressError(null); }}1216 >1217 cancel1218 </button>1219 <Show when={duressEnabled()}>1220 <button1221 class="peach-btn peach-btn--ghost peach-btn--sm"1222 style="color:var(--peach-breach)"1223 onClick={() => void handleDisableDuress()}1224 disabled={duressSaving()}1225 >1226 {duressSaving() ? "disabling..." : "disable duress"}1227 </button>1228 </Show>1229 <Show when={!duressEnabled()}>1230 <button1231 class="peach-btn peach-btn--accent peach-btn--sm"1232 onClick={() => void handleSetupDuress()}1233 disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}1234 >1235 {duressSaving() ? "saving..." : "save"}1236 </button>1237 </Show>1238 </div>1239 </div>1240 </Show>12411242 <p class="peach-settings-sub-label">1243 CANARY ENTRIES1244 <Show when={!isPro()}>1245 <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>1246 </Show>1247 </p>1248 <div class="peach-settings-row peach-settings-row--top">1249 <div class="peach-settings-row-main">1250 <span>1251 Canary entries1252 <Show when={!isPro()}>1253 <span class="peach-pro-badge">Pro</span>1254 </Show>1255 </span>1256 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1257 Silently pings a webhook when an entry is accessed. Configure canary on any entry in your vault.1258 </span>1259 </div>1260 <span class="peach-settings-value" style="flex-shrink:0">1261 {canaryEntries().length > 0 ? `${canaryEntries().length} active` : "none"}1262 </span>1263 </div>1264 <Show when={canaryEntries().length > 0}>1265 <For each={canaryEntries()}>1266 {(entry) => (1267 <div class="peach-settings-row peach-settings-row--meta">1268 <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0">{entry.title}</span>1269 <button1270 class="peach-btn peach-btn--subtle peach-btn--sm"1271 onClick={() => void handleTestCanary(entry)}1272 disabled={canaryTestingId() === entry.id}1273 style="flex-shrink:0"1274 >1275 {canaryTestingId() === entry.id1276 ? "sending…"1277 : canaryTestResult()?.id === entry.id1278 ? (canaryTestResult()!.ok ? "sent ✓" : "failed ✗")1279 : "test"}1280 </button>1281 </div>1282 )}1283 </For>1284 </Show>12851286 <p class="peach-settings-sub-label">PASSKEYS</p>1287 <div class="peach-settings-row peach-settings-row--top">1288 <div class="peach-settings-row-main">1289 <span>Intercept passkey requests</span>1290 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">1291 Peach can handle passkey sign-in and creation on supported Chromium browsers.1292 </span>1293 </div>1294 <button1295 class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}1296 onClick={() => void handleTogglePasskeys()}1297 aria-label="Toggle passkey interception"1298 disabled={!passkeyState()?.supported || passkeySaving()}1299 >1300 <span class="peach-toggle-knob"></span>1301 </button>1302 </div>1303 <div class="peach-settings-row peach-settings-row--meta">1304 <span>Status</span>1305 <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>1306 </div>1307 <div class="peach-settings-row peach-settings-row--meta">1308 <span>Browser</span>1309 <span class="peach-settings-value">1310 {passkeyState()?.browserLabel ?? "Unknown"}1311 {passkeyState()?.supported ? " ✓" : " (interception unavailable)"}1312 </span>1313 </div>1314 <div class="peach-info-notice" style="margin-top:8px">1315 Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.1316 </div>1317 <Show when={passkeyState()?.conflict}>1318 <div class="peach-settings-row peach-settings-row--meta">1319 <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>1320 </div>1321 </Show>1322 </div>1323 </div>1324 );1325 }13261327 function renderVault() {1328 return (1329 <div class="peach-settings-body">1330 <div class="peach-settings-group">1331 <p class="peach-settings-sub-label">ENTRIES</p>1332 <div class="peach-settings-row peach-settings-row--meta">1333 <span>Entries</span>1334 <span class="peach-settings-value">{props.entriesCount}</span>1335 </div>1336 <div class="peach-settings-row peach-settings-row--meta">1337 <span>Duplicates</span>1338 <Show when={dedupDone() !== null} fallback={1339 <button1340 class="peach-btn peach-btn--subtle peach-btn--sm"1341 onClick={() => void handleCountDuplicates()}1342 disabled={dedupRunning()}1343 >1344 {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}1345 </button>1346 }>1347 <span class="peach-settings-value" style="color:var(--peach-sync-ok)">1348 {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}1349 </span>1350 </Show>1351 </div>1352 <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>1353 <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">1354 <div class="peach-restore-warning">1355 {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.1356 </div>1357 <div style="display:flex;gap:6px">1358 <button1359 class="peach-btn peach-btn--subtle peach-btn--sm"1360 onClick={() => setDedupCount(null)}1361 >1362 cancel1363 </button>1364 <button1365 class="peach-btn peach-btn--accent peach-btn--sm"1366 onClick={() => void handleDeduplicate()}1367 disabled={dedupRunning()}1368 >1369 {dedupRunning() ? "removing..." : "remove duplicates"}1370 </button>1371 </div>1372 </div>1373 </Show>13741375 <p class="peach-settings-sub-label">MAINTENANCE</p>1376 <div class="peach-settings-row peach-settings-row--meta">1377 <span>Trash auto-delete</span>1378 <select1379 class="peach-settings-select"1380 value={String(props.trashRetentionDays)}1381 onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}1382 >1383 <option value="7">7 days</option>1384 <option value="30">30 days</option>1385 <option value="90">90 days</option>1386 <option value="0">Never</option>1387 </select>1388 </div>1389 <div class="peach-settings-row peach-settings-row--meta">1390 <span>Advanced Vault Health</span>1391 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>1392 open1393 </button>1394 </div>1395 <Show when={declinedCardCount() > 0}>1396 <div class="peach-settings-row peach-settings-row--meta">1397 <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>1398 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>1399 Clear1400 </button>1401 </div>1402 </Show>14031404 <p class="peach-settings-sub-label">DATA</p>1405 <div class="peach-settings-row peach-settings-row--meta">1406 <span>Import passwords</span>1407 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>1408 open1409 </button>1410 </div>1411 <div class="peach-settings-row peach-settings-row--meta">1412 <span>Export backup</span>1413 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onExport}>1414 open1415 </button>1416 </div>1417 <div class="peach-settings-row peach-settings-row--meta">1418 <span>1419 Peach Codex1420 <Show when={!isPro()}>1421 <span class="peach-pro-badge">Pro</span>1422 </Show>1423 </span>1424 <button1425 class="peach-btn peach-btn--subtle peach-btn--sm"1426 onClick={() => props.onOpenCodexGenerate?.()}1427 >1428 open1429 </button>1430 </div>1431 {1432}1433 <div class="peach-settings-row peach-settings-row--meta">1434 <span>Restore from Codex</span>1435 <button1436 class="peach-btn peach-btn--subtle peach-btn--sm"1437 onClick={() => props.onOpenCodexRestore?.()}1438 >1439 open1440 </button>1441 </div>14421443 <span class="peach-settings-sub-label">VAULT HISTORY</span>1444 <Show when={historyLoading()}>1445 <p class="peach-settings-hint">Loading…</p>1446 </Show>1447 <Show when={!historyLoading() && historySnapshots().length === 0}>1448 <p class="peach-settings-hint">1449 No snapshots yet. Peach saves automatically before every change —1450 saves, syncs, and imports.1451 </p>1452 </Show>1453 <Show when={!historyLoading() && historySnapshots().length > 0}>1454 <p class="peach-settings-hint">1455 Restoring locks your vault. Unlock with the password that1456 encrypted that snapshot to confirm.1457 </p>1458 <div class="peach-history-list">1459 <For each={historySnapshots().slice(0, historyShowAll() ? undefined : 3)}>1460 {(snap) => (1461 <div class="peach-history-row">1462 <div class="peach-history-meta">1463 <span class="peach-history-time">1464 {formatSnapshotTime(snap.savedAt)}1465 </span>1466 <span class="peach-history-sub">1467 {[1468 snap.entryCount !== null1469 ? `up to ${snap.entryCount} ${snap.entryCount === 1 ? "entry" : "entries"}`1470 : null,1471 reasonLabel(snap.reason),1472 ]1473 .filter(Boolean)1474 .join(" · ")}1475 </span>1476 </div>1477 <button1478 class={`peach-btn peach-btn--subtle peach-btn--sm peach-history-btn${1479 historyRestoring() === snap.id ? " is-busy" : ""1480 }`}1481 onClick={() => void handleRestoreVaultSnapshot(snap.id)}1482 disabled={historyRestoring() !== null}1483 >1484 <Show when={historyRestoring() === snap.id} fallback="Restore">1485 …1486 </Show>1487 </button>1488 </div>1489 )}1490 </For>1491 </div>1492 <Show when={historySnapshots().length > 3}>1493 <button1494 class="peach-settings-advanced-toggle"1495 onClick={() => setHistoryShowAll((v) => !v)}1496 aria-expanded={historyShowAll()}1497 >1498 <span class="peach-settings-hint" style="margin:0">1499 {historyShowAll() ? "show less" : `${historySnapshots().length - 3} older`}1500 </span>1501 <span class="peach-settings-advanced-chevron">1502 {historyShowAll() ? "▲" : "▼"}1503 </span>1504 </button>1505 </Show>1506 <p class="peach-history-footer">1507 {historySnapshots().length} of 10 snapshots1508 </p>1509 </Show>15101511 <button1512 class="peach-settings-advanced-toggle"1513 onClick={() => setShowVaultAdvanced((v) => !v)}1514 aria-expanded={showVaultAdvanced()}1515 >1516 <span class="peach-settings-sub-label" style="margin:0">ADVANCED</span>1517 <span class="peach-settings-advanced-chevron">1518 {showVaultAdvanced() ? "▲" : "▼"}1519 </span>1520 </button>15211522 <Show when={showVaultAdvanced()}>1523 <div class="peach-danger-card">1524 <div class="peach-danger-card-head">1525 <span class="peach-danger-card-glyph" aria-hidden="true">⟳</span>1526 <h3 class="peach-danger-card-title">Replace vault from backup</h3>1527 </div>1528 <p class="peach-danger-card-copy">1529 Wipes the current vault and restores from a backup file.1530 Recoverable only if your backup is good.1531 </p>1532 <button1533 class="peach-danger-card-button"1534 onClick={() => props.onOpenFileImport?.()}1535 >1536 Choose backup file1537 </button>1538 </div>15391540 <Show when={props.onOpenPanic}>1541 <div class="peach-danger-card peach-danger-card--hazard">1542 <div class="peach-danger-card-head">1543 <span class="peach-danger-card-glyph" aria-hidden="true">⚠</span>1544 <h3 class="peach-danger-card-title">Destroy vault</h3>1545 </div>1546 <p class="peach-danger-card-copy">1547 Permanently wipes every entry, setting, and license on this1548 device. There is no recovery.1549 </p>1550 <button1551 class="peach-danger-card-button"1552 onClick={() => props.onOpenPanic?.()}1553 >1554 Begin destruction1555 </button>1556 </div>1557 </Show>1558 </Show>1559 </div>1560 </div>1561 );1562 }15631564 function renderSync() {1565 return (1566 <div class="peach-settings-body">1567 <div class="peach-settings-group">1568 <div class="peach-settings-row peach-settings-row--meta">1569 <span>Status</span>1570 <span class="peach-settings-value" style="display:inline-flex;align-items:center;gap:8px">1571 {isPro() ? "Pro" : "Free"}1572 <span class={`stk-pip ${isPro() ? "is-on" : ""}`} aria-hidden="true" />1573 </span>1574 </div>15751576 {}1577 <p class="peach-settings-sub-label" style="margin-top:10px">PAIR A DEVICE</p>1578 {15791580}1581 <button1582 class="peach-sync-pair-option"1583 onClick={() => props.onOpenSyncPairing?.("code")}1584 >1585 <span class="peach-sync-pair-option-icon" aria-hidden="true">1586 <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">1587 <rect x="6" y="2.5" width="8" height="15" rx="1.6" />1588 <path d="M9 15 L11 15" />1589 </svg>1590 </span>1591 <span class="peach-sync-pair-option-main">1592 <span class="peach-sync-pair-option-title">Show a code</span>1593 <span class="peach-sync-pair-option-sub">This browser shows a code; scan it with Peach on your phone</span>1594 </span>1595 <span class="peach-sync-pair-option-chevron" aria-hidden="true">›</span>1596 </button>1597 <Show when={!isPro()}>1598 <p class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2);margin:8px 2px 0">1599 At least one device needs Peach Pro — pair with a Pro device and it carries over to here.1600 </p>1601 </Show>16021603 {160416051606}1607 <p class="peach-settings-sub-label" style="margin-top:12px">HOW THIS VAULT SYNCS</p>16081609 <button1610 class="peach-sync-mode"1611 classList={{ "is-active": syncMode() === "public" }}1612 disabled={relaySaving()}1613 onClick={() => void saveSyncSettings("public")}1614 >1615 <span class="peach-sync-mode-main">1616 <span class="peach-sync-mode-title">Public relay</span>1617 <span class="peach-sync-mode-sub">1618 Peach's own relay. Works anywhere, nothing to set up. It never sees1619 your vault — everything is encrypted before it leaves the device.1620 </span>1621 </span>1622 <span class="peach-sync-mode-pip" aria-hidden="true" />1623 </button>16241625 <button1626 class="peach-sync-mode"1627 classList={{ "is-active": syncMode() === "private" }}1628 disabled={relaySaving()}1629 onClick={() => void saveSyncSettings("private")}1630 >1631 <span class="peach-sync-mode-main">1632 <span class="peach-sync-mode-title">Private relay</span>1633 <span class="peach-sync-mode-sub">1634 Your own peach-relay, on your own domain. Same open-source code.1635 </span>1636 </span>1637 <span class="peach-sync-mode-pip" aria-hidden="true" />1638 </button>16391640 <Show when={syncMode() === "private"}>1641 <label class="peach-label" for="sync-relay-url">relay URL</label>1642 <input1643 id="sync-relay-url"1644 class="peach-underline-input"1645 type="url"1646 inputmode="url"1647 autocomplete="off"1648 spellcheck={false}1649 value={relayUrl()}1650 disabled={relaySaving()}1651 onInput={(event) => setRelayUrl((event.target as HTMLInputElement).value)}1652 onKeyDown={(event) => {1653 if (event.key === "Enter") void saveSyncSettings();1654 }}1655 />1656 <div class="peach-settings-row peach-settings-row--meta">1657 <span class="peach-settings-value" style={`color:${relayStatus()?.startsWith("Use") ? "var(--peach-breach)" : "var(--peach-t2)"}`}>1658 {relayStatus() ?? "Only secure wss:// relays are accepted."}1659 </span>1660 <button1661 class="peach-btn peach-btn--subtle peach-btn--sm"1662 disabled={relaySaving()}1663 onClick={() => void saveSyncSettings()}1664 >{relaySaving() ? "saving…" : "save"}</button>1665 </div>1666 </Show>16671668 <button1669 class="peach-sync-mode"1670 classList={{ "is-active": syncMode() === "off" }}1671 disabled={relaySaving()}1672 onClick={() => void saveSyncSettings("off")}1673 >1674 <span class="peach-sync-mode-main">1675 <span class="peach-sync-mode-title">No sync</span>1676 <span class="peach-sync-mode-sub">1677 This device keeps to itself. Move your vault by hand with Codex or1678 CSV export and import.1679 </span>1680 </span>1681 <span class="peach-sync-mode-pip" aria-hidden="true" />1682 </button>16831684 {}1685 <Show when={pairedDevices().length > 0}>1686 <p class="peach-settings-sub-label" style="margin-top:12px">PAIRED DEVICES</p>1687 <For each={pairedDevices()}>1688 {(d) => (1689 <div class="peach-settings-row peach-settings-row--top">1690 <div class="peach-settings-row-main">1691 <span>{d.deviceName ?? "Unnamed device"}</span>1692 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1693 {deviceTypeLabel(d.deviceType)} · {syncRelativeTime(d.lastSyncedAt)}{isSyncStale(d) ? " · not seen recently" : ""}1694 </span>1695 </div>1696 <Show1697 when={confirmUnpair() === d.deviceId}1698 fallback={1699 <button1700 class="peach-btn peach-btn--subtle peach-btn--sm"1701 onClick={() => setConfirmUnpair(d.deviceId)}1702 >remove</button>1703 }1704 >1705 {17061707170817091710}1711 <div class="peach-unpair-confirm">1712 <p class="peach-unpair-confirm-note">1713 Stops this device receiving future changes. It doesn't1714 lock it out of what it already has — if it's lost or1715 stolen, also change your master password and generate1716 new recovery words.1717 </p>1718 <div style="display:flex;gap:6px;justify-content:flex-end">1719 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => setConfirmUnpair(null)}>cancel</button>1720 <button1721 class="peach-btn peach-btn--sm"1722 style="background:var(--peach-breach);border-color:var(--peach-breach)"1723 onClick={() => void unpairDevice(d.deviceId)}1724 >remove</button>1725 </div>1726 </div>1727 </Show>1728 </div>1729 )}1730 </For>1731 <div class="peach-settings-row peach-settings-row--meta">1732 <span class="peach-settings-value" style="color:var(--peach-t2)">{syncFlash() ?? ""}</span>1733 <button1734 class="peach-btn peach-btn--subtle peach-btn--sm"1735 disabled={syncStatus() === "syncing"}1736 onClick={() => void syncNow()}1737 >{syncStatus() === "syncing" ? "syncing…" : "sync now"}</button>1738 </div>1739 </Show>1740 {}1741 <Show when={pendingVouched().length > 0}>1742 <p class="peach-settings-sub-label">1743 PENDING DEVICES1744 <span class="peach-pro-badge" style="background:#C8A840;color:#1a1a1a">{pendingVouched().length}</span>1745 </p>1746 <For each={pendingVouched()}>1747 {(dev) => (1748 <div class="peach-settings-row peach-settings-row--top">1749 <div class="peach-settings-row-main">1750 <span>{dev.deviceName}</span>1751 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1752 Was added by another of your devices. Allow it to sync with this vault?1753 </span>1754 </div>1755 <div style="display:flex;gap:6px;flex-shrink:0">1756 <button1757 class="peach-btn peach-btn--sm"1758 disabled={vouchedBusy() === dev.deviceId}1759 onClick={() => void confirmVouched(dev.deviceId)}1760 >Allow</button>1761 <button1762 class="peach-btn peach-btn--subtle peach-btn--sm"1763 disabled={vouchedBusy() === dev.deviceId}1764 onClick={() => void rejectVouched(dev.deviceId)}1765 >Reject</button>1766 </div>1767 </div>1768 )}1769 </For>1770 </Show>17711772 {}1773 <p class="peach-settings-sub-label" style="margin-top:12px">RECOVERY</p>1774 <Show1775 when={rotateConfirmOpen()}1776 fallback={1777 <div class="peach-settings-row peach-settings-row--top">1778 <div class="peach-settings-row-main">1779 <span>Rotate sync identity</span>1780 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1781 Use this if a paired device was lost or stolen. Re-keys the vault so the old device's key is useless.1782 </span>1783 </div>1784 <button1785 class="peach-btn peach-btn--subtle peach-btn--sm"1786 style="color:var(--peach-breach);border-color:color-mix(in srgb,var(--peach-breach) 40%,transparent)"1787 onClick={() => { setRotateError(null); setRotateConfirmOpen(true); }}1788 >rotate</button>1789 </div>1790 }1791 >1792 <div class="peach-info-notice" style="margin-top:8px;border-color:color-mix(in srgb,var(--peach-breach) 40%,transparent)">1793 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.1794 <Show when={rotateError()}>1795 <div style="color:var(--peach-breach);margin-top:6px">{rotateError()}</div>1796 </Show>1797 <div style="display:flex;gap:6px;margin-top:10px">1798 <button1799 class="peach-btn peach-btn--subtle peach-btn--sm"1800 disabled={rotating()}1801 onClick={() => setRotateConfirmOpen(false)}1802 >Cancel</button>1803 <button1804 class="peach-btn peach-btn--sm"1805 style="background:var(--peach-breach);border-color:var(--peach-breach)"1806 disabled={rotating()}1807 onClick={() => void doRotateSyncIdentity()}1808 >{rotating() ? "Rotating…" : "Rotate sync identity"}</button>1809 </div>1810 </div>1811 </Show>1812 </div>1813 </div>1814 );1815 }18161817 function renderAppearance() {1818 const sizeOptions: Array<{ id: "medium" | "large"; label: string }> = [1819 { id: "medium", label: "Default" },1820 { id: "large", label: "Large" },1821 ];1822 return (1823 <div class="peach-settings-body">1824 <div class="peach-settings-group">1825 <p class="peach-settings-sub-label">THEME</p>1826 <div class="peach-settings-row peach-settings-row--meta">1827 <span>Theme</span>1828 <button1829 class="peach-btn peach-btn--subtle peach-btn--sm"1830 onClick={() =>1831 props.onThemeChange(props.theme === "dark" ? "light" : "dark")1832 }1833 >1834 {props.theme === "dark" ? "Switch to light" : "Switch to dark"}1835 </button>1836 </div>18371838 <p class="peach-settings-sub-label">ACCENT</p>1839 <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">1840 <span>Accent</span>1841 <div class="peach-accent-picker">1842 <For each={ACCENT_COLORS}>1843 {(color) => (1844 <button1845 class="peach-accent-swatch peach-swatch"1846 classList={{ "is-active": props.accentColor === color.name }}1847 style={{ background: color.hex }}1848 onClick={() => void props.onAccentChange(color.name)}1849 aria-label={color.name}1850 title={color.name}1851 />1852 )}1853 </For>1854 </div>1855 </div>18561857 <p class="peach-settings-sub-label">TEXT SIZE</p>1858 <div class="peach-settings-row peach-settings-row--meta">1859 <span>Size</span>1860 <div class="peach-pill-group">1861 <For each={sizeOptions}>1862 {(opt) => (1863 <button1864 class="peach-btn peach-btn--ghost peach-btn--sm"1865 classList={{ "is-active": props.textSize === opt.id }}1866 onClick={() => void props.onTextSizeChange(opt.id)}1867 >1868 {opt.label}1869 </button>1870 )}1871 </For>1872 </div>1873 </div>18741875 <p class="peach-settings-sub-label">MOTION</p>1876 <div class="peach-settings-row peach-settings-row--meta">1877 <span>Reduce motion</span>1878 <button1879 class="peach-btn peach-btn--subtle peach-btn--sm"1880 onClick={() => void props.onReduceMotionChange(!props.reduceMotion)}1881 >1882 {props.reduceMotion ? "On" : "Off"}1883 </button>1884 </div>1885 </div>1886 </div>1887 );1888 }18891890 function renderShortcuts() {1891 const [capturingAction, setCapturingAction] = createSignal<ShortcutAction | null>(null);1892 const [conflictMsg, setConflictMsg] = createSignal<string | null>(null);18931894 function startCapture(action: ShortcutAction) {1895 setConflictMsg(null);1896 setCapturingAction(action);1897 }1898 function cancelCapture() {1899 setCapturingAction(null);1900 setConflictMsg(null);1901 }1902 function commitBinding(action: ShortcutAction, binding: Keybinding) {1903 const conflict = findConflict(props.shortcuts, binding, action);1904 if (conflict) {1905 const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict);1906 setConflictMsg(`Already bound to "${other?.label ?? conflict}"`);1907 return;1908 }1909 const next = { ...props.shortcuts, [action]: binding };1910 setCapturingAction(null);1911 setConflictMsg(null);1912 void props.onShortcutsChange(next);1913 }1914 function resetAction(action: ShortcutAction) {1915 const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] };1916 void props.onShortcutsChange(next);1917 }1918 function resetAll() {1919 void props.onShortcutsChange({ ...DEFAULT_BINDINGS });1920 }19211922 1923 1924 function handleCaptureKey(e: KeyboardEvent) {1925 e.preventDefault();1926 e.stopPropagation();1927 if (e.key === "Escape") { cancelCapture(); return; }1928 const binding = keybindingFromEvent(e);1929 if (!binding) return; 1930 const action = capturingAction();1931 if (action) commitBinding(action, binding);1932 }19331934 return (1935 <div class="peach-settings-body">1936 <div class="peach-settings-group peach-shortcuts-group">1937 <p class="peach-settings-sub-label">ENTRY VIEW</p>1938 <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry")}>1939 {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1940 </For>1941 </div>19421943 <div class="peach-settings-group peach-shortcuts-group">1944 <p class="peach-settings-sub-label">ANYWHERE IN POPUP</p>1945 <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global")}>1946 {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1947 </For>1948 </div>19491950 <Show when={conflictMsg()}>1951 <div class="peach-shortcuts-conflict">{conflictMsg()}</div>1952 </Show>19531954 <div class="peach-settings-group" style="display:flex;flex-direction:column;gap:8px">1955 <div class="peach-settings-row peach-settings-row--meta">1956 <span>Inline autofill on page</span>1957 <a1958 class="peach-settings-link"1959 href="chrome://extensions/shortcuts"1960 onClick={(e) => {1961 e.preventDefault();1962 chrome.tabs.create({ url: "chrome://extensions/shortcuts" });1963 }}1964 >1965 ⌘⇧P — customize ↗1966 </a>1967 </div>1968 <button1969 class="peach-btn peach-btn--subtle peach-btn--sm"1970 style="align-self:flex-start"1971 onClick={resetAll}1972 >1973 Reset all to defaults1974 </button>1975 </div>1976 </div>1977 );19781979 function renderRow(1980 desc: typeof SHORTCUT_DESCRIPTORS[number],1981 capturing: () => ShortcutAction | null,1982 onStart: (a: ShortcutAction) => void,1983 onCancel: () => void,1984 onReset: (a: ShortcutAction) => void,1985 onKey: (e: KeyboardEvent) => void,1986 ) {1987 const isActive = () => capturing() === desc.action;1988 const binding = () => props.shortcuts[desc.action];1989 const isCustom = () => {1990 const b = binding();1991 const d = DEFAULT_BINDINGS[desc.action];1992 return b.key !== d.key1993 || Boolean(b.ctrl) !== Boolean(d.ctrl)1994 || Boolean(b.meta) !== Boolean(d.meta)1995 || Boolean(b.shift) !== Boolean(d.shift)1996 || Boolean(b.alt) !== Boolean(d.alt);1997 };1998 return (1999 <div class="peach-shortcut-row">2000 <span class="peach-shortcut-row-label">{desc.label}</span>2001 <button2002 class="peach-shortcut-chip"2003 classList={{ "is-capturing": isActive(), "is-custom": isCustom() }}2004 onClick={() => isActive() ? onCancel() : onStart(desc.action)}2005 onKeyDown={(e) => { if (isActive()) onKey(e); }}2006 ref={(el) => {2007 2008 if (el) {2009 queueMicrotask(() => { if (isActive()) el.focus(); });2010 }2011 }}2012 >2013 {isActive() ? "Press a key…" : formatKeybinding(binding())}2014 </button>2015 <Show when={isCustom() && !isActive()}>2016 <button2017 class="peach-shortcut-reset"2018 title="Reset to default"2019 onClick={() => onReset(desc.action)}2020 >2021 ↺2022 </button>2023 </Show>2024 </div>2025 );2026 }2027 }20282029 const PRO_FEATURES: Array<{2030 feature: ProFeature;2031 name: string;2032 desc: string;2033 }> = [2034 { feature: "sync", name: "Peach Sync", desc: "Encrypted · peer-to-peer" },2035 { feature: "codex", name: "Peach Codex", desc: "Restore from paper" },2036 { feature: "passkeys", name: "Passkeys", desc: "Zero-knowledge WebAuthn" },2037 { feature: "advanced_health", name: "Advanced Vault Health", desc: "Reuse · age · exposure" },2038 ];20392040 const PRO_SECURITY_FEATURES: Array<{2041 feature: ProFeature;2042 name: string;2043 desc: string;2044 }> = [2045 { feature: "canary_entry", name: "Canary entries", desc: "Detects vault compromise" },2046 { feature: "duress_vault", name: "Duress vault", desc: "Decoy under coercion" },2047 ];20482049 const [licenseKey, setLicenseKey] = createSignal<string | null>(null);2050 const [showKey, setShowKey] = createSignal(false);20512052 const [deactivating, setDeactivating] = createSignal(false);20532054 async function handleDeactivateLicense() {2055 setDeactivating(true);2056 await sendMessage({ type: "DEACTIVATE_LICENSE" });2057 setLicenseInfo(null);2058 await props.onLicenseChange?.();2059 setDeactivating(false);2060 }20612062 async function handleTestCanary(entry: {id: string; title: string; canary: Canary}) {2063 setCanaryTestingId(entry.id);2064 setCanaryTestResult(null);2065 const res = await sendMessage({2066 type: "FIRE_CANARY_PING",2067 payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger: "test" },2068 }) as { ok: boolean };2069 setCanaryTestResult({ id: entry.id, ok: res.ok });2070 setCanaryTestingId(null);2071 setTimeout(() => setCanaryTestResult((r) => r?.id === entry.id ? null : r), 3000);2072 }20732074 function renderLicense() {2075 async function onActivate() {2076 await handleActivateLicense();2077 if ((licenseInfo()?.valid ?? false) && licenseInfo()?.plan === "pro") {2078 await props.onLicenseChange?.();2079 }2080 }20812082 return (2083 <div class="peach-settings-body">2084 <Show2085 when={isPro()}2086 fallback={2087 <div class="peach-pro-upgrade">2088 <p class="peach-pro-eyebrow">PEACH PRO · ONE-TIME</p>20892090 <h2 class="peach-pro-headline">2091 Every<em>thing,</em><br />every<em>where.</em>2092 </h2>20932094 <p class="peach-pro-subtitle">2095 One key unlocks every Pro feature — for the lifetime of the app.2096 </p>20972098 <div class="peach-pro-grid">2099 <For each={PRO_FEATURES}>2100 {(item, i) => (2101 <div2102 class="peach-pro-grid-item"2103 classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}2104 >2105 <span class="peach-pro-grid-num">{String(i() + 1).padStart(2, "0")}</span>2106 <span class="peach-pro-grid-name">{item.name}</span>2107 <span class="peach-pro-grid-desc">{item.desc}</span>2108 </div>2109 )}2110 </For>2111 </div>21122113 <p class="peach-pro-section-label">ADVANCED SECURITY</p>2114 <div class="peach-pro-grid">2115 <For each={PRO_SECURITY_FEATURES}>2116 {(item, i) => (2117 <div2118 class="peach-pro-grid-item"2119 classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}2120 >2121 <span class="peach-pro-grid-num">{String(PRO_FEATURES.length + i() + 1).padStart(2, "0")}</span>2122 <span class="peach-pro-grid-name">{item.name}</span>2123 <span class="peach-pro-grid-desc">{item.desc}</span>2124 </div>2125 )}2126 </For>2127 </div>21282129 <div class="peach-pro-upgrade-actions">2130 <p class="peach-pro-pricing">2131 $39 <span class="peach-pro-pricing-sub">once · yours forever</span>2132 </p>2133 <a2134 href="https://buy.polar.sh/polar_cl_IXSFIPojx67rcOTksn8LkH5mnU3osPHtYqBIl1eyuIz"2135 target="_blank"2136 rel="noopener noreferrer"2137 class="peach-pro-upgrade-cta"2138 >2139 Get Peach Pro2140 </a>2141 <div class="peach-pro-key-divider"><span>ALREADY HAVE A KEY</span></div>2142 <div class="peach-pro-key-inline">2143 <input2144 class="peach-pro-key-inline-input"2145 type="text"2146 placeholder="PEACH-XXXX-XXXX-XXXX"2147 value={licenseKeyInput()}2148 onInput={(e) => setLicenseKeyInput(e.currentTarget.value)}2149 onKeyDown={(e) => { if (e.key === "Enter") void onActivate(); }}2150 autocomplete="off"2151 spellcheck={false}2152 />2153 <button2154 class="peach-pro-key-apply"2155 onClick={() => void onActivate()}2156 disabled={activatingLicense() || !licenseKeyInput().trim()}2157 >2158 {activatingLicense() ? "…" : "APPLY"}2159 </button>2160 </div>2161 <Show when={licenseError()}>2162 <p class="peach-pro-key-error">{licenseError()}</p>2163 </Show>2164 </div>2165 </div>2166 }2167 >2168 <div class="peach-pro-active">2169 <div class="peach-pro-active-hero">2170 <button2171 class="peach-pro-active-mark-wrap"2172 type="button"2173 aria-label="Celebrate"2174 onClick={handleProCelebrationClick}2175 >2176 <PeachMark class="peach-pro-active-mark" />2177 <Show when={justActivated()}>2178 <div class="peach-pro-sparks" aria-hidden="true">2179 <span class="peach-pro-spark" style="--spark-x:0px;--spark-y:-54px;--spark-delay:0ms">✦</span>2180 <span class="peach-pro-spark" style="--spark-x:38px;--spark-y:-38px;--spark-delay:50ms">✦</span>2181 <span class="peach-pro-spark" style="--spark-x:54px;--spark-y:0px;--spark-delay:110ms">·</span>2182 <span class="peach-pro-spark" style="--spark-x:38px;--spark-y:38px;--spark-delay:30ms">✦</span>2183 <span class="peach-pro-spark" style="--spark-x:0px;--spark-y:54px;--spark-delay:80ms">·</span>2184 <span class="peach-pro-spark" style="--spark-x:-38px;--spark-y:38px;--spark-delay:130ms">✦</span>2185 <span class="peach-pro-spark" style="--spark-x:-54px;--spark-y:0px;--spark-delay:55ms">·</span>2186 <span class="peach-pro-spark" style="--spark-x:-38px;--spark-y:-38px;--spark-delay:95ms">✦</span>2187 </div>2188 </Show>2189 </button>2190 <h2 class="peach-pro-active-title">Peach Pro</h2>2191 <p class="peach-pro-active-since">{formatActivatedSince(licenseInfo()?.issued)}</p>2192 <Show when={justActivated()}>2193 <p class="peach-pro-thankyou-text" aria-live="polite">thank you ✦</p>2194 </Show>2195 </div>21962197 <Show when={licenseKey()}>2198 <div class="peach-settings-license">2199 <span class="peach-settings-label">LICENSE KEY</span>2200 <div class="peach-settings-license-row">2201 <Show2202 when={showKey()}2203 fallback={2204 <span class="peach-settings-license-key peach-settings-license-key--masked" aria-label="License key hidden">2205 ••••••••••••••••••••••••••••2206 </span>2207 }2208 >2209 <span class="peach-settings-license-key">{licenseKey()!}</span>2210 </Show>2211 <div class="peach-settings-license-actions">2212 <button2213 class="peach-settings-license-copy"2214 title="Copy license key"2215 onClick={() => copyLicenseKey(licenseKey()!)}2216 >2217 {copied() ? "✓" : "copy"}2218 </button>2219 <button2220 class="peach-settings-license-toggle"2221 aria-label={showKey() ? "Hide license key" : "Reveal license key"}2222 title={showKey() ? "Hide" : "Reveal"}2223 onClick={() => setShowKey(v => !v)}2224 >2225 <Show2226 when={showKey()}2227 fallback={2228 <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">2229 <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />2230 <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />2231 </svg>2232 }2233 >2234 <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">2235 <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />2236 <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />2237 <line x1="2" y1="2" x2="12" y2="12" />2238 </svg>2239 </Show>2240 </button>2241 </div>2242 </div>2243 </div>2244 </Show>22452246 <div class="peach-pro-active-features">2247 <For each={[...PRO_FEATURES, ...PRO_SECURITY_FEATURES]}>2248 {(item) => (2249 <div class="peach-pro-active-feature">2250 <span class="peach-pro-active-feature-mark">✦</span>2251 <span class="peach-pro-active-feature-name">{item.name}</span>2252 </div>2253 )}2254 </For>2255 </div>22562257 <button2258 class="peach-pro-deactivate"2259 onClick={() => void handleDeactivateLicense()}2260 disabled={deactivating()}2261 >2262 {deactivating() ? "Deactivating…" : "Deactivate"}2263 </button>2264 </div>2265 </Show>2266 </div>2267 );2268 }22692270 function renderFeedback() {2271 return (2272 <div class="peach-settings-body">2273 <div class="peach-settings-group peach-feedback-group">2274 <Show2275 when={feedbackState() === 'sent'}2276 fallback={2277 <>2278 <p class="peach-feedback-intro">2279 Help us improve Peach. Every report goes directly to our inbox.2280 </p>22812282 <p class="peach-settings-sub-label">TYPE</p>2283 <div class="peach-feedback-chips">2284 <For each={FEEDBACK_CATEGORIES}>2285 {(cat) => {2286 const selected = () => fbCategory() === cat;2287 return (2288 <button2289 type="button"2290 class={`peach-feedback-chip ${selected() ? "is-active" : ""}`}2291 aria-pressed={selected()}2292 onClick={() => setFbCategory(cat)}2293 >2294 {cat}2295 </button>2296 );2297 }}2298 </For>2299 </div>23002301 <p class="peach-settings-sub-label">MESSAGE</p>2302 <textarea2303 class="peach-feedback-textarea"2304 placeholder="Describe the issue or idea..."2305 maxLength={MAX_FEEDBACK_MESSAGE_LENGTH}2306 value={fbMessage()}2307 onInput={(e) => setFbMessage(e.currentTarget.value)}2308 />23092310 <p class="peach-feedback-meta">2311 Version {version} · will be included automatically2312 </p>23132314 <button2315 type="button"2316 class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"2317 disabled={!fbMessage().trim() || feedbackState() === 'sending'}2318 onClick={() => void handleSubmitFeedback()}2319 >2320 <Switch>2321 <Match when={feedbackState() === 'idle'}>Send</Match>2322 <Match when={feedbackState() === 'sending'}>Sending...</Match>2323 <Match when={feedbackState() === 'error'}>Failed - try again</Match>2324 </Switch>2325 </button>23262327 <p class="peach-feedback-anon">2328 Sent anonymously · no account required2329 </p>2330 </>2331 }2332 >2333 <div class="peach-feedback-thanks" role="status" aria-live="polite">2334 <p class="peach-feedback-thanks-title">Thank you</p>2335 <p>2336 Your note is in the queue. Someone will process it soon.2337 </p>2338 </div>2339 </Show>2340 </div>2341 </div>2342 );2343 }23442345 const bodies: StackBodies = {2346 protection: renderSecurity(),2347 vault: renderVault(),2348 sync: renderSync(),2349 appearance: renderAppearance(),2350 shortcuts: renderShortcuts(),2351 pro: renderLicense(),2352 feedback: renderFeedback(),2353 };23542355 return (2356 <Show2357 when={showNewRecoveryWords()}2358 fallback={2359 <div2360 class="peach-overlay-view peach-settings-overlay"2361 classList={{ "ov-enter-right": !props.isExiting, "ov-exit-right": !!props.isExiting }}2362 style="display:flex;flex-direction:column"2363 >2364 <StackDeck2365 active={activeCard()}2366 onActiveChange={setActiveCard}2367 onBack={props.onBack}2368 cards={cards()}2369 bodies={bodies}2370 version={version}2371 isFanning={isFanning()}2372 celebratingCardId={justActivated() ? "pro" : null}2373 isPro={isPro()}2374 />2375 </div>2376 }2377 >2378 <RecoveryWordDisplayView onDone={() => setShowNewRecoveryWords(false)} />2379 </Show>2380 );2381}