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"];7677function formatActivatedSince(issued: string | undefined): string {78 if (!issued) return "Active";79 try {80 const d = new Date(issued);81 if (Number.isNaN(d.getTime())) return "Active";82 return `Active since ${d.toLocaleDateString(undefined, { month: "long", year: "numeric" })}`;83 } catch {84 return "Active";85 }86}8788function initialSectionToCard(value: string | null | undefined): CardId | null {89 if (!value) return null;90 if (value === "security") return "protection";91 if (value === "license") return "pro";92 if (93 value === "protection" ||94 value === "vault" ||95 value === "sync" ||96 value === "appearance" ||97 value === "shortcuts" ||98 value === "pro" ||99 value === "feedback"100 ) {101 return value;102 }103 return null;104}105106export function SettingsView(props: SettingsProps) {107 const manifest = chrome.runtime.getManifest();108 const version = manifest.version;109 const initial = props.initialSection;110 const [activeCard, setActiveCard] = createSignal<CardId>(111 props.upgradeHighlight ? "pro" : initialSectionToCard(initial) ?? "protection"112 );113114 createEffect(() => { props.onCardChange?.(activeCard()); });115116 const [checkingBreaches, setCheckingBreaches] = createSignal(false);117 const [breachResult, setBreachResult] = createSignal<string | null>(null);118 const [licenseInfo, setLicenseInfo] = createSignal<LicenseInfo | null>(null);119 const [licenseKeyInput, setLicenseKeyInput] = createSignal("");120 const [licenseError, setLicenseError] = createSignal<string | null>(null);121 const [, setLicenseSuccess] = createSignal<string | null>(null);122 const [activatingLicense, setActivatingLicense] = createSignal(false);123124 const isPro = () => props.isPro ?? false;125 const cards = createMemo<StackCardSpec[]>(() => [126 {127 id: "protection",128 num: "I · PROTECT",129 title: "Protection",130 lede: `${props.breachMonitoring ? "breaches watched" : "breach watch off"} · ${props.lockOnClose ? "locks on close" : "manual lock"}`,131 bgVar: "--stack-protect-bg",132 fgVar: "--stack-protect-fg",133 icon: "shield",134 },135 {136 id: "vault",137 num: "II · KEEP",138 title: "Vault",139 lede: `${props.entriesCount} ${props.entriesCount === 1 ? "entry" : "entries"} · backups, history, cleanup`,140 bgVar: "--stack-vault-bg",141 fgVar: "--stack-vault-fg",142 icon: "lock",143 },144 {145 id: "sync",146 num: "III · ECHO",147 title: "Sync",148 lede: isPro() ? "pair by chirp, keep in step" : "Pro · encrypted device pairing",149 bgVar: "--stack-sync-bg",150 fgVar: "--stack-sync-fg",151 icon: "sync",152 },153 {154 id: "appearance",155 num: "IV · DRESS",156 title: "Appearance",157 lede: `${props.theme} · ${props.accentColor} · ${props.textSize}`,158 bgVar: "--stack-appear-bg",159 fgVar: "--stack-appear-fg",160 icon: "sun",161 },162 {163 id: "shortcuts",164 num: "V · KEYS",165 title: "Shortcuts",166 lede: `${SHORTCUT_DESCRIPTORS.length} bindings · what your fingers know`,167 bgVar: "--stack-shortcut-bg",168 fgVar: "--stack-shortcut-fg",169 icon: "keys",170 },171 {172 id: "pro",173 num: "VI · FOREVER",174 title: "Peach Pro",175 lede: isPro() ? "active · paid once, yours forever" : "$39 once · yours forever",176 bgVar: "--stack-pro-bg",177 fgVar: "--stack-pro-fg",178 icon: "spark",179 },180 {181 id: "feedback",182 num: "VII · ECHO",183 title: "Feedback",184 lede: `version ${version} · tell us what would make it better`,185 bgVar: "--stack-feedback-bg",186 fgVar: "--stack-feedback-fg",187 icon: "bubble",188 },189 ]);190 const [biometricSupported, setBiometricSupported] = createSignal(false);191 const [biometricStored, setBiometricStored] = createSignal(false);192 const [biometricBusy, setBiometricBusy] = createSignal(false);193 const [biometricError, setBiometricError] = createSignal<string | null>(null);194 const [passkeyState, setPasskeyState] = createSignal<PasskeyProviderState | null>(null);195 const [passkeySaving, setPasskeySaving] = createSignal(false);196197 const [dedupCount, setDedupCount] = createSignal<number | null>(null);198 const [dedupRunning, setDedupRunning] = createSignal(false);199 const [dedupDone, setDedupDone] = createSignal<number | null>(null);200201 const [declinedCardCount, setDeclinedCardCount] = createSignal(0);202203 204 205 206 const [historySnapshots, setHistorySnapshots] = createSignal<VaultSnapshot[]>([]);207 const [historyLoading, setHistoryLoading] = createSignal(false);208 const [historyRestoring, setHistoryRestoring] = createSignal<string | null>(null);209 const [historyShowAll, setHistoryShowAll] = createSignal(false);210211 async function loadVaultHistory() {212 setHistoryLoading(true);213 try {214 const res = await sendMessage({ type: "GET_VAULT_HISTORY" });215 const list = (res as { ok: boolean; data?: VaultSnapshot[] }).data ?? [];216 setHistorySnapshots(list);217 } finally {218 setHistoryLoading(false);219 }220 }221222 async function handleRestoreVaultSnapshot(id: string) {223 if (historyRestoring()) return;224 setHistoryRestoring(id);225 const res = await sendMessage({226 type: "RESTORE_VAULT_SNAPSHOT",227 payload: { snapshotId: id },228 });229 setHistoryRestoring(null);230 if ((res as { ok: boolean }).ok) {231 232 233 props.onBack();234 }235 }236237 function formatSnapshotTime(savedAt: number): string {238 const diff = Date.now() - savedAt;239 if (diff < 60_000) return "Just now";240 if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;241 const d = new Date(savedAt);242 const isToday = new Date().toDateString() === d.toDateString();243 const isYest =244 new Date(Date.now() - 86_400_000).toDateString() === d.toDateString();245 const t = d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });246 if (isToday) return `Today ${t}`;247 if (isYest) return `Yesterday ${t}`;248 return `${d.toLocaleDateString([], { month: "short", day: "numeric" })} ${t}`;249 }250251 function reasonLabel(reason: VaultSnapshot["reason"]): string {252 return (253 {254 local_save: "saved",255 sync_received: "synced",256 import: "imported",257 restore: "restored",258 } as const259 )[reason] ?? reason;260 }261262 const [showVaultAdvanced, setShowVaultAdvanced] = createSignal(false);263264 const [showChangePw, setShowChangePw] = createSignal(false);265 const [changePwCurrent, setChangePwCurrent] = createSignal("");266 const [changePwNew, setChangePwNew] = createSignal("");267 const [changePwConfirm, setChangePwConfirm] = createSignal("");268 const [changePwSaving, setChangePwSaving] = createSignal(false);269 const [changePwError, setChangePwError] = createSignal<string | null>(null);270 const [changePwSuccess] = createSignal(false);271 const [showNewRecoveryWords, setShowNewRecoveryWords] = createSignal(false);272273 const [duressEnabled, setDuressEnabled] = createSignal(false);274 const [showDuressSetup, setShowDuressSetup] = createSignal(false);275 const [duressPassword, setDuressPassword] = createSignal("");276 const [duressConfirm, setDuressConfirm] = createSignal("");277 const [duressMainPassword, setDuressMainPassword] = createSignal("");278 const [duressSaving, setDuressSaving] = createSignal(false);279 const [duressError, setDuressError] = createSignal<string | null>(null);280 const [duressSuccess, setDuressSuccess] = createSignal<string | null>(null);281282 const [canaryEntries, setCanaryEntries] = createSignal<Array<{id: string; title: string; canary: Canary}>>([]);283284 const [canaryTestingId, setCanaryTestingId] = createSignal<string | null>(null);285 const [canaryTestResult, setCanaryTestResult] = createSignal<{id: string; ok: boolean} | null>(null);286287 const [fbCategory, setFbCategory] = createSignal("Bug report");288 const [fbMessage, setFbMessage] = createSignal("");289 type FeedbackState = 'idle' | 'sending' | 'sent' | 'error';290 const [feedbackState, setFeedbackState] = createSignal<FeedbackState>('idle');291292 createEffect(() => {293 if (props.upgradeHighlight) {294 setActiveCard("pro");295 }296 });297298 onMount(async () => {299 const [licenseRes, passkeyRes, duressRes, settingsRes, keyRes] = await Promise.all([300 sendMessage({ type: "GET_LICENSE" }),301 sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }),302 sendMessage({ type: "IS_DURESS_ENABLED" }),303 sendMessage({ type: "GET_SETTINGS" }),304 sendMessage({ type: "GET_LICENSE_KEY" }),305 ]);306307 308 309 void loadVaultHistory();310311 if ((licenseRes as { ok: boolean }).ok) {312 setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null);313 }314315 if ((passkeyRes as { ok: boolean }).ok) {316 setPasskeyState(317 (passkeyRes as { ok: boolean; data?: PasskeyProviderState }).data ?? null318 );319 }320321 if ((duressRes as { ok: boolean }).ok) {322 setDuressEnabled((duressRes as { ok: boolean; data?: boolean }).data ?? false);323 }324325 if ((settingsRes as { ok: boolean }).ok) {326 const sData = (settingsRes as { ok: boolean; data?: { declinedCardFingerprints?: string[] } }).data;327 setDeclinedCardCount(sData?.declinedCardFingerprints?.length ?? 0);328 }329330 if ((keyRes as { ok: boolean }).ok) {331 setLicenseKey((keyRes as { ok: boolean; data?: string }).data ?? null);332 }333334 const entriesRes = await sendMessage({ type: "GET_ENTRIES" });335 if ((entriesRes as { ok: boolean }).ok) {336 const entries = (entriesRes as { ok: boolean; data?: Array<{id: string; title: string; canary?: Canary}> }).data ?? [];337 setCanaryEntries(338 entries339 .filter((e) => e.canary?.webhookUrl)340 .map((e) => ({ id: e.id, title: e.title, canary: e.canary! }))341 );342 }343344345 if (isNativePlatform()) {346 const biometric = nativePlugin<{ isAvailable: () => Promise<{ available: boolean }> }>("BiometricPlugin");347 const keystore = nativePlugin<{ hasMasterKey: () => Promise<{ exists: boolean }> }>("KeystorePlugin");348 try {349 if (biometric) {350 const a = await biometric.isAvailable();351 setBiometricSupported(a.available);352 }353 if (keystore) {354 const k = await keystore.hasMasterKey();355 setBiometricStored(k.exists);356 }357 } catch { }358 }359 });360361 async function handleEnableBiometrics() {362 setBiometricBusy(true);363 setBiometricError(null);364 try {365 const keyRes = (await sendMessage({ type: "GET_DERIVED_KEY" })) as {366 ok: boolean;367 data?: { masterKeyHex: string };368 error?: string;369 };370 if (!keyRes.ok || !keyRes.data?.masterKeyHex) {371 setBiometricError(keyRes.error === "vault_locked"372 ? "unlock the vault first"373 : "couldn't read derived key");374 return;375 }376 const keystore = nativePlugin<{ storeMasterKey: (p: { masterKeyHex: string }) => Promise<void> }>("KeystorePlugin");377 if (!keystore) {378 setBiometricError("biometric plugin unavailable");379 return;380 }381 await keystore.storeMasterKey({ masterKeyHex: keyRes.data.masterKeyHex });382 await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: true } });383 setBiometricStored(true);384 } catch (e) {385 setBiometricError(e instanceof Error ? e.message : "failed to enable biometrics");386 } finally {387 setBiometricBusy(false);388 }389 }390391 async function handleDisableBiometrics() {392 setBiometricBusy(true);393 setBiometricError(null);394 try {395 const keystore = nativePlugin<{ clearMasterKey: () => Promise<void> }>("KeystorePlugin");396 if (keystore) await keystore.clearMasterKey();397 await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: false } });398 setBiometricStored(false);399 } catch (e) {400 setBiometricError(e instanceof Error ? e.message : "failed to disable biometrics");401 } finally {402 setBiometricBusy(false);403 }404 }405406 async function handleChangePassword() {407 setChangePwError(null);408 if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; }409 if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; }410 if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; }411 if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; }412 setChangePwSaving(true);413 const res = await sendMessage({414 type: "CHANGE_PASSWORD",415 payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() },416 }) as { ok: boolean; error?: string };417 if (res.ok) {418 setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");419 setShowChangePw(false);420 setShowNewRecoveryWords(true);421 } else {422 setChangePwError(res.error ?? "failed to change password");423 }424 setChangePwSaving(false);425 }426427 const [copied, setCopied] = createSignal(false);428429 async function copyLicenseKey(key: string) {430 await navigator.clipboard.writeText(key);431 setCopied(true);432 setTimeout(() => setCopied(false), 2000);433 }434435 async function handleActivateLicense() {436 const key = licenseKeyInput().trim();437 if (!key) return;438 setActivatingLicense(true);439 setLicenseError(null);440 setLicenseSuccess(null);441 const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } });442 setActivatingLicense(false);443 if ((res as { ok: boolean }).ok) {444 const data = (res as { ok: boolean; data?: LicenseInfo }).data;445 if (data?.valid) {446 setLicenseInfo(data);447 setLicenseKey(key);448 setLicenseSuccess("License activated!");449 setLicenseKeyInput("");450 } else {451 setLicenseError("Invalid license key");452 }453 } else {454 setLicenseError("Activation failed");455 }456 }457458 async function handleTogglePasskeys() {459 const current = passkeyState();460 if (!current || !current.supported) return;461 setPasskeySaving(true);462 const res = await sendMessage({463 type: "SET_PASSKEY_PROVIDER_ENABLED",464 payload: { enabled: !current.enabled },465 });466 setPasskeySaving(false);467 if ((res as { ok: boolean }).ok) {468 setPasskeyState(469 (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null470 );471 }472 }473474 async function handleCheckAllBreaches() {475 setCheckingBreaches(true);476 setBreachResult(null);477 const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });478 setCheckingBreaches(false);479 if ((res as { ok: boolean }).ok) {480 const data = (res as { ok: boolean; data?: Record<string, number> }).data ?? {};481 const breached = Object.values(data).filter((count) => count > 0).length;482 setBreachResult(483 breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean"484 );485 props.onBreachCheckDone();486 }487 }488489490 async function handleSetupDuress() {491 const mainPw = duressMainPassword().trim();492 const duressPw = duressPassword().trim();493 const confirmPw = duressConfirm().trim();494495 if (!mainPw) { setDuressError("enter your main password to confirm"); return; }496 if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; }497 if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; }498 if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; }499500 setDuressSaving(true);501 setDuressError(null);502503 const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };504 if (!verifyRes.ok || !verifyRes.data) {505 setDuressError("incorrect main password");506 setDuressSaving(false);507 return;508 }509510 const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string };511 setDuressSaving(false);512 if (res.ok) {513 setDuressEnabled(true);514 setShowDuressSetup(false);515 setDuressPassword("");516 setDuressConfirm("");517 setDuressMainPassword("");518 setDuressSuccess("Duress password set. If entered, a decoy vault will be shown.");519 setTimeout(() => setDuressSuccess(null), 5000);520 } else {521 setDuressError(522 res.error === "session_expired"523 ? "Vault session expired — lock and unlock Peach, then try again"524 : res.error ?? "failed to set duress password"525 );526 }527 }528529 async function handleClearDeclinedCards() {530 await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } });531 setDeclinedCardCount(0);532 }533534 async function handleCountDuplicates() {535 setDedupRunning(true);536 setDedupDone(null);537 const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number };538 setDedupRunning(false);539 if (res.ok) setDedupCount(res.data ?? 0);540 }541542 async function handleDeduplicate() {543 setDedupRunning(true);544 const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number };545 setDedupRunning(false);546 if (res.ok) {547 setDedupDone(res.data ?? 0);548 setDedupCount(0);549 }550 }551552 async function handleDisableDuress() {553 const mainPw = duressMainPassword().trim();554 if (!mainPw) { setDuressError("enter your main password to confirm"); return; }555556 setDuressSaving(true);557 setDuressError(null);558559 const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };560 if (!verifyRes.ok || !verifyRes.data) {561 setDuressError("incorrect main password");562 setDuressSaving(false);563 return;564 }565566 const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string };567 setDuressSaving(false);568 if (res.ok) {569 setDuressEnabled(false);570 setShowDuressSetup(false);571 setDuressMainPassword("");572 setDuressSuccess("Duress password disabled.");573 setTimeout(() => setDuressSuccess(null), 4000);574 } else {575 setDuressError(res.error ?? "failed to disable duress password");576 }577 }578579 async function handleSubmitFeedback() {580 if (!fbMessage().trim()) return;581582 setFeedbackState('sending');583584 try {585 const res = await fetch('https://api.web3forms.com/submit', {586 method: 'POST',587 headers: { 'Content-Type': 'application/json' },588 body: JSON.stringify({589 access_key: WEB3FORMS_KEY,590 subject: `Peach Feedback — ${fbCategory()}`,591 message: fbMessage(),592 from_name: 'Peach User',593 594 botcheck: '',595 }),596 });597598 if (!res.ok) throw new Error(`HTTP ${res.status}`);599 const data = await res.json() as { success: boolean; message?: string };600 if (!data.success) throw new Error(data.message ?? 'unknown error');601602 setFeedbackState('sent');603 setFbMessage('');604 setFbCategory(FEEDBACK_CATEGORIES[0]);605 setTimeout(() => setFeedbackState('idle'), 4000);606 } catch (err) {607 console.error('[feedback]', err);608 setFeedbackState('error');609 setTimeout(() => setFeedbackState('idle'), 4000);610 }611 }612613 function renderSecurity() {614 return (615 <div class="peach-settings-body">616 <div class="peach-settings-group">617 <p class="peach-settings-sub-label">LOCKING</p>618 <div class="peach-settings-row">619 <span>Lock on close</span>620 <button621 class={`peach-toggle ${props.lockOnClose ? "is-on" : ""}`}622 onClick={() => void props.onLockOnCloseChange(!props.lockOnClose)}623 aria-label="Toggle lock on close"624 >625 <span class="peach-toggle-knob"></span>626 </button>627 </div>628 <div class="peach-settings-row">629 <span>Auto-lock after</span>630 <select631 class="peach-settings-select"632 value={String(props.autoLockMinutes)}633 onChange={(e) => void props.onAutoLockMinutesChange(Number(e.currentTarget.value))}634 >635 <option value="1">1 minute</option>636 <option value="5">5 minutes</option>637 <option value="15">15 minutes</option>638 <option value="30">30 minutes</option>639 <option value="0">Never</option>640 </select>641 </div>642643 <Show when={isNativePlatform() && biometricSupported()}>644 <p class="peach-settings-sub-label">BIOMETRICS</p>645 <div class="peach-settings-row">646 <span>Unlock with biometrics</span>647 <button648 class={`peach-toggle ${biometricStored() ? "is-on" : ""}`}649 onClick={() => void (biometricStored() ? handleDisableBiometrics() : handleEnableBiometrics())}650 disabled={biometricBusy()}651 aria-label="Toggle biometric unlock"652 >653 <span class="peach-toggle-knob"></span>654 </button>655 </div>656 <Show when={biometricError()}>657 <div class="peach-settings-row peach-settings-row--meta">658 <span class="peach-hint peach-hint-error">{biometricError()}</span>659 </div>660 </Show>661 </Show>662663 <p class="peach-settings-sub-label">BREACH PROTECTION</p>664 <div class="peach-settings-row">665 <span>Breach monitoring</span>666 <button667 class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}668 onClick={() => void props.onBreachMonitoringChange(!props.breachMonitoring)}669 aria-label="Toggle breach monitoring"670 >671 <span class="peach-toggle-knob"></span>672 </button>673 </div>674 <div class="peach-settings-row peach-settings-row--meta">675 <span>Check all passwords</span>676 <button677 class="peach-btn peach-btn--subtle peach-btn--sm"678 onClick={() => void handleCheckAllBreaches()}679 disabled={checkingBreaches()}680 >681 {checkingBreaches() ? "checking..." : "run"}682 </button>683 </div>684 <Show when={breachResult()}>685 <div class="peach-settings-row peach-settings-row--meta">686 <span>Last check</span>687 <span class="peach-settings-value">{breachResult()}</span>688 </div>689 </Show>690691 <p class="peach-settings-sub-label">MASTER PASSWORD</p>692 <div class="peach-settings-row peach-settings-row--meta">693 <span>Master password</span>694 <button695 class="peach-btn peach-btn--subtle peach-btn--sm"696 onClick={() => { setShowChangePw((v) => !v); setChangePwError(null); }}697 >698 {showChangePw() ? "cancel" : "change"}699 </button>700 </div>701 <Show when={showChangePw()}>702 <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">703 <input704 class="peach-settings-input"705 type="password"706 placeholder="current password"707 value={changePwCurrent()}708 onInput={(e) => setChangePwCurrent((e.target as HTMLInputElement).value)}709 autocomplete="current-password"710 />711 <input712 class="peach-settings-input"713 type="password"714 placeholder="new password (min 12 chars)"715 value={changePwNew()}716 onInput={(e) => setChangePwNew((e.target as HTMLInputElement).value)}717 autocomplete="new-password"718 />719 <input720 class="peach-settings-input"721 type="password"722 placeholder="confirm new password"723 value={changePwConfirm()}724 onInput={(e) => setChangePwConfirm((e.target as HTMLInputElement).value)}725 autocomplete="new-password"726 onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }}727 />728 <Show when={changePwError()}>729 <p class="peach-hint peach-hint-error" style="margin:0">{changePwError()}</p>730 </Show>731 <Show when={changePwSuccess()}>732 <p class="peach-hint" style="margin:0;color:var(--peach-accent)">password changed ✓</p>733 </Show>734 <button735 class="peach-btn peach-btn--accent peach-btn--sm"736 style="align-self:flex-start"737 onClick={() => void handleChangePassword()}738 disabled={changePwSaving() || !changePwCurrent() || !changePwNew() || !changePwConfirm()}739 >740 {changePwSaving() ? "saving…" : "save new password"}741 </button>742 </div>743 </Show>744745 <p class="peach-settings-sub-label">746 DURESS PASSWORD747 <Show when={!isPro()}>748 <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>749 </Show>750 </p>751 <div class="peach-settings-row peach-settings-row--top">752 <div class="peach-settings-row-main">753 <span>754 Duress password755 <Show when={!isPro()}>756 <span class="peach-pro-badge">Pro</span>757 </Show>758 </span>759 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">760 If entered under coercion, shows a decoy empty vault instead of your real one.761 </span>762 </div>763 <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>764 {duressEnabled() ? "on" : "off"}765 </div>766 </div>767768 <Show when={duressEnabled() && !showDuressSetup()}>769 <div class="peach-settings-row peach-settings-row--meta">770 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">771 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.772 </span>773 </div>774 </Show>775776 <Show when={duressSuccess()}>777 <div class="peach-settings-row peach-settings-row--meta">778 <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>779 </div>780 </Show>781782 <Show when={!showDuressSetup()}>783 <div class="peach-settings-row peach-settings-row--meta">784 <button785 class="peach-btn peach-btn--subtle peach-btn--sm"786 onClick={() => {787 788 789 790 791 792 if (!isPro() && !duressEnabled()) {793 props.onProGate?.("duress_vault");794 setActiveCard("pro");795 return;796 }797 setShowDuressSetup(true);798 setDuressError(null);799 setDuressSuccess(null);800 setDuressPassword("");801 setDuressConfirm("");802 setDuressMainPassword("");803 }}804 >805 {duressEnabled() ? "Change / disable" : "Set up"}806 </button>807 </div>808 </Show>809810 <Show when={showDuressSetup()}>811 <div class="peach-duress-form">812 <label class="peach-label">main password (to confirm)</label>813 <input814 class="peach-settings-input"815 type="password"816 placeholder="your current master password"817 autocomplete="current-password"818 value={duressMainPassword()}819 onInput={(e) => setDuressMainPassword(e.currentTarget.value)}820 />821 <Show when={!duressEnabled()}>822 <label class="peach-label">new duress password</label>823 <input824 class="peach-settings-input"825 type="password"826 placeholder="duress password (min 8 chars)"827 autocomplete="new-password"828 value={duressPassword()}829 onInput={(e) => setDuressPassword(e.currentTarget.value)}830 />831 <label class="peach-label">confirm duress password</label>832 <input833 class="peach-settings-input"834 type="password"835 placeholder="confirm duress password"836 autocomplete="new-password"837 value={duressConfirm()}838 onInput={(e) => setDuressConfirm(e.currentTarget.value)}839 />840 </Show>841842 <Show when={duressError()}>843 <p class="peach-hint peach-hint-error">{duressError()}</p>844 </Show>845846 <div class="peach-duress-actions">847 <button848 class="peach-btn peach-btn--subtle peach-btn--sm"849 onClick={() => { setShowDuressSetup(false); setDuressError(null); }}850 >851 cancel852 </button>853 <Show when={duressEnabled()}>854 <button855 class="peach-btn peach-btn--ghost peach-btn--sm"856 style="color:var(--peach-breach)"857 onClick={() => void handleDisableDuress()}858 disabled={duressSaving()}859 >860 {duressSaving() ? "disabling..." : "disable duress"}861 </button>862 </Show>863 <Show when={!duressEnabled()}>864 <button865 class="peach-btn peach-btn--accent peach-btn--sm"866 onClick={() => void handleSetupDuress()}867 disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}868 >869 {duressSaving() ? "saving..." : "save"}870 </button>871 </Show>872 </div>873 </div>874 </Show>875876 <p class="peach-settings-sub-label">877 CANARY ENTRIES878 <Show when={!isPro()}>879 <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>880 </Show>881 </p>882 <div class="peach-settings-row peach-settings-row--top">883 <div class="peach-settings-row-main">884 <span>885 Canary entries886 <Show when={!isPro()}>887 <span class="peach-pro-badge">Pro</span>888 </Show>889 </span>890 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">891 Silently pings a webhook when an entry is accessed. Configure canary on any entry in your vault.892 </span>893 </div>894 <span class="peach-settings-value" style="flex-shrink:0">895 {canaryEntries().length > 0 ? `${canaryEntries().length} active` : "none"}896 </span>897 </div>898 <Show when={canaryEntries().length > 0}>899 <For each={canaryEntries()}>900 {(entry) => (901 <div class="peach-settings-row peach-settings-row--meta">902 <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0">{entry.title}</span>903 <button904 class="peach-btn peach-btn--subtle peach-btn--sm"905 onClick={() => void handleTestCanary(entry)}906 disabled={canaryTestingId() === entry.id}907 style="flex-shrink:0"908 >909 {canaryTestingId() === entry.id910 ? "sending…"911 : canaryTestResult()?.id === entry.id912 ? (canaryTestResult()!.ok ? "sent ✓" : "failed ✗")913 : "test"}914 </button>915 </div>916 )}917 </For>918 </Show>919920 <p class="peach-settings-sub-label">PASSKEYS</p>921 <div class="peach-settings-row peach-settings-row--top">922 <div class="peach-settings-row-main">923 <span>Intercept passkey requests</span>924 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">925 Peach can handle passkey sign-in and creation on supported Chromium browsers.926 </span>927 </div>928 <button929 class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}930 onClick={() => void handleTogglePasskeys()}931 aria-label="Toggle passkey interception"932 disabled={!passkeyState()?.supported || passkeySaving()}933 >934 <span class="peach-toggle-knob"></span>935 </button>936 </div>937 <div class="peach-settings-row peach-settings-row--meta">938 <span>Status</span>939 <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>940 </div>941 <div class="peach-settings-row peach-settings-row--meta">942 <span>Browser</span>943 <span class="peach-settings-value">944 {passkeyState()?.browserLabel ?? "Unknown"}945 {passkeyState()?.supported ? " ✓" : " (interception unavailable)"}946 </span>947 </div>948 <div class="peach-info-notice" style="margin-top:8px">949 Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.950 </div>951 <Show when={passkeyState()?.conflict}>952 <div class="peach-settings-row peach-settings-row--meta">953 <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>954 </div>955 </Show>956 </div>957 </div>958 );959 }960961 function renderVault() {962 return (963 <div class="peach-settings-body">964 <div class="peach-settings-group">965 <p class="peach-settings-sub-label">ENTRIES</p>966 <div class="peach-settings-row peach-settings-row--meta">967 <span>Entries</span>968 <span class="peach-settings-value">{props.entriesCount}</span>969 </div>970 <div class="peach-settings-row peach-settings-row--meta">971 <span>Duplicates</span>972 <Show when={dedupDone() !== null} fallback={973 <button974 class="peach-btn peach-btn--subtle peach-btn--sm"975 onClick={() => void handleCountDuplicates()}976 disabled={dedupRunning()}977 >978 {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}979 </button>980 }>981 <span class="peach-settings-value" style="color:var(--peach-sync-ok)">982 {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}983 </span>984 </Show>985 </div>986 <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>987 <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">988 <div class="peach-restore-warning">989 {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.990 </div>991 <div style="display:flex;gap:6px">992 <button993 class="peach-btn peach-btn--subtle peach-btn--sm"994 onClick={() => setDedupCount(null)}995 >996 cancel997 </button>998 <button999 class="peach-btn peach-btn--accent peach-btn--sm"1000 onClick={() => void handleDeduplicate()}1001 disabled={dedupRunning()}1002 >1003 {dedupRunning() ? "removing..." : "remove duplicates"}1004 </button>1005 </div>1006 </div>1007 </Show>10081009 <p class="peach-settings-sub-label">MAINTENANCE</p>1010 <div class="peach-settings-row peach-settings-row--meta">1011 <span>Trash auto-delete</span>1012 <select1013 class="peach-settings-select"1014 value={String(props.trashRetentionDays)}1015 onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}1016 >1017 <option value="7">7 days</option>1018 <option value="30">30 days</option>1019 <option value="90">90 days</option>1020 <option value="0">Never</option>1021 </select>1022 </div>1023 <div class="peach-settings-row peach-settings-row--meta">1024 <span>Vault Health</span>1025 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>1026 open1027 </button>1028 </div>1029 <Show when={declinedCardCount() > 0}>1030 <div class="peach-settings-row peach-settings-row--meta">1031 <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>1032 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>1033 Clear1034 </button>1035 </div>1036 </Show>10371038 <p class="peach-settings-sub-label">DATA</p>1039 <div class="peach-settings-row peach-settings-row--meta">1040 <span>Import passwords</span>1041 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>1042 open1043 </button>1044 </div>1045 <div class="peach-settings-row peach-settings-row--meta">1046 <span>Export backup</span>1047 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onExport}>1048 open1049 </button>1050 </div>1051 <div class="peach-settings-row peach-settings-row--meta">1052 <span>1053 Peach Codex1054 <Show when={!isPro()}>1055 <span class="peach-pro-badge">Pro</span>1056 </Show>1057 </span>1058 <button1059 class="peach-btn peach-btn--subtle peach-btn--sm"1060 onClick={() => props.onOpenCodexGenerate?.()}1061 >1062 open1063 </button>1064 </div>1065 {1066}1067 <div class="peach-settings-row peach-settings-row--meta">1068 <span>Restore from Codex</span>1069 <button1070 class="peach-btn peach-btn--subtle peach-btn--sm"1071 onClick={() => props.onOpenCodexRestore?.()}1072 >1073 open1074 </button>1075 </div>10761077 <span class="peach-settings-sub-label">VAULT HISTORY</span>1078 <Show when={historyLoading()}>1079 <p class="peach-settings-hint">Loading…</p>1080 </Show>1081 <Show when={!historyLoading() && historySnapshots().length === 0}>1082 <p class="peach-settings-hint">1083 No snapshots yet. Peach saves automatically before every change —1084 saves, syncs, and imports.1085 </p>1086 </Show>1087 <Show when={!historyLoading() && historySnapshots().length > 0}>1088 <p class="peach-settings-hint">1089 Restoring locks your vault. Unlock with the password that1090 encrypted that snapshot to confirm.1091 </p>1092 <div class="peach-history-list">1093 <For each={historySnapshots().slice(0, historyShowAll() ? undefined : 3)}>1094 {(snap) => (1095 <div class="peach-history-row">1096 <div class="peach-history-meta">1097 <span class="peach-history-time">1098 {formatSnapshotTime(snap.savedAt)}1099 </span>1100 <span class="peach-history-sub">1101 {[1102 snap.entryCount !== null1103 ? `${snap.entryCount} ${snap.entryCount === 1 ? "entry" : "entries"}`1104 : null,1105 reasonLabel(snap.reason),1106 ]1107 .filter(Boolean)1108 .join(" · ")}1109 </span>1110 </div>1111 <button1112 class={`peach-btn peach-btn--subtle peach-btn--sm peach-history-btn${1113 historyRestoring() === snap.id ? " is-busy" : ""1114 }`}1115 onClick={() => void handleRestoreVaultSnapshot(snap.id)}1116 disabled={historyRestoring() !== null}1117 >1118 <Show when={historyRestoring() === snap.id} fallback="Restore">1119 …1120 </Show>1121 </button>1122 </div>1123 )}1124 </For>1125 </div>1126 <Show when={historySnapshots().length > 3}>1127 <button1128 class="peach-settings-advanced-toggle"1129 onClick={() => setHistoryShowAll((v) => !v)}1130 aria-expanded={historyShowAll()}1131 >1132 <span class="peach-settings-hint" style="margin:0">1133 {historyShowAll() ? "show less" : `${historySnapshots().length - 3} older`}1134 </span>1135 <span class="peach-settings-advanced-chevron">1136 {historyShowAll() ? "▲" : "▼"}1137 </span>1138 </button>1139 </Show>1140 <p class="peach-history-footer">1141 {historySnapshots().length} of 10 snapshots1142 </p>1143 </Show>11441145 <button1146 class="peach-settings-advanced-toggle"1147 onClick={() => setShowVaultAdvanced((v) => !v)}1148 aria-expanded={showVaultAdvanced()}1149 >1150 <span class="peach-settings-sub-label" style="margin:0">ADVANCED</span>1151 <span class="peach-settings-advanced-chevron">1152 {showVaultAdvanced() ? "▲" : "▼"}1153 </span>1154 </button>11551156 <Show when={showVaultAdvanced()}>1157 <div class="peach-danger-card">1158 <div class="peach-danger-card-head">1159 <span class="peach-danger-card-glyph" aria-hidden="true">⟳</span>1160 <h3 class="peach-danger-card-title">Replace vault from backup</h3>1161 </div>1162 <p class="peach-danger-card-copy">1163 Wipes the current vault and restores from a backup file.1164 Recoverable only if your backup is good.1165 </p>1166 <button1167 class="peach-danger-card-button"1168 onClick={() => props.onOpenFileImport?.()}1169 >1170 Choose backup file1171 </button>1172 </div>11731174 <Show when={props.onOpenPanic}>1175 <div class="peach-danger-card peach-danger-card--hazard">1176 <div class="peach-danger-card-head">1177 <span class="peach-danger-card-glyph" aria-hidden="true">⚠</span>1178 <h3 class="peach-danger-card-title">Destroy vault</h3>1179 </div>1180 <p class="peach-danger-card-copy">1181 Permanently wipes every entry, setting, and license on this1182 device. There is no recovery.1183 </p>1184 <button1185 class="peach-danger-card-button"1186 onClick={() => props.onOpenPanic?.()}1187 >1188 Begin destruction1189 </button>1190 </div>1191 </Show>1192 </Show>1193 </div>1194 </div>1195 );1196 }11971198 function renderSync() {1199 return (1200 <div class="peach-settings-body">1201 <div class="peach-settings-group">1202 <p class="peach-settings-sub-label">PAIRING</p>1203 <div class="peach-settings-row peach-settings-row--top">1204 <div class="peach-settings-row-main">1205 <span>1206 Peach Sync1207 <Show when={!isPro()}>1208 <span class="peach-pro-badge">Pro</span>1209 </Show>1210 </span>1211 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1212 Pair nearby devices by chirp and keep encrypted vault blobs in step without a central account.1213 </span>1214 </div>1215 <span class={`stk-pip ${isPro() ? "is-on" : ""}`} aria-hidden="true" />1216 </div>1217 <div class="peach-settings-row peach-settings-row--meta">1218 <span>Status</span>1219 <span class="peach-settings-value">{isPro() ? "Ready" : "Requires Pro"}</span>1220 </div>1221 <div class="peach-settings-row peach-settings-row--meta">1222 <span>Sync hub</span>1223 <button1224 class="peach-btn peach-btn--subtle peach-btn--sm"1225 onClick={() => props.onOpenSyncPairing?.()}1226 >1227 open1228 </button>1229 </div>12301231 <p class="peach-settings-sub-label">DEVICE FLOW</p>1232 <div class="peach-settings-row peach-settings-row--top">1233 <div class="peach-settings-row-main">1234 <span>Pair new device</span>1235 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1236 Opens the full chirp pairing console with scan, show-code, and advanced diagnostics.1237 </span>1238 </div>1239 <button1240 class="peach-btn peach-btn--subtle peach-btn--sm"1241 onClick={() => props.onOpenSyncPairing?.()}1242 >1243 pair1244 </button>1245 </div>1246 <div class="peach-info-notice" style="margin-top:8px">1247 Sync uses the same zero-knowledge vault format as backup and restore; pairing authorizes a device, not a cloud account.1248 </div>1249 </div>1250 </div>1251 );1252 }12531254 function renderAppearance() {1255 const sizeOptions: Array<{ id: "medium" | "large"; label: string }> = [1256 { id: "medium", label: "Default" },1257 { id: "large", label: "Large" },1258 ];1259 return (1260 <div class="peach-settings-body">1261 <div class="peach-settings-group">1262 <p class="peach-settings-sub-label">THEME</p>1263 <div class="peach-settings-row peach-settings-row--meta">1264 <span>Theme</span>1265 <button1266 class="peach-btn peach-btn--subtle peach-btn--sm"1267 onClick={() =>1268 props.onThemeChange(props.theme === "dark" ? "light" : "dark")1269 }1270 >1271 {props.theme === "dark" ? "Switch to light" : "Switch to dark"}1272 </button>1273 </div>12741275 <p class="peach-settings-sub-label">ACCENT</p>1276 <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">1277 <span>Accent</span>1278 <div class="peach-accent-picker">1279 <For each={ACCENT_COLORS}>1280 {(color) => (1281 <button1282 class="peach-accent-swatch peach-swatch"1283 classList={{ "is-active": props.accentColor === color.name }}1284 style={{ background: color.hex }}1285 onClick={() => void props.onAccentChange(color.name)}1286 aria-label={color.name}1287 title={color.name}1288 />1289 )}1290 </For>1291 </div>1292 </div>12931294 <p class="peach-settings-sub-label">TEXT SIZE</p>1295 <div class="peach-settings-row peach-settings-row--meta">1296 <span>Size</span>1297 <div class="peach-pill-group">1298 <For each={sizeOptions}>1299 {(opt) => (1300 <button1301 class="peach-btn peach-btn--ghost peach-btn--sm"1302 classList={{ "is-active": props.textSize === opt.id }}1303 onClick={() => void props.onTextSizeChange(opt.id)}1304 >1305 {opt.label}1306 </button>1307 )}1308 </For>1309 </div>1310 </div>13111312 <p class="peach-settings-sub-label">MOTION</p>1313 <div class="peach-settings-row peach-settings-row--meta">1314 <span>Reduce motion</span>1315 <button1316 class="peach-btn peach-btn--subtle peach-btn--sm"1317 onClick={() => void props.onReduceMotionChange(!props.reduceMotion)}1318 >1319 {props.reduceMotion ? "On" : "Off"}1320 </button>1321 </div>1322 </div>1323 </div>1324 );1325 }13261327 function renderShortcuts() {1328 const [capturingAction, setCapturingAction] = createSignal<ShortcutAction | null>(null);1329 const [conflictMsg, setConflictMsg] = createSignal<string | null>(null);13301331 function startCapture(action: ShortcutAction) {1332 setConflictMsg(null);1333 setCapturingAction(action);1334 }1335 function cancelCapture() {1336 setCapturingAction(null);1337 setConflictMsg(null);1338 }1339 function commitBinding(action: ShortcutAction, binding: Keybinding) {1340 const conflict = findConflict(props.shortcuts, binding, action);1341 if (conflict) {1342 const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict);1343 setConflictMsg(`Already bound to "${other?.label ?? conflict}"`);1344 return;1345 }1346 const next = { ...props.shortcuts, [action]: binding };1347 setCapturingAction(null);1348 setConflictMsg(null);1349 void props.onShortcutsChange(next);1350 }1351 function resetAction(action: ShortcutAction) {1352 const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] };1353 void props.onShortcutsChange(next);1354 }1355 function resetAll() {1356 void props.onShortcutsChange({ ...DEFAULT_BINDINGS });1357 }13581359 1360 1361 function handleCaptureKey(e: KeyboardEvent) {1362 e.preventDefault();1363 e.stopPropagation();1364 if (e.key === "Escape") { cancelCapture(); return; }1365 const binding = keybindingFromEvent(e);1366 if (!binding) return; 1367 const action = capturingAction();1368 if (action) commitBinding(action, binding);1369 }13701371 return (1372 <div class="peach-settings-body">1373 <div class="peach-settings-group peach-shortcuts-group">1374 <p class="peach-settings-sub-label">ENTRY VIEW</p>1375 <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry")}>1376 {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1377 </For>1378 </div>13791380 <div class="peach-settings-group peach-shortcuts-group">1381 <p class="peach-settings-sub-label">ANYWHERE IN POPUP</p>1382 <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global")}>1383 {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1384 </For>1385 </div>13861387 <Show when={conflictMsg()}>1388 <div class="peach-shortcuts-conflict">{conflictMsg()}</div>1389 </Show>13901391 <div class="peach-settings-group" style="display:flex;flex-direction:column;gap:8px">1392 <div class="peach-settings-row peach-settings-row--meta">1393 <span>Inline autofill on page</span>1394 <a1395 class="peach-settings-link"1396 href="chrome://extensions/shortcuts"1397 onClick={(e) => {1398 e.preventDefault();1399 chrome.tabs.create({ url: "chrome://extensions/shortcuts" });1400 }}1401 >1402 ⌘⇧P — customize ↗1403 </a>1404 </div>1405 <button1406 class="peach-btn peach-btn--subtle peach-btn--sm"1407 style="align-self:flex-start"1408 onClick={resetAll}1409 >1410 Reset all to defaults1411 </button>1412 </div>1413 </div>1414 );14151416 function renderRow(1417 desc: typeof SHORTCUT_DESCRIPTORS[number],1418 capturing: () => ShortcutAction | null,1419 onStart: (a: ShortcutAction) => void,1420 onCancel: () => void,1421 onReset: (a: ShortcutAction) => void,1422 onKey: (e: KeyboardEvent) => void,1423 ) {1424 const isActive = () => capturing() === desc.action;1425 const binding = () => props.shortcuts[desc.action];1426 const isCustom = () => {1427 const b = binding();1428 const d = DEFAULT_BINDINGS[desc.action];1429 return b.key !== d.key1430 || Boolean(b.ctrl) !== Boolean(d.ctrl)1431 || Boolean(b.meta) !== Boolean(d.meta)1432 || Boolean(b.shift) !== Boolean(d.shift)1433 || Boolean(b.alt) !== Boolean(d.alt);1434 };1435 return (1436 <div class="peach-shortcut-row">1437 <span class="peach-shortcut-row-label">{desc.label}</span>1438 <button1439 class="peach-shortcut-chip"1440 classList={{ "is-capturing": isActive(), "is-custom": isCustom() }}1441 onClick={() => isActive() ? onCancel() : onStart(desc.action)}1442 onKeyDown={(e) => { if (isActive()) onKey(e); }}1443 ref={(el) => {1444 1445 if (el) {1446 queueMicrotask(() => { if (isActive()) el.focus(); });1447 }1448 }}1449 >1450 {isActive() ? "Press a key…" : formatKeybinding(binding())}1451 </button>1452 <Show when={isCustom() && !isActive()}>1453 <button1454 class="peach-shortcut-reset"1455 title="Reset to default"1456 onClick={() => onReset(desc.action)}1457 >1458 ↺1459 </button>1460 </Show>1461 </div>1462 );1463 }1464 }14651466 const PRO_FEATURES: Array<{1467 feature: ProFeature;1468 name: string;1469 desc: string;1470 }> = [1471 { feature: "sync", name: "Peach Sync", desc: "Encrypted · peer-to-peer" },1472 { feature: "codex", name: "Peach Codex", desc: "Restore from paper" },1473 { feature: "passkeys", name: "Passkeys", desc: "Zero-knowledge WebAuthn" },1474 { feature: "ios_app", name: "Mobile apps", desc: "Android · iOS soon" },1475 { feature: "advanced_health", name: "Vault health", desc: "Reuse · age · exposure" },1476 ];14771478 const PRO_SECURITY_FEATURES: Array<{1479 feature: ProFeature;1480 name: string;1481 desc: string;1482 }> = [1483 { feature: "canary_entry", name: "Canary entries", desc: "Detects vault compromise" },1484 { feature: "duress_vault", name: "Duress vault", desc: "Decoy under coercion" },1485 ];14861487 const [licenseKey, setLicenseKey] = createSignal<string | null>(null);1488 const [showKey, setShowKey] = createSignal(false);14891490 const [deactivating, setDeactivating] = createSignal(false);14911492 async function handleDeactivateLicense() {1493 setDeactivating(true);1494 await sendMessage({ type: "DEACTIVATE_LICENSE" });1495 setLicenseInfo(null);1496 await props.onLicenseChange?.();1497 setDeactivating(false);1498 }14991500 async function handleTestCanary(entry: {id: string; title: string; canary: Canary}) {1501 setCanaryTestingId(entry.id);1502 setCanaryTestResult(null);1503 const res = await sendMessage({1504 type: "FIRE_CANARY_PING",1505 payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger: "test" },1506 }) as { ok: boolean };1507 setCanaryTestResult({ id: entry.id, ok: res.ok });1508 setCanaryTestingId(null);1509 setTimeout(() => setCanaryTestResult((r) => r?.id === entry.id ? null : r), 3000);1510 }15111512 function renderLicense() {1513 async function onActivate() {1514 await handleActivateLicense();1515 if ((licenseInfo()?.valid ?? false) && licenseInfo()?.plan === "pro") {1516 await props.onLicenseChange?.();1517 }1518 }15191520 return (1521 <div class="peach-settings-body">1522 <Show1523 when={isPro()}1524 fallback={1525 <div class="peach-pro-upgrade">1526 <p class="peach-pro-eyebrow">PEACH PRO · ONE-TIME</p>15271528 <h2 class="peach-pro-headline">1529 Every<em>thing,</em><br />every<em>where.</em>1530 </h2>15311532 <p class="peach-pro-subtitle">1533 One key unlocks every Pro feature — for the lifetime of the app.1534 </p>15351536 <div class="peach-pro-grid">1537 <For each={PRO_FEATURES}>1538 {(item, i) => (1539 <div1540 class="peach-pro-grid-item"1541 classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}1542 >1543 <span class="peach-pro-grid-num">{String(i() + 1).padStart(2, "0")}</span>1544 <span class="peach-pro-grid-name">{item.name}</span>1545 <span class="peach-pro-grid-desc">{item.desc}</span>1546 </div>1547 )}1548 </For>1549 </div>15501551 <p class="peach-pro-section-label">ADVANCED SECURITY</p>1552 <div class="peach-pro-grid">1553 <For each={PRO_SECURITY_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(PRO_FEATURES.length + 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 <div class="peach-pro-upgrade-actions">1568 <p class="peach-pro-pricing">1569 $39 <span class="peach-pro-pricing-sub">once · yours forever</span>1570 </p>1571 <a1572 href="https://peachpasswords.com/#pricing"1573 target="_blank"1574 rel="noopener noreferrer"1575 class="peach-pro-upgrade-cta"1576 >1577 Get Peach Pro1578 </a>1579 <div class="peach-pro-key-divider"><span>ALREADY HAVE A KEY</span></div>1580 <div class="peach-pro-key-inline">1581 <input1582 class="peach-pro-key-inline-input"1583 type="text"1584 placeholder="PEACH-XXXX-XXXX-XXXX"1585 value={licenseKeyInput()}1586 onInput={(e) => setLicenseKeyInput(e.currentTarget.value)}1587 onKeyDown={(e) => { if (e.key === "Enter") void onActivate(); }}1588 autocomplete="off"1589 spellcheck={false}1590 />1591 <button1592 class="peach-pro-key-apply"1593 onClick={() => void onActivate()}1594 disabled={activatingLicense() || !licenseKeyInput().trim()}1595 >1596 {activatingLicense() ? "…" : "APPLY"}1597 </button>1598 </div>1599 <Show when={licenseError()}>1600 <p class="peach-pro-key-error">{licenseError()}</p>1601 </Show>1602 </div>1603 </div>1604 }1605 >1606 <div class="peach-pro-active">1607 <div class="peach-pro-active-hero">1608 <div class="peach-pro-active-mark-wrap">1609 <PeachMark class="peach-pro-active-mark" />1610 </div>1611 <h2 class="peach-pro-active-title">Peach Pro</h2>1612 <p class="peach-pro-active-since">{formatActivatedSince(licenseInfo()?.issued)}</p>1613 </div>16141615 <Show when={licenseKey()}>1616 <div class="peach-settings-license">1617 <span class="peach-settings-label">LICENSE KEY</span>1618 <div class="peach-settings-license-row">1619 <Show1620 when={showKey()}1621 fallback={1622 <span class="peach-settings-license-key peach-settings-license-key--masked" aria-label="License key hidden">1623 ••••••••••••••••••••••••••••1624 </span>1625 }1626 >1627 <span class="peach-settings-license-key">{licenseKey()!}</span>1628 </Show>1629 <div class="peach-settings-license-actions">1630 <button1631 class="peach-settings-license-copy"1632 title="Copy license key"1633 onClick={() => copyLicenseKey(licenseKey()!)}1634 >1635 {copied() ? "✓" : "copy"}1636 </button>1637 <button1638 class="peach-settings-license-toggle"1639 aria-label={showKey() ? "Hide license key" : "Reveal license key"}1640 title={showKey() ? "Hide" : "Reveal"}1641 onClick={() => setShowKey(v => !v)}1642 >1643 <Show1644 when={showKey()}1645 fallback={1646 <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">1647 <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />1648 <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />1649 </svg>1650 }1651 >1652 <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">1653 <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />1654 <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />1655 <line x1="2" y1="2" x2="12" y2="12" />1656 </svg>1657 </Show>1658 </button>1659 </div>1660 </div>1661 </div>1662 </Show>16631664 <div class="peach-pro-active-features">1665 <For each={[...PRO_FEATURES, ...PRO_SECURITY_FEATURES]}>1666 {(item) => (1667 <div class="peach-pro-active-feature">1668 <span class="peach-pro-active-feature-mark">✦</span>1669 <span class="peach-pro-active-feature-name">{item.name}</span>1670 </div>1671 )}1672 </For>1673 </div>16741675 <button1676 class="peach-pro-deactivate"1677 onClick={() => void handleDeactivateLicense()}1678 disabled={deactivating()}1679 >1680 {deactivating() ? "Deactivating…" : "Deactivate"}1681 </button>1682 </div>1683 </Show>1684 </div>1685 );1686 }16871688 function renderFeedback() {1689 return (1690 <div class="peach-settings-body">1691 <div class="peach-settings-group peach-feedback-group">1692 <p class="peach-feedback-intro">1693 Help us improve Peach. Every report goes directly to our inbox.1694 </p>16951696 <p class="peach-settings-sub-label">TYPE</p>1697 <div class="peach-feedback-chips">1698 <For each={FEEDBACK_CATEGORIES}>1699 {(cat) => (1700 <button1701 class={`peach-feedback-chip ${fbCategory() === cat ? "is-active" : ""}`}1702 onClick={() => setFbCategory(cat)}1703 >1704 {cat}1705 </button>1706 )}1707 </For>1708 </div>17091710 <p class="peach-settings-sub-label">MESSAGE</p>1711 <textarea1712 class="peach-feedback-textarea"1713 placeholder="Describe the issue or idea..."1714 value={fbMessage()}1715 onInput={(e) => setFbMessage(e.currentTarget.value)}1716 />17171718 <p class="peach-feedback-meta">1719 Version {version} · will be included automatically1720 </p>17211722 <button1723 class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"1724 disabled={!fbMessage().trim() || feedbackState() === 'sending'}1725 onClick={() => void handleSubmitFeedback()}1726 >1727 <Switch>1728 <Match when={feedbackState() === 'idle'}>Send</Match>1729 <Match when={feedbackState() === 'sending'}>Sending…</Match>1730 <Match when={feedbackState() === 'sent'}>Sent ✓</Match>1731 <Match when={feedbackState() === 'error'}>Failed — try again</Match>1732 </Switch>1733 </button>17341735 <p class="peach-feedback-anon">1736 Sent anonymously · no account required1737 </p>1738 </div>1739 </div>1740 );1741 }17421743 const bodies: StackBodies = {1744 protection: renderSecurity(),1745 vault: renderVault(),1746 sync: renderSync(),1747 appearance: renderAppearance(),1748 shortcuts: renderShortcuts(),1749 pro: renderLicense(),1750 feedback: renderFeedback(),1751 };17521753 return (1754 <Show1755 when={showNewRecoveryWords()}1756 fallback={1757 <div1758 class="peach-overlay-view peach-settings-overlay"1759 classList={{ "ov-enter-right": !props.isExiting, "ov-exit-right": !!props.isExiting }}1760 style="display:flex;flex-direction:column"1761 >1762 <StackDeck1763 active={activeCard()}1764 onActiveChange={setActiveCard}1765 onBack={props.onBack}1766 cards={cards()}1767 bodies={bodies}1768 version={version}1769 />1770 </div>1771 }1772 >1773 <RecoveryWordDisplayView onDone={() => setShowNewRecoveryWords(false)} />1774 </Show>1775 );1776}