1import { createEffect, createMemo, createSignal, For, Match, 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 {16 DEFAULT_BINDINGS,17 findConflict,18 formatKeybinding,19 keybindingFromEvent,20 SHORTCUT_DESCRIPTORS,21 type Keybinding,22 type ShortcutAction,23} from "../../shared/shortcuts";24import { isNativePlatform, nativePlugin } from "../../shared/native";25import { WEB3FORMS_KEY } from "../../shared/config";2627interface LicenseInfo {28 valid: boolean;29 plan: "free" | "pro";30 issued?: string;31}3233interface SettingsProps {34 entriesCount: number;35 breachMonitoring: boolean;36 lockOnClose: boolean;37 autoLockMinutes: number;38 trashRetentionDays: number;39 theme: "dark" | "light";40 accentColor: AccentColorName;41 textSize: "small" | "medium" | "large";42 reduceMotion: boolean;43 isPro?: boolean;44 initialSection?: string | null;45 upgradeHighlight?: ProFeature | null;46 onBreachMonitoringChange: (enabled: boolean) => void | Promise<void>;47 onLockOnCloseChange: (enabled: boolean) => void | Promise<void>;48 onAutoLockMinutesChange: (minutes: number) => void | Promise<void>;49 onTrashRetentionChange: (days: number) => void | Promise<void>;50 onThemeChange: (theme: "dark" | "light") => void;51 onAccentChange: (name: AccentColorName) => void | Promise<void>;52 onTextSizeChange: (size: "small" | "medium" | "large") => void | Promise<void>;53 onReduceMotionChange: (enabled: boolean) => void | Promise<void>;54 shortcuts: Record<ShortcutAction, Keybinding>;55 onShortcutsChange: (active: Record<ShortcutAction, Keybinding>) => void | Promise<void>;56 onLicenseChange?: () => void | Promise<void>;57 isExiting?: boolean;58 onBack: () => void;59 onImport: () => void;60 onExport: () => void;61 onBreachCheckDone: () => void;62 onOpenHealth?: () => void;63 onOpenSyncPairing?: () => void;64 onOpenFileImport?: () => void;65 onOpenPanic?: () => void;66 onOpenCodexGenerate?: () => void;67 onOpenCodexRestore?: () => void;68 697071 onProGate?: (feature: ProFeature) => void;72 onCardChange?: (id: CardId) => void;73}7475const FEEDBACK_CATEGORIES = ["Bug report", "Feature request", "Question", "Other"];76const MAX_FEEDBACK_MESSAGE_LENGTH = 4000;7778function sanitizeFeedbackMessage(value: string): string {79 return value80 .trim()81 .slice(0, MAX_FEEDBACK_MESSAGE_LENGTH)82 .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "")83 .replace(/&/g, "&")84 .replace(/</g, "<")85 .replace(/>/g, ">")86 .replace(/"/g, """)87 .replace(/'/g, "'");88}8990function formatActivatedSince(issued: string | undefined): string {91 if (!issued) return "Active";92 try {93 const d = new Date(issued);94 if (Number.isNaN(d.getTime())) return "Active";95 return `Active since ${d.toLocaleDateString(undefined, { month: "long", year: "numeric" })}`;96 } catch {97 return "Active";98 }99}100101function initialSectionToCard(value: string | null | undefined): CardId | null {102 if (!value) return null;103 if (value === "security") return "protection";104 if (value === "license") return "pro";105 if (106 value === "protection" ||107 value === "vault" ||108 value === "sync" ||109 value === "appearance" ||110 value === "shortcuts" ||111 value === "pro" ||112 value === "feedback"113 ) {114 return value;115 }116 return null;117}118119export function SettingsView(props: SettingsProps) {120 const manifest = chrome.runtime.getManifest();121 const version = manifest.version;122 const initial = props.initialSection;123 const [activeCard, setActiveCard] = createSignal<CardId>(124 props.upgradeHighlight ? "pro" : initialSectionToCard(initial) ?? "protection"125 );126127 createEffect(() => { props.onCardChange?.(activeCard()); });128129 const [checkingBreaches, setCheckingBreaches] = createSignal(false);130 const [breachResult, setBreachResult] = createSignal<string | null>(null);131 const [licenseInfo, setLicenseInfo] = createSignal<LicenseInfo | null>(null);132 const [licenseKeyInput, setLicenseKeyInput] = createSignal("");133 const [licenseError, setLicenseError] = createSignal<string | null>(null);134 const [, setLicenseSuccess] = createSignal<string | null>(null);135 const [activatingLicense, setActivatingLicense] = createSignal(false);136137 const isPro = () => props.isPro ?? false;138 const cards = createMemo<StackCardSpec[]>(() => [139 {140 id: "protection",141 num: "I · PROTECT",142 title: "Protection",143 lede: `${props.breachMonitoring ? "breaches watched" : "breach watch off"} · ${props.lockOnClose ? "locks on close" : "manual lock"}`,144 bgVar: "--stack-protect-bg",145 fgVar: "--stack-protect-fg",146 icon: "shield",147 },148 {149 id: "vault",150 num: "II · KEEP",151 title: "Vault",152 lede: `${props.entriesCount} ${props.entriesCount === 1 ? "entry" : "entries"} · backups, history, cleanup`,153 bgVar: "--stack-vault-bg",154 fgVar: "--stack-vault-fg",155 icon: "lock",156 },157 {158 id: "sync",159 num: "III · ECHO",160 title: "Sync",161 lede: isPro() ? "pair by chirp, keep in step" : "Pro · encrypted device pairing",162 bgVar: "--stack-sync-bg",163 fgVar: "--stack-sync-fg",164 icon: "sync",165 },166 {167 id: "appearance",168 num: "IV · DRESS",169 title: "Appearance",170 lede: `${props.theme} · ${props.accentColor} · ${props.textSize}`,171 bgVar: "--stack-appear-bg",172 fgVar: "--stack-appear-fg",173 icon: "sun",174 },175 {176 id: "shortcuts",177 num: "V · KEYS",178 title: "Shortcuts",179 lede: `${SHORTCUT_DESCRIPTORS.length} bindings · what your fingers know`,180 bgVar: "--stack-shortcut-bg",181 fgVar: "--stack-shortcut-fg",182 icon: "keys",183 },184 {185 id: "pro",186 num: "VI · FOREVER",187 title: "Peach Pro",188 lede: isPro() ? "active · paid once, yours forever" : "$39 once · yours forever",189 bgVar: "--stack-pro-bg",190 fgVar: "--stack-pro-fg",191 icon: "spark",192 },193 {194 id: "feedback",195 num: "VII · ECHO",196 title: "Feedback",197 lede: `version ${version} · tell us what would make it better`,198 bgVar: "--stack-feedback-bg",199 fgVar: "--stack-feedback-fg",200 icon: "bubble",201 },202 ]);203 const [biometricSupported, setBiometricSupported] = createSignal(false);204 const [biometricStored, setBiometricStored] = createSignal(false);205 const [biometricBusy, setBiometricBusy] = createSignal(false);206 const [biometricError, setBiometricError] = createSignal<string | null>(null);207 const [passkeyState, setPasskeyState] = createSignal<PasskeyProviderState | null>(null);208 const [passkeySaving, setPasskeySaving] = createSignal(false);209210 const [dedupCount, setDedupCount] = createSignal<number | null>(null);211 const [dedupRunning, setDedupRunning] = createSignal(false);212 const [dedupDone, setDedupDone] = createSignal<number | null>(null);213214 const [declinedCardCount, setDeclinedCardCount] = createSignal(0);215216 217 218 219 const [historySnapshots, setHistorySnapshots] = createSignal<VaultSnapshot[]>([]);220 const [historyLoading, setHistoryLoading] = createSignal(false);221 const [historyRestoring, setHistoryRestoring] = createSignal<string | null>(null);222 const [historyShowAll, setHistoryShowAll] = createSignal(false);223224 async function loadVaultHistory() {225 setHistoryLoading(true);226 try {227 const res = await sendMessage({ type: "GET_VAULT_HISTORY" });228 const list = (res as { ok: boolean; data?: VaultSnapshot[] }).data ?? [];229 setHistorySnapshots(list);230 } finally {231 setHistoryLoading(false);232 }233 }234235 async function handleRestoreVaultSnapshot(id: string) {236 if (historyRestoring()) return;237 setHistoryRestoring(id);238 const res = await sendMessage({239 type: "RESTORE_VAULT_SNAPSHOT",240 payload: { snapshotId: id },241 });242 setHistoryRestoring(null);243 if ((res as { ok: boolean }).ok) {244 245 246 props.onBack();247 }248 }249250 function formatSnapshotTime(savedAt: number): string {251 const diff = Date.now() - savedAt;252 if (diff < 60_000) return "Just now";253 if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;254 const d = new Date(savedAt);255 const isToday = new Date().toDateString() === d.toDateString();256 const isYest =257 new Date(Date.now() - 86_400_000).toDateString() === d.toDateString();258 const t = d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });259 if (isToday) return `Today ${t}`;260 if (isYest) return `Yesterday ${t}`;261 return `${d.toLocaleDateString([], { month: "short", day: "numeric" })} ${t}`;262 }263264 function reasonLabel(reason: VaultSnapshot["reason"]): string {265 return (266 {267 local_save: "saved",268 sync_received: "synced",269 import: "imported",270 restore: "restored",271 } as const272 )[reason] ?? reason;273 }274275 const [showVaultAdvanced, setShowVaultAdvanced] = createSignal(false);276277 const [showChangePw, setShowChangePw] = createSignal(false);278 const [changePwCurrent, setChangePwCurrent] = createSignal("");279 const [changePwNew, setChangePwNew] = createSignal("");280 const [changePwConfirm, setChangePwConfirm] = createSignal("");281 const [changePwSaving, setChangePwSaving] = createSignal(false);282 const [changePwError, setChangePwError] = createSignal<string | null>(null);283 const [changePwSuccess] = createSignal(false);284 const [showNewRecoveryWords, setShowNewRecoveryWords] = createSignal(false);285286 const [duressEnabled, setDuressEnabled] = createSignal(false);287 const [showDuressSetup, setShowDuressSetup] = createSignal(false);288 const [duressPassword, setDuressPassword] = createSignal("");289 const [duressConfirm, setDuressConfirm] = createSignal("");290 const [duressMainPassword, setDuressMainPassword] = createSignal("");291 const [duressSaving, setDuressSaving] = createSignal(false);292 const [duressError, setDuressError] = createSignal<string | null>(null);293 const [duressSuccess, setDuressSuccess] = createSignal<string | null>(null);294295 const [canaryEntries, setCanaryEntries] = createSignal<Array<{id: string; title: string; canary: Canary}>>([]);296297 const [canaryTestingId, setCanaryTestingId] = createSignal<string | null>(null);298 const [canaryTestResult, setCanaryTestResult] = createSignal<{id: string; ok: boolean} | null>(null);299300 const [fbCategory, setFbCategory] = createSignal("Bug report");301 const [fbMessage, setFbMessage] = createSignal("");302 type FeedbackState = 'idle' | 'sending' | 'sent' | 'error';303 const [feedbackState, setFeedbackState] = createSignal<FeedbackState>('idle');304305 createEffect(() => {306 if (props.upgradeHighlight) {307 setActiveCard("pro");308 }309 });310311 onMount(async () => {312 const [licenseRes, passkeyRes, duressRes, settingsRes, keyRes] = await Promise.all([313 sendMessage({ type: "GET_LICENSE" }),314 sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }),315 sendMessage({ type: "IS_DURESS_ENABLED" }),316 sendMessage({ type: "GET_SETTINGS" }),317 sendMessage({ type: "GET_LICENSE_KEY" }),318 ]);319320 321 322 void loadVaultHistory();323324 if ((licenseRes as { ok: boolean }).ok) {325 setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null);326 }327328 if ((passkeyRes as { ok: boolean }).ok) {329 setPasskeyState(330 (passkeyRes as { ok: boolean; data?: PasskeyProviderState }).data ?? null331 );332 }333334 if ((duressRes as { ok: boolean }).ok) {335 setDuressEnabled((duressRes as { ok: boolean; data?: boolean }).data ?? false);336 }337338 if ((settingsRes as { ok: boolean }).ok) {339 const sData = (settingsRes as { ok: boolean; data?: { declinedCardFingerprints?: string[] } }).data;340 setDeclinedCardCount(sData?.declinedCardFingerprints?.length ?? 0);341 }342343 if ((keyRes as { ok: boolean }).ok) {344 setLicenseKey((keyRes as { ok: boolean; data?: string }).data ?? null);345 }346347 const entriesRes = await sendMessage({ type: "GET_ENTRIES" });348 if ((entriesRes as { ok: boolean }).ok) {349 const entries = (entriesRes as { ok: boolean; data?: Array<{id: string; title: string; canary?: Canary}> }).data ?? [];350 setCanaryEntries(351 entries352 .filter((e) => e.canary?.webhookUrl)353 .map((e) => ({ id: e.id, title: e.title, canary: e.canary! }))354 );355 }356357358 if (isNativePlatform()) {359 const biometric = nativePlugin<{ isAvailable: () => Promise<{ available: boolean }> }>("BiometricPlugin");360 const keystore = nativePlugin<{ hasMasterKey: () => Promise<{ exists: boolean }> }>("KeystorePlugin");361 try {362 if (biometric) {363 const a = await biometric.isAvailable();364 setBiometricSupported(a.available);365 }366 if (keystore) {367 const k = await keystore.hasMasterKey();368 setBiometricStored(k.exists);369 }370 } catch { }371 }372 });373374 async function handleEnableBiometrics() {375 setBiometricBusy(true);376 setBiometricError(null);377 try {378 const keyRes = (await sendMessage({ type: "GET_DERIVED_KEY" })) as {379 ok: boolean;380 data?: { masterKeyHex: string };381 error?: string;382 };383 if (!keyRes.ok || !keyRes.data?.masterKeyHex) {384 setBiometricError(keyRes.error === "vault_locked"385 ? "unlock the vault first"386 : "couldn't read derived key");387 return;388 }389 const keystore = nativePlugin<{ storeMasterKey: (p: { masterKeyHex: string }) => Promise<void> }>("KeystorePlugin");390 if (!keystore) {391 setBiometricError("biometric plugin unavailable");392 return;393 }394 await keystore.storeMasterKey({ masterKeyHex: keyRes.data.masterKeyHex });395 await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: true } });396 setBiometricStored(true);397 } catch (e) {398 setBiometricError(e instanceof Error ? e.message : "failed to enable biometrics");399 } finally {400 setBiometricBusy(false);401 }402 }403404 async function handleDisableBiometrics() {405 setBiometricBusy(true);406 setBiometricError(null);407 try {408 const keystore = nativePlugin<{ clearMasterKey: () => Promise<void> }>("KeystorePlugin");409 if (keystore) await keystore.clearMasterKey();410 await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: false } });411 setBiometricStored(false);412 } catch (e) {413 setBiometricError(e instanceof Error ? e.message : "failed to disable biometrics");414 } finally {415 setBiometricBusy(false);416 }417 }418419 async function handleChangePassword() {420 setChangePwError(null);421 if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; }422 if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; }423 if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; }424 if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; }425 setChangePwSaving(true);426 const res = await sendMessage({427 type: "CHANGE_PASSWORD",428 payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() },429 }) as { ok: boolean; error?: string };430 if (res.ok) {431 setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");432 setShowChangePw(false);433 setShowNewRecoveryWords(true);434 } else {435 setChangePwError(res.error ?? "failed to change password");436 }437 setChangePwSaving(false);438 }439440 const [copied, setCopied] = createSignal(false);441442 async function copyLicenseKey(key: string) {443 await navigator.clipboard.writeText(key);444 setCopied(true);445 setTimeout(() => setCopied(false), 2000);446 }447448 async function handleActivateLicense() {449 const key = licenseKeyInput().trim();450 if (!key) return;451 setActivatingLicense(true);452 setLicenseError(null);453 setLicenseSuccess(null);454 const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } });455 setActivatingLicense(false);456 if ((res as { ok: boolean }).ok) {457 const data = (res as { ok: boolean; data?: LicenseInfo }).data;458 if (data?.valid) {459 setLicenseInfo(data);460 setLicenseKey(key);461 setLicenseSuccess("License activated!");462 setLicenseKeyInput("");463 } else {464 setLicenseError("Invalid license key");465 }466 } else {467 setLicenseError("Activation failed");468 }469 }470471 async function handleTogglePasskeys() {472 const current = passkeyState();473 if (!current || !current.supported) return;474 setPasskeySaving(true);475 const res = await sendMessage({476 type: "SET_PASSKEY_PROVIDER_ENABLED",477 payload: { enabled: !current.enabled },478 });479 setPasskeySaving(false);480 if ((res as { ok: boolean }).ok) {481 setPasskeyState(482 (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null483 );484 }485 }486487 async function handleCheckAllBreaches() {488 setCheckingBreaches(true);489 setBreachResult(null);490 const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });491 setCheckingBreaches(false);492 if ((res as { ok: boolean }).ok) {493 const data = (res as { ok: boolean; data?: Record<string, number> }).data ?? {};494 const breached = Object.values(data).filter((count) => count > 0).length;495 setBreachResult(496 breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean"497 );498 props.onBreachCheckDone();499 }500 }501502503 async function handleSetupDuress() {504 const mainPw = duressMainPassword().trim();505 const duressPw = duressPassword().trim();506 const confirmPw = duressConfirm().trim();507508 if (!mainPw) { setDuressError("enter your main password to confirm"); return; }509 if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; }510 if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; }511 if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; }512513 setDuressSaving(true);514 setDuressError(null);515516 const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };517 if (!verifyRes.ok || !verifyRes.data) {518 setDuressError("incorrect main password");519 setDuressSaving(false);520 return;521 }522523 const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string };524 setDuressSaving(false);525 if (res.ok) {526 setDuressEnabled(true);527 setShowDuressSetup(false);528 setDuressPassword("");529 setDuressConfirm("");530 setDuressMainPassword("");531 setDuressSuccess("Duress password set. If entered, a decoy vault will be shown.");532 setTimeout(() => setDuressSuccess(null), 5000);533 } else {534 setDuressError(535 res.error === "session_expired"536 ? "Vault session expired — lock and unlock Peach, then try again"537 : res.error ?? "failed to set duress password"538 );539 }540 }541542 async function handleClearDeclinedCards() {543 await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } });544 setDeclinedCardCount(0);545 }546547 async function handleCountDuplicates() {548 setDedupRunning(true);549 setDedupDone(null);550 const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number };551 setDedupRunning(false);552 if (res.ok) setDedupCount(res.data ?? 0);553 }554555 async function handleDeduplicate() {556 setDedupRunning(true);557 const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number };558 setDedupRunning(false);559 if (res.ok) {560 setDedupDone(res.data ?? 0);561 setDedupCount(0);562 }563 }564565 async function handleDisableDuress() {566 const mainPw = duressMainPassword().trim();567 if (!mainPw) { setDuressError("enter your main password to confirm"); return; }568569 setDuressSaving(true);570 setDuressError(null);571572 const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };573 if (!verifyRes.ok || !verifyRes.data) {574 setDuressError("incorrect main password");575 setDuressSaving(false);576 return;577 }578579 const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string };580 setDuressSaving(false);581 if (res.ok) {582 setDuressEnabled(false);583 setShowDuressSetup(false);584 setDuressMainPassword("");585 setDuressSuccess("Duress password disabled.");586 setTimeout(() => setDuressSuccess(null), 4000);587 } else {588 setDuressError(res.error ?? "failed to disable duress password");589 }590 }591592 async function handleSubmitFeedback() {593 const message = sanitizeFeedbackMessage(fbMessage());594 if (!message) return;595596 setFeedbackState('sending');597598 try {599 const category = FEEDBACK_CATEGORIES.includes(fbCategory())600 ? fbCategory()601 : FEEDBACK_CATEGORIES[0];602 const res = await fetch('https://api.web3forms.com/submit', {603 method: 'POST',604 headers: { 'Content-Type': 'application/json' },605 body: JSON.stringify({606 access_key: WEB3FORMS_KEY,607 subject: `Peach Feedback - ${category}`,608 message,609 from_name: 'Peach User',610 611 botcheck: '',612 }),613 });614615 if (!res.ok) throw new Error(`HTTP ${res.status}`);616 const data = await res.json() as { success: boolean; message?: string };617 if (!data.success) throw new Error(data.message ?? 'unknown error');618619 setFeedbackState('sent');620 setFbMessage('');621 setFbCategory(FEEDBACK_CATEGORIES[0]);622 } catch (err) {623 console.error('[feedback]', err);624 setFeedbackState('error');625 setTimeout(() => setFeedbackState('idle'), 4000);626 }627 }628629 function renderSecurity() {630 return (631 <div class="peach-settings-body">632 <div class="peach-settings-group">633 <p class="peach-settings-sub-label">LOCKING</p>634 <div class="peach-settings-row">635 <span>Lock on close</span>636 <button637 class={`peach-toggle ${props.lockOnClose ? "is-on" : ""}`}638 onClick={() => void props.onLockOnCloseChange(!props.lockOnClose)}639 aria-label="Toggle lock on close"640 >641 <span class="peach-toggle-knob"></span>642 </button>643 </div>644 <div class="peach-settings-row">645 <span>Auto-lock after</span>646 <select647 class="peach-settings-select"648 value={String(props.autoLockMinutes)}649 onChange={(e) => void props.onAutoLockMinutesChange(Number(e.currentTarget.value))}650 >651 <option value="1">1 minute</option>652 <option value="5">5 minutes</option>653 <option value="15">15 minutes</option>654 <option value="30">30 minutes</option>655 <option value="0">Never</option>656 </select>657 </div>658659 <Show when={isNativePlatform() && biometricSupported()}>660 <p class="peach-settings-sub-label">BIOMETRICS</p>661 <div class="peach-settings-row">662 <span>Unlock with biometrics</span>663 <button664 class={`peach-toggle ${biometricStored() ? "is-on" : ""}`}665 onClick={() => void (biometricStored() ? handleDisableBiometrics() : handleEnableBiometrics())}666 disabled={biometricBusy()}667 aria-label="Toggle biometric unlock"668 >669 <span class="peach-toggle-knob"></span>670 </button>671 </div>672 <Show when={biometricError()}>673 <div class="peach-settings-row peach-settings-row--meta">674 <span class="peach-hint peach-hint-error">{biometricError()}</span>675 </div>676 </Show>677 </Show>678679 <p class="peach-settings-sub-label">BREACH PROTECTION</p>680 <div class="peach-settings-row">681 <span>Breach monitoring</span>682 <button683 class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}684 onClick={() => void props.onBreachMonitoringChange(!props.breachMonitoring)}685 aria-label="Toggle breach monitoring"686 >687 <span class="peach-toggle-knob"></span>688 </button>689 </div>690 <div class="peach-settings-row peach-settings-row--meta">691 <span>Check all passwords</span>692 <button693 class="peach-btn peach-btn--subtle peach-btn--sm"694 onClick={() => void handleCheckAllBreaches()}695 disabled={checkingBreaches()}696 >697 {checkingBreaches() ? "checking..." : "run"}698 </button>699 </div>700 <Show when={breachResult()}>701 <div class="peach-settings-row peach-settings-row--meta">702 <span>Last check</span>703 <span class="peach-settings-value">{breachResult()}</span>704 </div>705 </Show>706707 <p class="peach-settings-sub-label">MASTER PASSWORD</p>708 <div class="peach-settings-row peach-settings-row--meta">709 <span>Master password</span>710 <button711 class="peach-btn peach-btn--subtle peach-btn--sm"712 onClick={() => { setShowChangePw((v) => !v); setChangePwError(null); }}713 >714 {showChangePw() ? "cancel" : "change"}715 </button>716 </div>717 <Show when={showChangePw()}>718 <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">719 <input720 class="peach-settings-input"721 type="password"722 placeholder="current password"723 value={changePwCurrent()}724 onInput={(e) => setChangePwCurrent((e.target as HTMLInputElement).value)}725 autocomplete="current-password"726 />727 <input728 class="peach-settings-input"729 type="password"730 placeholder="new password (min 12 chars)"731 value={changePwNew()}732 onInput={(e) => setChangePwNew((e.target as HTMLInputElement).value)}733 autocomplete="new-password"734 />735 <input736 class="peach-settings-input"737 type="password"738 placeholder="confirm new password"739 value={changePwConfirm()}740 onInput={(e) => setChangePwConfirm((e.target as HTMLInputElement).value)}741 autocomplete="new-password"742 onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }}743 />744 <Show when={changePwError()}>745 <p class="peach-hint peach-hint-error" style="margin:0">{changePwError()}</p>746 </Show>747 <Show when={changePwSuccess()}>748 <p class="peach-hint" style="margin:0;color:var(--peach-accent)">password changed ✓</p>749 </Show>750 <button751 class="peach-btn peach-btn--accent peach-btn--sm"752 style="align-self:flex-start"753 onClick={() => void handleChangePassword()}754 disabled={changePwSaving() || !changePwCurrent() || !changePwNew() || !changePwConfirm()}755 >756 {changePwSaving() ? "saving…" : "save new password"}757 </button>758 </div>759 </Show>760761 <p class="peach-settings-sub-label">762 DURESS PASSWORD763 <Show when={!isPro()}>764 <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>765 </Show>766 </p>767 <div class="peach-settings-row peach-settings-row--top">768 <div class="peach-settings-row-main">769 <span>770 Duress password771 <Show when={!isPro()}>772 <span class="peach-pro-badge">Pro</span>773 </Show>774 </span>775 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">776 If entered under coercion, shows a decoy empty vault instead of your real one.777 </span>778 </div>779 <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>780 {duressEnabled() ? "on" : "off"}781 </div>782 </div>783784 <Show when={duressEnabled() && !showDuressSetup()}>785 <div class="peach-settings-row peach-settings-row--meta">786 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">787 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.788 </span>789 </div>790 </Show>791792 <Show when={duressSuccess()}>793 <div class="peach-settings-row peach-settings-row--meta">794 <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>795 </div>796 </Show>797798 <Show when={!showDuressSetup()}>799 <div class="peach-settings-row peach-settings-row--meta">800 <button801 class="peach-btn peach-btn--subtle peach-btn--sm"802 onClick={() => {803 804 805 806 807 808 if (!isPro() && !duressEnabled()) {809 props.onProGate?.("duress_vault");810 setActiveCard("pro");811 return;812 }813 setShowDuressSetup(true);814 setDuressError(null);815 setDuressSuccess(null);816 setDuressPassword("");817 setDuressConfirm("");818 setDuressMainPassword("");819 }}820 >821 {duressEnabled() ? "Change / disable" : "Set up"}822 </button>823 </div>824 </Show>825826 <Show when={showDuressSetup()}>827 <div class="peach-duress-form">828 <label class="peach-label">main password (to confirm)</label>829 <input830 class="peach-settings-input"831 type="password"832 placeholder="your current master password"833 autocomplete="current-password"834 value={duressMainPassword()}835 onInput={(e) => setDuressMainPassword(e.currentTarget.value)}836 />837 <Show when={!duressEnabled()}>838 <label class="peach-label">new duress password</label>839 <input840 class="peach-settings-input"841 type="password"842 placeholder="duress password (min 8 chars)"843 autocomplete="new-password"844 value={duressPassword()}845 onInput={(e) => setDuressPassword(e.currentTarget.value)}846 />847 <label class="peach-label">confirm duress password</label>848 <input849 class="peach-settings-input"850 type="password"851 placeholder="confirm duress password"852 autocomplete="new-password"853 value={duressConfirm()}854 onInput={(e) => setDuressConfirm(e.currentTarget.value)}855 />856 </Show>857858 <Show when={duressError()}>859 <p class="peach-hint peach-hint-error">{duressError()}</p>860 </Show>861862 <div class="peach-duress-actions">863 <button864 class="peach-btn peach-btn--subtle peach-btn--sm"865 onClick={() => { setShowDuressSetup(false); setDuressError(null); }}866 >867 cancel868 </button>869 <Show when={duressEnabled()}>870 <button871 class="peach-btn peach-btn--ghost peach-btn--sm"872 style="color:var(--peach-breach)"873 onClick={() => void handleDisableDuress()}874 disabled={duressSaving()}875 >876 {duressSaving() ? "disabling..." : "disable duress"}877 </button>878 </Show>879 <Show when={!duressEnabled()}>880 <button881 class="peach-btn peach-btn--accent peach-btn--sm"882 onClick={() => void handleSetupDuress()}883 disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}884 >885 {duressSaving() ? "saving..." : "save"}886 </button>887 </Show>888 </div>889 </div>890 </Show>891892 <p class="peach-settings-sub-label">893 CANARY ENTRIES894 <Show when={!isPro()}>895 <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>896 </Show>897 </p>898 <div class="peach-settings-row peach-settings-row--top">899 <div class="peach-settings-row-main">900 <span>901 Canary entries902 <Show when={!isPro()}>903 <span class="peach-pro-badge">Pro</span>904 </Show>905 </span>906 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">907 Silently pings a webhook when an entry is accessed. Configure canary on any entry in your vault.908 </span>909 </div>910 <span class="peach-settings-value" style="flex-shrink:0">911 {canaryEntries().length > 0 ? `${canaryEntries().length} active` : "none"}912 </span>913 </div>914 <Show when={canaryEntries().length > 0}>915 <For each={canaryEntries()}>916 {(entry) => (917 <div class="peach-settings-row peach-settings-row--meta">918 <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0">{entry.title}</span>919 <button920 class="peach-btn peach-btn--subtle peach-btn--sm"921 onClick={() => void handleTestCanary(entry)}922 disabled={canaryTestingId() === entry.id}923 style="flex-shrink:0"924 >925 {canaryTestingId() === entry.id926 ? "sending…"927 : canaryTestResult()?.id === entry.id928 ? (canaryTestResult()!.ok ? "sent ✓" : "failed ✗")929 : "test"}930 </button>931 </div>932 )}933 </For>934 </Show>935936 <p class="peach-settings-sub-label">PASSKEYS</p>937 <div class="peach-settings-row peach-settings-row--top">938 <div class="peach-settings-row-main">939 <span>Intercept passkey requests</span>940 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">941 Peach can handle passkey sign-in and creation on supported Chromium browsers.942 </span>943 </div>944 <button945 class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}946 onClick={() => void handleTogglePasskeys()}947 aria-label="Toggle passkey interception"948 disabled={!passkeyState()?.supported || passkeySaving()}949 >950 <span class="peach-toggle-knob"></span>951 </button>952 </div>953 <div class="peach-settings-row peach-settings-row--meta">954 <span>Status</span>955 <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>956 </div>957 <div class="peach-settings-row peach-settings-row--meta">958 <span>Browser</span>959 <span class="peach-settings-value">960 {passkeyState()?.browserLabel ?? "Unknown"}961 {passkeyState()?.supported ? " ✓" : " (interception unavailable)"}962 </span>963 </div>964 <div class="peach-info-notice" style="margin-top:8px">965 Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.966 </div>967 <Show when={passkeyState()?.conflict}>968 <div class="peach-settings-row peach-settings-row--meta">969 <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>970 </div>971 </Show>972 </div>973 </div>974 );975 }976977 function renderVault() {978 return (979 <div class="peach-settings-body">980 <div class="peach-settings-group">981 <p class="peach-settings-sub-label">ENTRIES</p>982 <div class="peach-settings-row peach-settings-row--meta">983 <span>Entries</span>984 <span class="peach-settings-value">{props.entriesCount}</span>985 </div>986 <div class="peach-settings-row peach-settings-row--meta">987 <span>Duplicates</span>988 <Show when={dedupDone() !== null} fallback={989 <button990 class="peach-btn peach-btn--subtle peach-btn--sm"991 onClick={() => void handleCountDuplicates()}992 disabled={dedupRunning()}993 >994 {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}995 </button>996 }>997 <span class="peach-settings-value" style="color:var(--peach-sync-ok)">998 {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}999 </span>1000 </Show>1001 </div>1002 <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>1003 <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">1004 <div class="peach-restore-warning">1005 {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.1006 </div>1007 <div style="display:flex;gap:6px">1008 <button1009 class="peach-btn peach-btn--subtle peach-btn--sm"1010 onClick={() => setDedupCount(null)}1011 >1012 cancel1013 </button>1014 <button1015 class="peach-btn peach-btn--accent peach-btn--sm"1016 onClick={() => void handleDeduplicate()}1017 disabled={dedupRunning()}1018 >1019 {dedupRunning() ? "removing..." : "remove duplicates"}1020 </button>1021 </div>1022 </div>1023 </Show>10241025 <p class="peach-settings-sub-label">MAINTENANCE</p>1026 <div class="peach-settings-row peach-settings-row--meta">1027 <span>Trash auto-delete</span>1028 <select1029 class="peach-settings-select"1030 value={String(props.trashRetentionDays)}1031 onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}1032 >1033 <option value="7">7 days</option>1034 <option value="30">30 days</option>1035 <option value="90">90 days</option>1036 <option value="0">Never</option>1037 </select>1038 </div>1039 <div class="peach-settings-row peach-settings-row--meta">1040 <span>Vault Health</span>1041 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>1042 open1043 </button>1044 </div>1045 <Show when={declinedCardCount() > 0}>1046 <div class="peach-settings-row peach-settings-row--meta">1047 <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>1048 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>1049 Clear1050 </button>1051 </div>1052 </Show>10531054 <p class="peach-settings-sub-label">DATA</p>1055 <div class="peach-settings-row peach-settings-row--meta">1056 <span>Import passwords</span>1057 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>1058 open1059 </button>1060 </div>1061 <div class="peach-settings-row peach-settings-row--meta">1062 <span>Export backup</span>1063 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onExport}>1064 open1065 </button>1066 </div>1067 <div class="peach-settings-row peach-settings-row--meta">1068 <span>1069 Peach Codex1070 <Show when={!isPro()}>1071 <span class="peach-pro-badge">Pro</span>1072 </Show>1073 </span>1074 <button1075 class="peach-btn peach-btn--subtle peach-btn--sm"1076 onClick={() => props.onOpenCodexGenerate?.()}1077 >1078 open1079 </button>1080 </div>1081 {1082}1083 <div class="peach-settings-row peach-settings-row--meta">1084 <span>Restore from Codex</span>1085 <button1086 class="peach-btn peach-btn--subtle peach-btn--sm"1087 onClick={() => props.onOpenCodexRestore?.()}1088 >1089 open1090 </button>1091 </div>10921093 <span class="peach-settings-sub-label">VAULT HISTORY</span>1094 <Show when={historyLoading()}>1095 <p class="peach-settings-hint">Loading…</p>1096 </Show>1097 <Show when={!historyLoading() && historySnapshots().length === 0}>1098 <p class="peach-settings-hint">1099 No snapshots yet. Peach saves automatically before every change —1100 saves, syncs, and imports.1101 </p>1102 </Show>1103 <Show when={!historyLoading() && historySnapshots().length > 0}>1104 <p class="peach-settings-hint">1105 Restoring locks your vault. Unlock with the password that1106 encrypted that snapshot to confirm.1107 </p>1108 <div class="peach-history-list">1109 <For each={historySnapshots().slice(0, historyShowAll() ? undefined : 3)}>1110 {(snap) => (1111 <div class="peach-history-row">1112 <div class="peach-history-meta">1113 <span class="peach-history-time">1114 {formatSnapshotTime(snap.savedAt)}1115 </span>1116 <span class="peach-history-sub">1117 {[1118 snap.entryCount !== null1119 ? `${snap.entryCount} ${snap.entryCount === 1 ? "entry" : "entries"}`1120 : null,1121 reasonLabel(snap.reason),1122 ]1123 .filter(Boolean)1124 .join(" · ")}1125 </span>1126 </div>1127 <button1128 class={`peach-btn peach-btn--subtle peach-btn--sm peach-history-btn${1129 historyRestoring() === snap.id ? " is-busy" : ""1130 }`}1131 onClick={() => void handleRestoreVaultSnapshot(snap.id)}1132 disabled={historyRestoring() !== null}1133 >1134 <Show when={historyRestoring() === snap.id} fallback="Restore">1135 …1136 </Show>1137 </button>1138 </div>1139 )}1140 </For>1141 </div>1142 <Show when={historySnapshots().length > 3}>1143 <button1144 class="peach-settings-advanced-toggle"1145 onClick={() => setHistoryShowAll((v) => !v)}1146 aria-expanded={historyShowAll()}1147 >1148 <span class="peach-settings-hint" style="margin:0">1149 {historyShowAll() ? "show less" : `${historySnapshots().length - 3} older`}1150 </span>1151 <span class="peach-settings-advanced-chevron">1152 {historyShowAll() ? "▲" : "▼"}1153 </span>1154 </button>1155 </Show>1156 <p class="peach-history-footer">1157 {historySnapshots().length} of 10 snapshots1158 </p>1159 </Show>11601161 <button1162 class="peach-settings-advanced-toggle"1163 onClick={() => setShowVaultAdvanced((v) => !v)}1164 aria-expanded={showVaultAdvanced()}1165 >1166 <span class="peach-settings-sub-label" style="margin:0">ADVANCED</span>1167 <span class="peach-settings-advanced-chevron">1168 {showVaultAdvanced() ? "▲" : "▼"}1169 </span>1170 </button>11711172 <Show when={showVaultAdvanced()}>1173 <div class="peach-danger-card">1174 <div class="peach-danger-card-head">1175 <span class="peach-danger-card-glyph" aria-hidden="true">⟳</span>1176 <h3 class="peach-danger-card-title">Replace vault from backup</h3>1177 </div>1178 <p class="peach-danger-card-copy">1179 Wipes the current vault and restores from a backup file.1180 Recoverable only if your backup is good.1181 </p>1182 <button1183 class="peach-danger-card-button"1184 onClick={() => props.onOpenFileImport?.()}1185 >1186 Choose backup file1187 </button>1188 </div>11891190 <Show when={props.onOpenPanic}>1191 <div class="peach-danger-card peach-danger-card--hazard">1192 <div class="peach-danger-card-head">1193 <span class="peach-danger-card-glyph" aria-hidden="true">⚠</span>1194 <h3 class="peach-danger-card-title">Destroy vault</h3>1195 </div>1196 <p class="peach-danger-card-copy">1197 Permanently wipes every entry, setting, and license on this1198 device. There is no recovery.1199 </p>1200 <button1201 class="peach-danger-card-button"1202 onClick={() => props.onOpenPanic?.()}1203 >1204 Begin destruction1205 </button>1206 </div>1207 </Show>1208 </Show>1209 </div>1210 </div>1211 );1212 }12131214 function renderSync() {1215 return (1216 <div class="peach-settings-body">1217 <div class="peach-settings-group">1218 <p class="peach-settings-sub-label">PAIRING</p>1219 <div class="peach-settings-row peach-settings-row--top">1220 <div class="peach-settings-row-main">1221 <span>1222 Peach Sync1223 <Show when={!isPro()}>1224 <span class="peach-pro-badge">Pro</span>1225 </Show>1226 </span>1227 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1228 Pair nearby devices by chirp and keep encrypted vault blobs in step without a central account.1229 </span>1230 </div>1231 <span class={`stk-pip ${isPro() ? "is-on" : ""}`} aria-hidden="true" />1232 </div>1233 <div class="peach-settings-row peach-settings-row--meta">1234 <span>Status</span>1235 <span class="peach-settings-value">{isPro() ? "Ready" : "Requires Pro"}</span>1236 </div>1237 <div class="peach-settings-row peach-settings-row--meta">1238 <span>Sync hub</span>1239 <button1240 class="peach-btn peach-btn--subtle peach-btn--sm"1241 onClick={() => props.onOpenSyncPairing?.()}1242 >1243 open1244 </button>1245 </div>12461247 <p class="peach-settings-sub-label">DEVICE FLOW</p>1248 <div class="peach-settings-row peach-settings-row--top">1249 <div class="peach-settings-row-main">1250 <span>Pair new device</span>1251 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1252 Opens the full chirp pairing console with scan, show-code, and advanced diagnostics.1253 </span>1254 </div>1255 <button1256 class="peach-btn peach-btn--subtle peach-btn--sm"1257 onClick={() => props.onOpenSyncPairing?.()}1258 >1259 pair1260 </button>1261 </div>1262 <div class="peach-info-notice" style="margin-top:8px">1263 Sync uses the same zero-knowledge vault format as backup and restore; pairing authorizes a device, not a cloud account.1264 </div>1265 </div>1266 </div>1267 );1268 }12691270 function renderAppearance() {1271 const sizeOptions: Array<{ id: "medium" | "large"; label: string }> = [1272 { id: "medium", label: "Default" },1273 { id: "large", label: "Large" },1274 ];1275 return (1276 <div class="peach-settings-body">1277 <div class="peach-settings-group">1278 <p class="peach-settings-sub-label">THEME</p>1279 <div class="peach-settings-row peach-settings-row--meta">1280 <span>Theme</span>1281 <button1282 class="peach-btn peach-btn--subtle peach-btn--sm"1283 onClick={() =>1284 props.onThemeChange(props.theme === "dark" ? "light" : "dark")1285 }1286 >1287 {props.theme === "dark" ? "Switch to light" : "Switch to dark"}1288 </button>1289 </div>12901291 <p class="peach-settings-sub-label">ACCENT</p>1292 <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">1293 <span>Accent</span>1294 <div class="peach-accent-picker">1295 <For each={ACCENT_COLORS}>1296 {(color) => (1297 <button1298 class="peach-accent-swatch peach-swatch"1299 classList={{ "is-active": props.accentColor === color.name }}1300 style={{ background: color.hex }}1301 onClick={() => void props.onAccentChange(color.name)}1302 aria-label={color.name}1303 title={color.name}1304 />1305 )}1306 </For>1307 </div>1308 </div>13091310 <p class="peach-settings-sub-label">TEXT SIZE</p>1311 <div class="peach-settings-row peach-settings-row--meta">1312 <span>Size</span>1313 <div class="peach-pill-group">1314 <For each={sizeOptions}>1315 {(opt) => (1316 <button1317 class="peach-btn peach-btn--ghost peach-btn--sm"1318 classList={{ "is-active": props.textSize === opt.id }}1319 onClick={() => void props.onTextSizeChange(opt.id)}1320 >1321 {opt.label}1322 </button>1323 )}1324 </For>1325 </div>1326 </div>13271328 <p class="peach-settings-sub-label">MOTION</p>1329 <div class="peach-settings-row peach-settings-row--meta">1330 <span>Reduce motion</span>1331 <button1332 class="peach-btn peach-btn--subtle peach-btn--sm"1333 onClick={() => void props.onReduceMotionChange(!props.reduceMotion)}1334 >1335 {props.reduceMotion ? "On" : "Off"}1336 </button>1337 </div>1338 </div>1339 </div>1340 );1341 }13421343 function renderShortcuts() {1344 const [capturingAction, setCapturingAction] = createSignal<ShortcutAction | null>(null);1345 const [conflictMsg, setConflictMsg] = createSignal<string | null>(null);13461347 function startCapture(action: ShortcutAction) {1348 setConflictMsg(null);1349 setCapturingAction(action);1350 }1351 function cancelCapture() {1352 setCapturingAction(null);1353 setConflictMsg(null);1354 }1355 function commitBinding(action: ShortcutAction, binding: Keybinding) {1356 const conflict = findConflict(props.shortcuts, binding, action);1357 if (conflict) {1358 const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict);1359 setConflictMsg(`Already bound to "${other?.label ?? conflict}"`);1360 return;1361 }1362 const next = { ...props.shortcuts, [action]: binding };1363 setCapturingAction(null);1364 setConflictMsg(null);1365 void props.onShortcutsChange(next);1366 }1367 function resetAction(action: ShortcutAction) {1368 const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] };1369 void props.onShortcutsChange(next);1370 }1371 function resetAll() {1372 void props.onShortcutsChange({ ...DEFAULT_BINDINGS });1373 }13741375 1376 1377 function handleCaptureKey(e: KeyboardEvent) {1378 e.preventDefault();1379 e.stopPropagation();1380 if (e.key === "Escape") { cancelCapture(); return; }1381 const binding = keybindingFromEvent(e);1382 if (!binding) return; 1383 const action = capturingAction();1384 if (action) commitBinding(action, binding);1385 }13861387 return (1388 <div class="peach-settings-body">1389 <div class="peach-settings-group peach-shortcuts-group">1390 <p class="peach-settings-sub-label">ENTRY VIEW</p>1391 <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry")}>1392 {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1393 </For>1394 </div>13951396 <div class="peach-settings-group peach-shortcuts-group">1397 <p class="peach-settings-sub-label">ANYWHERE IN POPUP</p>1398 <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global")}>1399 {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1400 </For>1401 </div>14021403 <Show when={conflictMsg()}>1404 <div class="peach-shortcuts-conflict">{conflictMsg()}</div>1405 </Show>14061407 <div class="peach-settings-group" style="display:flex;flex-direction:column;gap:8px">1408 <div class="peach-settings-row peach-settings-row--meta">1409 <span>Inline autofill on page</span>1410 <a1411 class="peach-settings-link"1412 href="chrome://extensions/shortcuts"1413 onClick={(e) => {1414 e.preventDefault();1415 chrome.tabs.create({ url: "chrome://extensions/shortcuts" });1416 }}1417 >1418 ⌘⇧P — customize ↗1419 </a>1420 </div>1421 <button1422 class="peach-btn peach-btn--subtle peach-btn--sm"1423 style="align-self:flex-start"1424 onClick={resetAll}1425 >1426 Reset all to defaults1427 </button>1428 </div>1429 </div>1430 );14311432 function renderRow(1433 desc: typeof SHORTCUT_DESCRIPTORS[number],1434 capturing: () => ShortcutAction | null,1435 onStart: (a: ShortcutAction) => void,1436 onCancel: () => void,1437 onReset: (a: ShortcutAction) => void,1438 onKey: (e: KeyboardEvent) => void,1439 ) {1440 const isActive = () => capturing() === desc.action;1441 const binding = () => props.shortcuts[desc.action];1442 const isCustom = () => {1443 const b = binding();1444 const d = DEFAULT_BINDINGS[desc.action];1445 return b.key !== d.key1446 || Boolean(b.ctrl) !== Boolean(d.ctrl)1447 || Boolean(b.meta) !== Boolean(d.meta)1448 || Boolean(b.shift) !== Boolean(d.shift)1449 || Boolean(b.alt) !== Boolean(d.alt);1450 };1451 return (1452 <div class="peach-shortcut-row">1453 <span class="peach-shortcut-row-label">{desc.label}</span>1454 <button1455 class="peach-shortcut-chip"1456 classList={{ "is-capturing": isActive(), "is-custom": isCustom() }}1457 onClick={() => isActive() ? onCancel() : onStart(desc.action)}1458 onKeyDown={(e) => { if (isActive()) onKey(e); }}1459 ref={(el) => {1460 1461 if (el) {1462 queueMicrotask(() => { if (isActive()) el.focus(); });1463 }1464 }}1465 >1466 {isActive() ? "Press a key…" : formatKeybinding(binding())}1467 </button>1468 <Show when={isCustom() && !isActive()}>1469 <button1470 class="peach-shortcut-reset"1471 title="Reset to default"1472 onClick={() => onReset(desc.action)}1473 >1474 ↺1475 </button>1476 </Show>1477 </div>1478 );1479 }1480 }14811482 const PRO_FEATURES: Array<{1483 feature: ProFeature;1484 name: string;1485 desc: string;1486 }> = [1487 { feature: "sync", name: "Peach Sync", desc: "Encrypted · peer-to-peer" },1488 { feature: "codex", name: "Peach Codex", desc: "Restore from paper" },1489 { feature: "passkeys", name: "Passkeys", desc: "Zero-knowledge WebAuthn" },1490 { feature: "ios_app", name: "Mobile apps", desc: "Android · iOS soon" },1491 { feature: "advanced_health", name: "Vault health", desc: "Reuse · age · exposure" },1492 ];14931494 const PRO_SECURITY_FEATURES: Array<{1495 feature: ProFeature;1496 name: string;1497 desc: string;1498 }> = [1499 { feature: "canary_entry", name: "Canary entries", desc: "Detects vault compromise" },1500 { feature: "duress_vault", name: "Duress vault", desc: "Decoy under coercion" },1501 ];15021503 const [licenseKey, setLicenseKey] = createSignal<string | null>(null);1504 const [showKey, setShowKey] = createSignal(false);15051506 const [deactivating, setDeactivating] = createSignal(false);15071508 async function handleDeactivateLicense() {1509 setDeactivating(true);1510 await sendMessage({ type: "DEACTIVATE_LICENSE" });1511 setLicenseInfo(null);1512 await props.onLicenseChange?.();1513 setDeactivating(false);1514 }15151516 async function handleTestCanary(entry: {id: string; title: string; canary: Canary}) {1517 setCanaryTestingId(entry.id);1518 setCanaryTestResult(null);1519 const res = await sendMessage({1520 type: "FIRE_CANARY_PING",1521 payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger: "test" },1522 }) as { ok: boolean };1523 setCanaryTestResult({ id: entry.id, ok: res.ok });1524 setCanaryTestingId(null);1525 setTimeout(() => setCanaryTestResult((r) => r?.id === entry.id ? null : r), 3000);1526 }15271528 function renderLicense() {1529 async function onActivate() {1530 await handleActivateLicense();1531 if ((licenseInfo()?.valid ?? false) && licenseInfo()?.plan === "pro") {1532 await props.onLicenseChange?.();1533 }1534 }15351536 return (1537 <div class="peach-settings-body">1538 <Show1539 when={isPro()}1540 fallback={1541 <div class="peach-pro-upgrade">1542 <p class="peach-pro-eyebrow">PEACH PRO · ONE-TIME</p>15431544 <h2 class="peach-pro-headline">1545 Every<em>thing,</em><br />every<em>where.</em>1546 </h2>15471548 <p class="peach-pro-subtitle">1549 One key unlocks every Pro feature — for the lifetime of the app.1550 </p>15511552 <div class="peach-pro-grid">1553 <For each={PRO_FEATURES}>1554 {(item, i) => (1555 <div1556 class="peach-pro-grid-item"1557 classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}1558 >1559 <span class="peach-pro-grid-num">{String(i() + 1).padStart(2, "0")}</span>1560 <span class="peach-pro-grid-name">{item.name}</span>1561 <span class="peach-pro-grid-desc">{item.desc}</span>1562 </div>1563 )}1564 </For>1565 </div>15661567 <p class="peach-pro-section-label">ADVANCED SECURITY</p>1568 <div class="peach-pro-grid">1569 <For each={PRO_SECURITY_FEATURES}>1570 {(item, i) => (1571 <div1572 class="peach-pro-grid-item"1573 classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}1574 >1575 <span class="peach-pro-grid-num">{String(PRO_FEATURES.length + i() + 1).padStart(2, "0")}</span>1576 <span class="peach-pro-grid-name">{item.name}</span>1577 <span class="peach-pro-grid-desc">{item.desc}</span>1578 </div>1579 )}1580 </For>1581 </div>15821583 <div class="peach-pro-upgrade-actions">1584 <p class="peach-pro-pricing">1585 $39 <span class="peach-pro-pricing-sub">once · yours forever</span>1586 </p>1587 <a1588 href="https://peachpasswords.com/#pricing"1589 target="_blank"1590 rel="noopener noreferrer"1591 class="peach-pro-upgrade-cta"1592 >1593 Get Peach Pro1594 </a>1595 <div class="peach-pro-key-divider"><span>ALREADY HAVE A KEY</span></div>1596 <div class="peach-pro-key-inline">1597 <input1598 class="peach-pro-key-inline-input"1599 type="text"1600 placeholder="PEACH-XXXX-XXXX-XXXX"1601 value={licenseKeyInput()}1602 onInput={(e) => setLicenseKeyInput(e.currentTarget.value)}1603 onKeyDown={(e) => { if (e.key === "Enter") void onActivate(); }}1604 autocomplete="off"1605 spellcheck={false}1606 />1607 <button1608 class="peach-pro-key-apply"1609 onClick={() => void onActivate()}1610 disabled={activatingLicense() || !licenseKeyInput().trim()}1611 >1612 {activatingLicense() ? "…" : "APPLY"}1613 </button>1614 </div>1615 <Show when={licenseError()}>1616 <p class="peach-pro-key-error">{licenseError()}</p>1617 </Show>1618 </div>1619 </div>1620 }1621 >1622 <div class="peach-pro-active">1623 <div class="peach-pro-active-hero">1624 <div class="peach-pro-active-mark-wrap">1625 <PeachMark class="peach-pro-active-mark" />1626 </div>1627 <h2 class="peach-pro-active-title">Peach Pro</h2>1628 <p class="peach-pro-active-since">{formatActivatedSince(licenseInfo()?.issued)}</p>1629 </div>16301631 <Show when={licenseKey()}>1632 <div class="peach-settings-license">1633 <span class="peach-settings-label">LICENSE KEY</span>1634 <div class="peach-settings-license-row">1635 <Show1636 when={showKey()}1637 fallback={1638 <span class="peach-settings-license-key peach-settings-license-key--masked" aria-label="License key hidden">1639 ••••••••••••••••••••••••••••1640 </span>1641 }1642 >1643 <span class="peach-settings-license-key">{licenseKey()!}</span>1644 </Show>1645 <div class="peach-settings-license-actions">1646 <button1647 class="peach-settings-license-copy"1648 title="Copy license key"1649 onClick={() => copyLicenseKey(licenseKey()!)}1650 >1651 {copied() ? "✓" : "copy"}1652 </button>1653 <button1654 class="peach-settings-license-toggle"1655 aria-label={showKey() ? "Hide license key" : "Reveal license key"}1656 title={showKey() ? "Hide" : "Reveal"}1657 onClick={() => setShowKey(v => !v)}1658 >1659 <Show1660 when={showKey()}1661 fallback={1662 <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">1663 <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />1664 <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />1665 </svg>1666 }1667 >1668 <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">1669 <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />1670 <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />1671 <line x1="2" y1="2" x2="12" y2="12" />1672 </svg>1673 </Show>1674 </button>1675 </div>1676 </div>1677 </div>1678 </Show>16791680 <div class="peach-pro-active-features">1681 <For each={[...PRO_FEATURES, ...PRO_SECURITY_FEATURES]}>1682 {(item) => (1683 <div class="peach-pro-active-feature">1684 <span class="peach-pro-active-feature-mark">✦</span>1685 <span class="peach-pro-active-feature-name">{item.name}</span>1686 </div>1687 )}1688 </For>1689 </div>16901691 <button1692 class="peach-pro-deactivate"1693 onClick={() => void handleDeactivateLicense()}1694 disabled={deactivating()}1695 >1696 {deactivating() ? "Deactivating…" : "Deactivate"}1697 </button>1698 </div>1699 </Show>1700 </div>1701 );1702 }17031704 function renderFeedback() {1705 return (1706 <div class="peach-settings-body">1707 <div class="peach-settings-group peach-feedback-group">1708 <Show1709 when={feedbackState() === 'sent'}1710 fallback={1711 <>1712 <p class="peach-feedback-intro">1713 Help us improve Peach. Every report goes directly to our inbox.1714 </p>17151716 <p class="peach-settings-sub-label">TYPE</p>1717 <div class="peach-feedback-chips">1718 <For each={FEEDBACK_CATEGORIES}>1719 {(cat) => {1720 const selected = () => fbCategory() === cat;1721 return (1722 <button1723 type="button"1724 class={`peach-feedback-chip ${selected() ? "is-active" : ""}`}1725 aria-pressed={selected()}1726 onClick={() => setFbCategory(cat)}1727 >1728 {cat}1729 </button>1730 );1731 }}1732 </For>1733 </div>17341735 <p class="peach-settings-sub-label">MESSAGE</p>1736 <textarea1737 class="peach-feedback-textarea"1738 placeholder="Describe the issue or idea..."1739 maxLength={MAX_FEEDBACK_MESSAGE_LENGTH}1740 value={fbMessage()}1741 onInput={(e) => setFbMessage(e.currentTarget.value)}1742 />17431744 <p class="peach-feedback-meta">1745 Version {version} · will be included automatically1746 </p>17471748 <button1749 type="button"1750 class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"1751 disabled={!fbMessage().trim() || feedbackState() === 'sending'}1752 onClick={() => void handleSubmitFeedback()}1753 >1754 <Switch>1755 <Match when={feedbackState() === 'idle'}>Send</Match>1756 <Match when={feedbackState() === 'sending'}>Sending...</Match>1757 <Match when={feedbackState() === 'error'}>Failed - try again</Match>1758 </Switch>1759 </button>17601761 <p class="peach-feedback-anon">1762 Sent anonymously · no account required1763 </p>1764 </>1765 }1766 >1767 <div class="peach-feedback-thanks" role="status" aria-live="polite">1768 <p class="peach-feedback-thanks-title">Thank you</p>1769 <p>1770 Your note is in the queue. Someone will process it soon.1771 </p>1772 </div>1773 </Show>1774 </div>1775 </div>1776 );1777 }17781779 const bodies: StackBodies = {1780 protection: renderSecurity(),1781 vault: renderVault(),1782 sync: renderSync(),1783 appearance: renderAppearance(),1784 shortcuts: renderShortcuts(),1785 pro: renderLicense(),1786 feedback: renderFeedback(),1787 };17881789 return (1790 <Show1791 when={showNewRecoveryWords()}1792 fallback={1793 <div1794 class="peach-overlay-view peach-settings-overlay"1795 classList={{ "ov-enter-right": !props.isExiting, "ov-exit-right": !!props.isExiting }}1796 style="display:flex;flex-direction:column"1797 >1798 <StackDeck1799 active={activeCard()}1800 onActiveChange={setActiveCard}1801 onBack={props.onBack}1802 cards={cards()}1803 bodies={bodies}1804 version={version}1805 />1806 </div>1807 }1808 >1809 <RecoveryWordDisplayView onDone={() => setShowNewRecoveryWords(false)} />1810 </Show>1811 );1812}