1import { createEffect, createMemo, createSignal, For, onMount, Show } 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";2526interface LicenseInfo {27 valid: boolean;28 plan: "free" | "pro";29 issued?: string;30}3132interface SettingsProps {33 entriesCount: number;34 breachMonitoring: boolean;35 lockOnClose: boolean;36 autoLockMinutes: number;37 trashRetentionDays: number;38 theme: "dark" | "light";39 accentColor: AccentColorName;40 textSize: "small" | "medium" | "large";41 reduceMotion: boolean;42 isPro?: boolean;43 initialSection?: string | null;44 upgradeHighlight?: ProFeature | null;45 onBreachMonitoringChange: (enabled: boolean) => void | Promise<void>;46 onLockOnCloseChange: (enabled: boolean) => void | Promise<void>;47 onAutoLockMinutesChange: (minutes: number) => void | Promise<void>;48 onTrashRetentionChange: (days: number) => void | Promise<void>;49 onThemeChange: (theme: "dark" | "light") => void;50 onAccentChange: (name: AccentColorName) => void | Promise<void>;51 onTextSizeChange: (size: "small" | "medium" | "large") => void | Promise<void>;52 onReduceMotionChange: (enabled: boolean) => void | Promise<void>;53 shortcuts: Record<ShortcutAction, Keybinding>;54 onShortcutsChange: (active: Record<ShortcutAction, Keybinding>) => void | Promise<void>;55 onLicenseChange?: () => void | Promise<void>;56 isExiting?: boolean;57 onBack: () => void;58 onImport: () => void;59 onExport: () => void;60 onBreachCheckDone: () => void;61 onOpenHealth?: () => void;62 onOpenSyncPairing?: () => void;63 onOpenFileImport?: () => void;64 onOpenPanic?: () => void;65 onOpenCodexGenerate?: () => void;66 onOpenCodexRestore?: () => void;67 686970 onProGate?: (feature: ProFeature) => void;71 onCardChange?: (id: CardId) => void;72}7374const FEEDBACK_CATEGORIES = ["Bug report", "Feature request", "Question", "Other"];7576function formatActivatedSince(issued: string | undefined): string {77 if (!issued) return "Active";78 try {79 const d = new Date(issued);80 if (Number.isNaN(d.getTime())) return "Active";81 return `Active since ${d.toLocaleDateString(undefined, { month: "long", year: "numeric" })}`;82 } catch {83 return "Active";84 }85}8687function initialSectionToCard(value: string | null | undefined): CardId | null {88 if (!value) return null;89 if (value === "security") return "protection";90 if (value === "license") return "pro";91 if (92 value === "protection" ||93 value === "vault" ||94 value === "sync" ||95 value === "appearance" ||96 value === "shortcuts" ||97 value === "pro" ||98 value === "feedback"99 ) {100 return value;101 }102 return null;103}104105export function SettingsView(props: SettingsProps) {106 const manifest = chrome.runtime.getManifest();107 const version = manifest.version;108 const initial = props.initialSection;109 const [activeCard, setActiveCard] = createSignal<CardId>(110 props.upgradeHighlight ? "pro" : initialSectionToCard(initial) ?? "protection"111 );112113 createEffect(() => { props.onCardChange?.(activeCard()); });114115 const [checkingBreaches, setCheckingBreaches] = createSignal(false);116 const [breachResult, setBreachResult] = createSignal<string | null>(null);117 const [licenseInfo, setLicenseInfo] = createSignal<LicenseInfo | null>(null);118 const [licenseKeyInput, setLicenseKeyInput] = createSignal("");119 const [licenseError, setLicenseError] = createSignal<string | null>(null);120 const [, setLicenseSuccess] = createSignal<string | null>(null);121 const [activatingLicense, setActivatingLicense] = createSignal(false);122123 const isPro = () => props.isPro ?? false;124 const cards = createMemo<StackCardSpec[]>(() => [125 {126 id: "protection",127 num: "I · PROTECT",128 title: "Protection",129 lede: `${props.breachMonitoring ? "breaches watched" : "breach watch off"} · ${props.lockOnClose ? "locks on close" : "manual lock"}`,130 bgVar: "--stack-protect-bg",131 fgVar: "--stack-protect-fg",132 icon: "shield",133 },134 {135 id: "vault",136 num: "II · KEEP",137 title: "Vault",138 lede: `${props.entriesCount} ${props.entriesCount === 1 ? "entry" : "entries"} · backups, history, cleanup`,139 bgVar: "--stack-vault-bg",140 fgVar: "--stack-vault-fg",141 icon: "lock",142 },143 {144 id: "sync",145 num: "III · ECHO",146 title: "Sync",147 lede: isPro() ? "pair by chirp, keep in step" : "Pro · encrypted device pairing",148 bgVar: "--stack-sync-bg",149 fgVar: "--stack-sync-fg",150 icon: "sync",151 },152 {153 id: "appearance",154 num: "IV · DRESS",155 title: "Appearance",156 lede: `${props.theme} · ${props.accentColor} · ${props.textSize}`,157 bgVar: "--stack-appear-bg",158 fgVar: "--stack-appear-fg",159 icon: "sun",160 },161 {162 id: "shortcuts",163 num: "V · KEYS",164 title: "Shortcuts",165 lede: `${SHORTCUT_DESCRIPTORS.length} bindings · what your fingers know`,166 bgVar: "--stack-shortcut-bg",167 fgVar: "--stack-shortcut-fg",168 icon: "keys",169 },170 {171 id: "pro",172 num: "VI · FOREVER",173 title: "Peach Pro",174 lede: isPro() ? "active · paid once, yours forever" : "$39 once · yours forever",175 bgVar: "--stack-pro-bg",176 fgVar: "--stack-pro-fg",177 icon: "spark",178 },179 {180 id: "feedback",181 num: "VII · ECHO",182 title: "Feedback",183 lede: `version ${version} · tell us what would make it better`,184 bgVar: "--stack-feedback-bg",185 fgVar: "--stack-feedback-fg",186 icon: "bubble",187 },188 ]);189 const [biometricSupported, setBiometricSupported] = createSignal(false);190 const [biometricStored, setBiometricStored] = createSignal(false);191 const [biometricBusy, setBiometricBusy] = createSignal(false);192 const [biometricError, setBiometricError] = createSignal<string | null>(null);193 const [passkeyState, setPasskeyState] = createSignal<PasskeyProviderState | null>(null);194 const [passkeySaving, setPasskeySaving] = createSignal(false);195196 const [dedupCount, setDedupCount] = createSignal<number | null>(null);197 const [dedupRunning, setDedupRunning] = createSignal(false);198 const [dedupDone, setDedupDone] = createSignal<number | null>(null);199200 const [declinedCardCount, setDeclinedCardCount] = createSignal(0);201202 203 204 205 const [historySnapshots, setHistorySnapshots] = createSignal<VaultSnapshot[]>([]);206 const [historyLoading, setHistoryLoading] = createSignal(false);207 const [historyRestoring, setHistoryRestoring] = createSignal<string | null>(null);208 const [historyShowAll, setHistoryShowAll] = createSignal(false);209210 async function loadVaultHistory() {211 setHistoryLoading(true);212 try {213 const res = await sendMessage({ type: "GET_VAULT_HISTORY" });214 const list = (res as { ok: boolean; data?: VaultSnapshot[] }).data ?? [];215 setHistorySnapshots(list);216 } finally {217 setHistoryLoading(false);218 }219 }220221 async function handleRestoreVaultSnapshot(id: string) {222 if (historyRestoring()) return;223 setHistoryRestoring(id);224 const res = await sendMessage({225 type: "RESTORE_VAULT_SNAPSHOT",226 payload: { snapshotId: id },227 });228 setHistoryRestoring(null);229 if ((res as { ok: boolean }).ok) {230 231 232 props.onBack();233 }234 }235236 function formatSnapshotTime(savedAt: number): string {237 const diff = Date.now() - savedAt;238 if (diff < 60_000) return "Just now";239 if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;240 const d = new Date(savedAt);241 const isToday = new Date().toDateString() === d.toDateString();242 const isYest =243 new Date(Date.now() - 86_400_000).toDateString() === d.toDateString();244 const t = d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });245 if (isToday) return `Today ${t}`;246 if (isYest) return `Yesterday ${t}`;247 return `${d.toLocaleDateString([], { month: "short", day: "numeric" })} ${t}`;248 }249250 function reasonLabel(reason: VaultSnapshot["reason"]): string {251 return (252 {253 local_save: "saved",254 sync_received: "synced",255 import: "imported",256 restore: "restored",257 } as const258 )[reason] ?? reason;259 }260261 const [showVaultAdvanced, setShowVaultAdvanced] = createSignal(false);262263 const [showChangePw, setShowChangePw] = createSignal(false);264 const [changePwCurrent, setChangePwCurrent] = createSignal("");265 const [changePwNew, setChangePwNew] = createSignal("");266 const [changePwConfirm, setChangePwConfirm] = createSignal("");267 const [changePwSaving, setChangePwSaving] = createSignal(false);268 const [changePwError, setChangePwError] = createSignal<string | null>(null);269 const [changePwSuccess] = createSignal(false);270 const [showNewRecoveryWords, setShowNewRecoveryWords] = createSignal(false);271272 const [duressEnabled, setDuressEnabled] = createSignal(false);273 const [showDuressSetup, setShowDuressSetup] = createSignal(false);274 const [duressPassword, setDuressPassword] = createSignal("");275 const [duressConfirm, setDuressConfirm] = createSignal("");276 const [duressMainPassword, setDuressMainPassword] = createSignal("");277 const [duressSaving, setDuressSaving] = createSignal(false);278 const [duressError, setDuressError] = createSignal<string | null>(null);279 const [duressSuccess, setDuressSuccess] = createSignal<string | null>(null);280281 const [canaryEntries, setCanaryEntries] = createSignal<Array<{id: string; title: string; canary: Canary}>>([]);282283 const [canaryTestingId, setCanaryTestingId] = createSignal<string | null>(null);284 const [canaryTestResult, setCanaryTestResult] = createSignal<{id: string; ok: boolean} | null>(null);285286 const [fbCategory, setFbCategory] = createSignal("Bug report");287 const [fbMessage, setFbMessage] = createSignal("");288 const [fbSubmitting, setFbSubmitting] = createSignal(false);289 const [fbResult, setFbResult] = createSignal<"success" | "error" | null>(null);290291 createEffect(() => {292 if (props.upgradeHighlight) {293 setActiveCard("pro");294 }295 });296297 onMount(async () => {298 const [licenseRes, passkeyRes, duressRes, settingsRes, keyRes] = await Promise.all([299 sendMessage({ type: "GET_LICENSE" }),300 sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }),301 sendMessage({ type: "IS_DURESS_ENABLED" }),302 sendMessage({ type: "GET_SETTINGS" }),303 sendMessage({ type: "GET_LICENSE_KEY" }),304 ]);305306 307 308 void loadVaultHistory();309310 if ((licenseRes as { ok: boolean }).ok) {311 setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null);312 }313314 if ((passkeyRes as { ok: boolean }).ok) {315 setPasskeyState(316 (passkeyRes as { ok: boolean; data?: PasskeyProviderState }).data ?? null317 );318 }319320 if ((duressRes as { ok: boolean }).ok) {321 setDuressEnabled((duressRes as { ok: boolean; data?: boolean }).data ?? false);322 }323324 if ((settingsRes as { ok: boolean }).ok) {325 const sData = (settingsRes as { ok: boolean; data?: { declinedCardFingerprints?: string[] } }).data;326 setDeclinedCardCount(sData?.declinedCardFingerprints?.length ?? 0);327 }328329 if ((keyRes as { ok: boolean }).ok) {330 setLicenseKey((keyRes as { ok: boolean; data?: string }).data ?? null);331 }332333 const entriesRes = await sendMessage({ type: "GET_ENTRIES" });334 if ((entriesRes as { ok: boolean }).ok) {335 const entries = (entriesRes as { ok: boolean; data?: Array<{id: string; title: string; canary?: Canary}> }).data ?? [];336 setCanaryEntries(337 entries338 .filter((e) => e.canary?.webhookUrl)339 .map((e) => ({ id: e.id, title: e.title, canary: e.canary! }))340 );341 }342343344 if (isNativePlatform()) {345 const biometric = nativePlugin<{ isAvailable: () => Promise<{ available: boolean }> }>("BiometricPlugin");346 const keystore = nativePlugin<{ hasMasterKey: () => Promise<{ exists: boolean }> }>("KeystorePlugin");347 try {348 if (biometric) {349 const a = await biometric.isAvailable();350 setBiometricSupported(a.available);351 }352 if (keystore) {353 const k = await keystore.hasMasterKey();354 setBiometricStored(k.exists);355 }356 } catch { }357 }358 });359360 async function handleEnableBiometrics() {361 setBiometricBusy(true);362 setBiometricError(null);363 try {364 const keyRes = (await sendMessage({ type: "GET_DERIVED_KEY" })) as {365 ok: boolean;366 data?: { masterKeyHex: string };367 error?: string;368 };369 if (!keyRes.ok || !keyRes.data?.masterKeyHex) {370 setBiometricError(keyRes.error === "vault_locked"371 ? "unlock the vault first"372 : "couldn't read derived key");373 return;374 }375 const keystore = nativePlugin<{ storeMasterKey: (p: { masterKeyHex: string }) => Promise<void> }>("KeystorePlugin");376 if (!keystore) {377 setBiometricError("biometric plugin unavailable");378 return;379 }380 await keystore.storeMasterKey({ masterKeyHex: keyRes.data.masterKeyHex });381 await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: true } });382 setBiometricStored(true);383 } catch (e) {384 setBiometricError(e instanceof Error ? e.message : "failed to enable biometrics");385 } finally {386 setBiometricBusy(false);387 }388 }389390 async function handleDisableBiometrics() {391 setBiometricBusy(true);392 setBiometricError(null);393 try {394 const keystore = nativePlugin<{ clearMasterKey: () => Promise<void> }>("KeystorePlugin");395 if (keystore) await keystore.clearMasterKey();396 await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: false } });397 setBiometricStored(false);398 } catch (e) {399 setBiometricError(e instanceof Error ? e.message : "failed to disable biometrics");400 } finally {401 setBiometricBusy(false);402 }403 }404405 async function handleChangePassword() {406 setChangePwError(null);407 if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; }408 if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; }409 if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; }410 if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; }411 setChangePwSaving(true);412 const res = await sendMessage({413 type: "CHANGE_PASSWORD",414 payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() },415 }) as { ok: boolean; error?: string };416 if (res.ok) {417 setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");418 setShowChangePw(false);419 setShowNewRecoveryWords(true);420 } else {421 setChangePwError(res.error ?? "failed to change password");422 }423 setChangePwSaving(false);424 }425426 const [copied, setCopied] = createSignal(false);427428 async function copyLicenseKey(key: string) {429 await navigator.clipboard.writeText(key);430 setCopied(true);431 setTimeout(() => setCopied(false), 2000);432 }433434 async function handleActivateLicense() {435 const key = licenseKeyInput().trim();436 if (!key) return;437 setActivatingLicense(true);438 setLicenseError(null);439 setLicenseSuccess(null);440 const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } });441 setActivatingLicense(false);442 if ((res as { ok: boolean }).ok) {443 const data = (res as { ok: boolean; data?: LicenseInfo }).data;444 if (data?.valid) {445 setLicenseInfo(data);446 setLicenseKey(key);447 setLicenseSuccess("License activated!");448 setLicenseKeyInput("");449 } else {450 setLicenseError("Invalid license key");451 }452 } else {453 setLicenseError("Activation failed");454 }455 }456457 async function handleTogglePasskeys() {458 const current = passkeyState();459 if (!current || !current.supported) return;460 setPasskeySaving(true);461 const res = await sendMessage({462 type: "SET_PASSKEY_PROVIDER_ENABLED",463 payload: { enabled: !current.enabled },464 });465 setPasskeySaving(false);466 if ((res as { ok: boolean }).ok) {467 setPasskeyState(468 (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null469 );470 }471 }472473 async function handleCheckAllBreaches() {474 setCheckingBreaches(true);475 setBreachResult(null);476 const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });477 setCheckingBreaches(false);478 if ((res as { ok: boolean }).ok) {479 const data = (res as { ok: boolean; data?: Record<string, number> }).data ?? {};480 const breached = Object.values(data).filter((count) => count > 0).length;481 setBreachResult(482 breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean"483 );484 props.onBreachCheckDone();485 }486 }487488489 async function handleSetupDuress() {490 const mainPw = duressMainPassword().trim();491 const duressPw = duressPassword().trim();492 const confirmPw = duressConfirm().trim();493494 if (!mainPw) { setDuressError("enter your main password to confirm"); return; }495 if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; }496 if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; }497 if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; }498499 setDuressSaving(true);500 setDuressError(null);501502 const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };503 if (!verifyRes.ok || !verifyRes.data) {504 setDuressError("incorrect main password");505 setDuressSaving(false);506 return;507 }508509 const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string };510 setDuressSaving(false);511 if (res.ok) {512 setDuressEnabled(true);513 setShowDuressSetup(false);514 setDuressPassword("");515 setDuressConfirm("");516 setDuressMainPassword("");517 setDuressSuccess("Duress password set. If entered, a decoy vault will be shown.");518 setTimeout(() => setDuressSuccess(null), 5000);519 } else {520 setDuressError(521 res.error === "session_expired"522 ? "Vault session expired — lock and unlock Peach, then try again"523 : res.error ?? "failed to set duress password"524 );525 }526 }527528 async function handleClearDeclinedCards() {529 await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } });530 setDeclinedCardCount(0);531 }532533 async function handleCountDuplicates() {534 setDedupRunning(true);535 setDedupDone(null);536 const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number };537 setDedupRunning(false);538 if (res.ok) setDedupCount(res.data ?? 0);539 }540541 async function handleDeduplicate() {542 setDedupRunning(true);543 const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number };544 setDedupRunning(false);545 if (res.ok) {546 setDedupDone(res.data ?? 0);547 setDedupCount(0);548 }549 }550551 async function handleDisableDuress() {552 const mainPw = duressMainPassword().trim();553 if (!mainPw) { setDuressError("enter your main password to confirm"); return; }554555 setDuressSaving(true);556 setDuressError(null);557558 const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };559 if (!verifyRes.ok || !verifyRes.data) {560 setDuressError("incorrect main password");561 setDuressSaving(false);562 return;563 }564565 const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string };566 setDuressSaving(false);567 if (res.ok) {568 setDuressEnabled(false);569 setShowDuressSetup(false);570 setDuressMainPassword("");571 setDuressSuccess("Duress password disabled.");572 setTimeout(() => setDuressSuccess(null), 4000);573 } else {574 setDuressError(res.error ?? "failed to disable duress password");575 }576 }577578 async function handleSubmitFeedback() {579 if (!fbMessage().trim() || fbSubmitting()) return;580 setFbSubmitting(true);581 setFbResult(null);582 const res = await sendMessage({583 type: "SUBMIT_FEEDBACK",584 payload: {585 category: fbCategory(),586 message: fbMessage().trim(),587 version,588 browser: navigator.userAgent,589 },590 }) as { ok: boolean; error?: string };591 setFbSubmitting(false);592 if (res.ok) {593 setFbResult("success");594 setFbMessage("");595 setTimeout(() => setFbResult(null), 4000);596 } else {597 setFbResult("error");598 }599 }600601 function renderSecurity() {602 return (603 <div class="peach-settings-body">604 <div class="peach-settings-group">605 <p class="peach-settings-sub-label">LOCKING</p>606 <div class="peach-settings-row">607 <span>Lock on close</span>608 <button609 class={`peach-toggle ${props.lockOnClose ? "is-on" : ""}`}610 onClick={() => void props.onLockOnCloseChange(!props.lockOnClose)}611 aria-label="Toggle lock on close"612 >613 <span class="peach-toggle-knob"></span>614 </button>615 </div>616 <div class="peach-settings-row">617 <span>Auto-lock after</span>618 <select619 class="peach-settings-select"620 value={String(props.autoLockMinutes)}621 onChange={(e) => void props.onAutoLockMinutesChange(Number(e.currentTarget.value))}622 >623 <option value="1">1 minute</option>624 <option value="5">5 minutes</option>625 <option value="15">15 minutes</option>626 <option value="30">30 minutes</option>627 <option value="0">Never</option>628 </select>629 </div>630631 <Show when={isNativePlatform() && biometricSupported()}>632 <p class="peach-settings-sub-label">BIOMETRICS</p>633 <div class="peach-settings-row">634 <span>Unlock with biometrics</span>635 <button636 class={`peach-toggle ${biometricStored() ? "is-on" : ""}`}637 onClick={() => void (biometricStored() ? handleDisableBiometrics() : handleEnableBiometrics())}638 disabled={biometricBusy()}639 aria-label="Toggle biometric unlock"640 >641 <span class="peach-toggle-knob"></span>642 </button>643 </div>644 <Show when={biometricError()}>645 <div class="peach-settings-row peach-settings-row--meta">646 <span class="peach-hint peach-hint-error">{biometricError()}</span>647 </div>648 </Show>649 </Show>650651 <p class="peach-settings-sub-label">BREACH PROTECTION</p>652 <div class="peach-settings-row">653 <span>Breach monitoring</span>654 <button655 class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}656 onClick={() => void props.onBreachMonitoringChange(!props.breachMonitoring)}657 aria-label="Toggle breach monitoring"658 >659 <span class="peach-toggle-knob"></span>660 </button>661 </div>662 <div class="peach-settings-row peach-settings-row--meta">663 <span>Check all passwords</span>664 <button665 class="peach-btn peach-btn--subtle peach-btn--sm"666 onClick={() => void handleCheckAllBreaches()}667 disabled={checkingBreaches()}668 >669 {checkingBreaches() ? "checking..." : "run"}670 </button>671 </div>672 <Show when={breachResult()}>673 <div class="peach-settings-row peach-settings-row--meta">674 <span>Last check</span>675 <span class="peach-settings-value">{breachResult()}</span>676 </div>677 </Show>678679 <p class="peach-settings-sub-label">MASTER PASSWORD</p>680 <div class="peach-settings-row peach-settings-row--meta">681 <span>Master password</span>682 <button683 class="peach-btn peach-btn--subtle peach-btn--sm"684 onClick={() => { setShowChangePw((v) => !v); setChangePwError(null); }}685 >686 {showChangePw() ? "cancel" : "change"}687 </button>688 </div>689 <Show when={showChangePw()}>690 <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">691 <input692 class="peach-settings-input"693 type="password"694 placeholder="current password"695 value={changePwCurrent()}696 onInput={(e) => setChangePwCurrent((e.target as HTMLInputElement).value)}697 autocomplete="current-password"698 />699 <input700 class="peach-settings-input"701 type="password"702 placeholder="new password (min 12 chars)"703 value={changePwNew()}704 onInput={(e) => setChangePwNew((e.target as HTMLInputElement).value)}705 autocomplete="new-password"706 />707 <input708 class="peach-settings-input"709 type="password"710 placeholder="confirm new password"711 value={changePwConfirm()}712 onInput={(e) => setChangePwConfirm((e.target as HTMLInputElement).value)}713 autocomplete="new-password"714 onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }}715 />716 <Show when={changePwError()}>717 <p class="peach-hint peach-hint-error" style="margin:0">{changePwError()}</p>718 </Show>719 <Show when={changePwSuccess()}>720 <p class="peach-hint" style="margin:0;color:var(--peach-accent)">password changed ✓</p>721 </Show>722 <button723 class="peach-btn peach-btn--accent peach-btn--sm"724 style="align-self:flex-start"725 onClick={() => void handleChangePassword()}726 disabled={changePwSaving() || !changePwCurrent() || !changePwNew() || !changePwConfirm()}727 >728 {changePwSaving() ? "saving…" : "save new password"}729 </button>730 </div>731 </Show>732733 <p class="peach-settings-sub-label">734 DURESS PASSWORD735 <Show when={!isPro()}>736 <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>737 </Show>738 </p>739 <div class="peach-settings-row peach-settings-row--top">740 <div class="peach-settings-row-main">741 <span>742 Duress password743 <Show when={!isPro()}>744 <span class="peach-pro-badge">Pro</span>745 </Show>746 </span>747 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">748 If entered under coercion, shows a decoy empty vault instead of your real one.749 </span>750 </div>751 <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>752 {duressEnabled() ? "on" : "off"}753 </div>754 </div>755756 <Show when={duressEnabled() && !showDuressSetup()}>757 <div class="peach-settings-row peach-settings-row--meta">758 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">759 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.760 </span>761 </div>762 </Show>763764 <Show when={duressSuccess()}>765 <div class="peach-settings-row peach-settings-row--meta">766 <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>767 </div>768 </Show>769770 <Show when={!showDuressSetup()}>771 <div class="peach-settings-row peach-settings-row--meta">772 <button773 class="peach-btn peach-btn--subtle peach-btn--sm"774 onClick={() => {775 776 777 778 779 780 if (!isPro() && !duressEnabled()) {781 props.onProGate?.("duress_vault");782 setActiveCard("pro");783 return;784 }785 setShowDuressSetup(true);786 setDuressError(null);787 setDuressSuccess(null);788 setDuressPassword("");789 setDuressConfirm("");790 setDuressMainPassword("");791 }}792 >793 {duressEnabled() ? "Change / disable" : "Set up"}794 </button>795 </div>796 </Show>797798 <Show when={showDuressSetup()}>799 <div class="peach-duress-form">800 <label class="peach-label">main password (to confirm)</label>801 <input802 class="peach-settings-input"803 type="password"804 placeholder="your current master password"805 autocomplete="current-password"806 value={duressMainPassword()}807 onInput={(e) => setDuressMainPassword(e.currentTarget.value)}808 />809 <Show when={!duressEnabled()}>810 <label class="peach-label">new duress password</label>811 <input812 class="peach-settings-input"813 type="password"814 placeholder="duress password (min 8 chars)"815 autocomplete="new-password"816 value={duressPassword()}817 onInput={(e) => setDuressPassword(e.currentTarget.value)}818 />819 <label class="peach-label">confirm duress password</label>820 <input821 class="peach-settings-input"822 type="password"823 placeholder="confirm duress password"824 autocomplete="new-password"825 value={duressConfirm()}826 onInput={(e) => setDuressConfirm(e.currentTarget.value)}827 />828 </Show>829830 <Show when={duressError()}>831 <p class="peach-hint peach-hint-error">{duressError()}</p>832 </Show>833834 <div class="peach-duress-actions">835 <button836 class="peach-btn peach-btn--subtle peach-btn--sm"837 onClick={() => { setShowDuressSetup(false); setDuressError(null); }}838 >839 cancel840 </button>841 <Show when={duressEnabled()}>842 <button843 class="peach-btn peach-btn--ghost peach-btn--sm"844 style="color:var(--peach-breach)"845 onClick={() => void handleDisableDuress()}846 disabled={duressSaving()}847 >848 {duressSaving() ? "disabling..." : "disable duress"}849 </button>850 </Show>851 <Show when={!duressEnabled()}>852 <button853 class="peach-btn peach-btn--accent peach-btn--sm"854 onClick={() => void handleSetupDuress()}855 disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}856 >857 {duressSaving() ? "saving..." : "save"}858 </button>859 </Show>860 </div>861 </div>862 </Show>863864 <p class="peach-settings-sub-label">865 CANARY ENTRIES866 <Show when={!isPro()}>867 <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>868 </Show>869 </p>870 <div class="peach-settings-row peach-settings-row--top">871 <div class="peach-settings-row-main">872 <span>873 Canary entries874 <Show when={!isPro()}>875 <span class="peach-pro-badge">Pro</span>876 </Show>877 </span>878 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">879 Silently pings a webhook when an entry is accessed. Configure canary on any entry in your vault.880 </span>881 </div>882 <span class="peach-settings-value" style="flex-shrink:0">883 {canaryEntries().length > 0 ? `${canaryEntries().length} active` : "none"}884 </span>885 </div>886 <Show when={canaryEntries().length > 0}>887 <For each={canaryEntries()}>888 {(entry) => (889 <div class="peach-settings-row peach-settings-row--meta">890 <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0">{entry.title}</span>891 <button892 class="peach-btn peach-btn--subtle peach-btn--sm"893 onClick={() => void handleTestCanary(entry)}894 disabled={canaryTestingId() === entry.id}895 style="flex-shrink:0"896 >897 {canaryTestingId() === entry.id898 ? "sending…"899 : canaryTestResult()?.id === entry.id900 ? (canaryTestResult()!.ok ? "sent ✓" : "failed ✗")901 : "test"}902 </button>903 </div>904 )}905 </For>906 </Show>907908 <p class="peach-settings-sub-label">PASSKEYS</p>909 <div class="peach-settings-row peach-settings-row--top">910 <div class="peach-settings-row-main">911 <span>Intercept passkey requests</span>912 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">913 Peach can handle passkey sign-in and creation on supported Chromium browsers.914 </span>915 </div>916 <button917 class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}918 onClick={() => void handleTogglePasskeys()}919 aria-label="Toggle passkey interception"920 disabled={!passkeyState()?.supported || passkeySaving()}921 >922 <span class="peach-toggle-knob"></span>923 </button>924 </div>925 <div class="peach-settings-row peach-settings-row--meta">926 <span>Status</span>927 <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>928 </div>929 <div class="peach-settings-row peach-settings-row--meta">930 <span>Browser</span>931 <span class="peach-settings-value">932 {passkeyState()?.browserLabel ?? "Unknown"}933 {passkeyState()?.supported ? " ✓" : " (interception unavailable)"}934 </span>935 </div>936 <div class="peach-info-notice" style="margin-top:8px">937 Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.938 </div>939 <Show when={passkeyState()?.conflict}>940 <div class="peach-settings-row peach-settings-row--meta">941 <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>942 </div>943 </Show>944 </div>945 </div>946 );947 }948949 function renderVault() {950 return (951 <div class="peach-settings-body">952 <div class="peach-settings-group">953 <p class="peach-settings-sub-label">ENTRIES</p>954 <div class="peach-settings-row peach-settings-row--meta">955 <span>Entries</span>956 <span class="peach-settings-value">{props.entriesCount}</span>957 </div>958 <div class="peach-settings-row peach-settings-row--meta">959 <span>Duplicates</span>960 <Show when={dedupDone() !== null} fallback={961 <button962 class="peach-btn peach-btn--subtle peach-btn--sm"963 onClick={() => void handleCountDuplicates()}964 disabled={dedupRunning()}965 >966 {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}967 </button>968 }>969 <span class="peach-settings-value" style="color:var(--peach-sync-ok)">970 {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}971 </span>972 </Show>973 </div>974 <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>975 <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">976 <div class="peach-restore-warning">977 {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.978 </div>979 <div style="display:flex;gap:6px">980 <button981 class="peach-btn peach-btn--subtle peach-btn--sm"982 onClick={() => setDedupCount(null)}983 >984 cancel985 </button>986 <button987 class="peach-btn peach-btn--accent peach-btn--sm"988 onClick={() => void handleDeduplicate()}989 disabled={dedupRunning()}990 >991 {dedupRunning() ? "removing..." : "remove duplicates"}992 </button>993 </div>994 </div>995 </Show>996997 <p class="peach-settings-sub-label">MAINTENANCE</p>998 <div class="peach-settings-row peach-settings-row--meta">999 <span>Trash auto-delete</span>1000 <select1001 class="peach-settings-select"1002 value={String(props.trashRetentionDays)}1003 onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}1004 >1005 <option value="7">7 days</option>1006 <option value="30">30 days</option>1007 <option value="90">90 days</option>1008 <option value="0">Never</option>1009 </select>1010 </div>1011 <div class="peach-settings-row peach-settings-row--meta">1012 <span>Vault Health</span>1013 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>1014 open1015 </button>1016 </div>1017 <Show when={declinedCardCount() > 0}>1018 <div class="peach-settings-row peach-settings-row--meta">1019 <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>1020 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>1021 Clear1022 </button>1023 </div>1024 </Show>10251026 <p class="peach-settings-sub-label">DATA</p>1027 <div class="peach-settings-row peach-settings-row--meta">1028 <span>Import passwords</span>1029 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>1030 open1031 </button>1032 </div>1033 <div class="peach-settings-row peach-settings-row--meta">1034 <span>Export backup</span>1035 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onExport}>1036 open1037 </button>1038 </div>1039 <div class="peach-settings-row peach-settings-row--meta">1040 <span>1041 Peach Codex1042 <Show when={!isPro()}>1043 <span class="peach-pro-badge">Pro</span>1044 </Show>1045 </span>1046 <button1047 class="peach-btn peach-btn--subtle peach-btn--sm"1048 onClick={() => props.onOpenCodexGenerate?.()}1049 >1050 open1051 </button>1052 </div>1053 {1054}1055 <div class="peach-settings-row peach-settings-row--meta">1056 <span>Restore from Codex</span>1057 <button1058 class="peach-btn peach-btn--subtle peach-btn--sm"1059 onClick={() => props.onOpenCodexRestore?.()}1060 >1061 open1062 </button>1063 </div>10641065 <span class="peach-settings-sub-label">VAULT HISTORY</span>1066 <Show when={historyLoading()}>1067 <p class="peach-settings-hint">Loading…</p>1068 </Show>1069 <Show when={!historyLoading() && historySnapshots().length === 0}>1070 <p class="peach-settings-hint">1071 No snapshots yet. Peach saves automatically before every change —1072 saves, syncs, and imports.1073 </p>1074 </Show>1075 <Show when={!historyLoading() && historySnapshots().length > 0}>1076 <p class="peach-settings-hint">1077 Restoring locks your vault. Unlock with the password that1078 encrypted that snapshot to confirm.1079 </p>1080 <div class="peach-history-list">1081 <For each={historySnapshots().slice(0, historyShowAll() ? undefined : 3)}>1082 {(snap) => (1083 <div class="peach-history-row">1084 <div class="peach-history-meta">1085 <span class="peach-history-time">1086 {formatSnapshotTime(snap.savedAt)}1087 </span>1088 <span class="peach-history-sub">1089 {[1090 snap.entryCount !== null1091 ? `${snap.entryCount} ${snap.entryCount === 1 ? "entry" : "entries"}`1092 : null,1093 reasonLabel(snap.reason),1094 ]1095 .filter(Boolean)1096 .join(" · ")}1097 </span>1098 </div>1099 <button1100 class={`peach-btn peach-btn--subtle peach-btn--sm peach-history-btn${1101 historyRestoring() === snap.id ? " is-busy" : ""1102 }`}1103 onClick={() => void handleRestoreVaultSnapshot(snap.id)}1104 disabled={historyRestoring() !== null}1105 >1106 <Show when={historyRestoring() === snap.id} fallback="Restore">1107 …1108 </Show>1109 </button>1110 </div>1111 )}1112 </For>1113 </div>1114 <Show when={historySnapshots().length > 3}>1115 <button1116 class="peach-settings-advanced-toggle"1117 onClick={() => setHistoryShowAll((v) => !v)}1118 aria-expanded={historyShowAll()}1119 >1120 <span class="peach-settings-hint" style="margin:0">1121 {historyShowAll() ? "show less" : `${historySnapshots().length - 3} older`}1122 </span>1123 <span class="peach-settings-advanced-chevron">1124 {historyShowAll() ? "▲" : "▼"}1125 </span>1126 </button>1127 </Show>1128 <p class="peach-history-footer">1129 {historySnapshots().length} of 10 snapshots1130 </p>1131 </Show>11321133 <button1134 class="peach-settings-advanced-toggle"1135 onClick={() => setShowVaultAdvanced((v) => !v)}1136 aria-expanded={showVaultAdvanced()}1137 >1138 <span class="peach-settings-sub-label" style="margin:0">ADVANCED</span>1139 <span class="peach-settings-advanced-chevron">1140 {showVaultAdvanced() ? "▲" : "▼"}1141 </span>1142 </button>11431144 <Show when={showVaultAdvanced()}>1145 <div class="peach-danger-card">1146 <div class="peach-danger-card-head">1147 <span class="peach-danger-card-glyph" aria-hidden="true">⟳</span>1148 <h3 class="peach-danger-card-title">Replace vault from backup</h3>1149 </div>1150 <p class="peach-danger-card-copy">1151 Wipes the current vault and restores from a backup file.1152 Recoverable only if your backup is good.1153 </p>1154 <button1155 class="peach-danger-card-button"1156 onClick={() => props.onOpenFileImport?.()}1157 >1158 Choose backup file1159 </button>1160 </div>11611162 <Show when={props.onOpenPanic}>1163 <div class="peach-danger-card peach-danger-card--hazard">1164 <div class="peach-danger-card-head">1165 <span class="peach-danger-card-glyph" aria-hidden="true">⚠</span>1166 <h3 class="peach-danger-card-title">Destroy vault</h3>1167 </div>1168 <p class="peach-danger-card-copy">1169 Permanently wipes every entry, setting, and license on this1170 device. There is no recovery.1171 </p>1172 <button1173 class="peach-danger-card-button"1174 onClick={() => props.onOpenPanic?.()}1175 >1176 Begin destruction1177 </button>1178 </div>1179 </Show>1180 </Show>1181 </div>1182 </div>1183 );1184 }11851186 function renderSync() {1187 return (1188 <div class="peach-settings-body">1189 <div class="peach-settings-group">1190 <p class="peach-settings-sub-label">PAIRING</p>1191 <div class="peach-settings-row peach-settings-row--top">1192 <div class="peach-settings-row-main">1193 <span>1194 Peach Sync1195 <Show when={!isPro()}>1196 <span class="peach-pro-badge">Pro</span>1197 </Show>1198 </span>1199 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1200 Pair nearby devices by chirp and keep encrypted vault blobs in step without a central account.1201 </span>1202 </div>1203 <span class={`stk-pip ${isPro() ? "is-on" : ""}`} aria-hidden="true" />1204 </div>1205 <div class="peach-settings-row peach-settings-row--meta">1206 <span>Status</span>1207 <span class="peach-settings-value">{isPro() ? "Ready" : "Requires Pro"}</span>1208 </div>1209 <div class="peach-settings-row peach-settings-row--meta">1210 <span>Sync hub</span>1211 <button1212 class="peach-btn peach-btn--subtle peach-btn--sm"1213 onClick={() => props.onOpenSyncPairing?.()}1214 >1215 open1216 </button>1217 </div>12181219 <p class="peach-settings-sub-label">DEVICE FLOW</p>1220 <div class="peach-settings-row peach-settings-row--top">1221 <div class="peach-settings-row-main">1222 <span>Pair new device</span>1223 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1224 Opens the full chirp pairing console with scan, show-code, and advanced diagnostics.1225 </span>1226 </div>1227 <button1228 class="peach-btn peach-btn--subtle peach-btn--sm"1229 onClick={() => props.onOpenSyncPairing?.()}1230 >1231 pair1232 </button>1233 </div>1234 <div class="peach-info-notice" style="margin-top:8px">1235 Sync uses the same zero-knowledge vault format as backup and restore; pairing authorizes a device, not a cloud account.1236 </div>1237 </div>1238 </div>1239 );1240 }12411242 function renderAppearance() {1243 const sizeOptions: Array<{ id: "medium" | "large"; label: string }> = [1244 { id: "medium", label: "Default" },1245 { id: "large", label: "Large" },1246 ];1247 return (1248 <div class="peach-settings-body">1249 <div class="peach-settings-group">1250 <p class="peach-settings-sub-label">THEME</p>1251 <div class="peach-settings-row peach-settings-row--meta">1252 <span>Theme</span>1253 <button1254 class="peach-btn peach-btn--subtle peach-btn--sm"1255 onClick={() =>1256 props.onThemeChange(props.theme === "dark" ? "light" : "dark")1257 }1258 >1259 {props.theme === "dark" ? "Switch to light" : "Switch to dark"}1260 </button>1261 </div>12621263 <p class="peach-settings-sub-label">ACCENT</p>1264 <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">1265 <span>Accent</span>1266 <div class="peach-accent-picker">1267 <For each={ACCENT_COLORS}>1268 {(color) => (1269 <button1270 class="peach-accent-swatch peach-swatch"1271 classList={{ "is-active": props.accentColor === color.name }}1272 style={{ background: color.hex }}1273 onClick={() => void props.onAccentChange(color.name)}1274 aria-label={color.name}1275 title={color.name}1276 />1277 )}1278 </For>1279 </div>1280 </div>12811282 <p class="peach-settings-sub-label">TEXT SIZE</p>1283 <div class="peach-settings-row peach-settings-row--meta">1284 <span>Size</span>1285 <div class="peach-pill-group">1286 <For each={sizeOptions}>1287 {(opt) => (1288 <button1289 class="peach-btn peach-btn--ghost peach-btn--sm"1290 classList={{ "is-active": props.textSize === opt.id }}1291 onClick={() => void props.onTextSizeChange(opt.id)}1292 >1293 {opt.label}1294 </button>1295 )}1296 </For>1297 </div>1298 </div>12991300 <p class="peach-settings-sub-label">MOTION</p>1301 <div class="peach-settings-row peach-settings-row--meta">1302 <span>Reduce motion</span>1303 <button1304 class="peach-btn peach-btn--subtle peach-btn--sm"1305 onClick={() => void props.onReduceMotionChange(!props.reduceMotion)}1306 >1307 {props.reduceMotion ? "On" : "Off"}1308 </button>1309 </div>1310 </div>1311 </div>1312 );1313 }13141315 function renderShortcuts() {1316 const [capturingAction, setCapturingAction] = createSignal<ShortcutAction | null>(null);1317 const [conflictMsg, setConflictMsg] = createSignal<string | null>(null);13181319 function startCapture(action: ShortcutAction) {1320 setConflictMsg(null);1321 setCapturingAction(action);1322 }1323 function cancelCapture() {1324 setCapturingAction(null);1325 setConflictMsg(null);1326 }1327 function commitBinding(action: ShortcutAction, binding: Keybinding) {1328 const conflict = findConflict(props.shortcuts, binding, action);1329 if (conflict) {1330 const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict);1331 setConflictMsg(`Already bound to "${other?.label ?? conflict}"`);1332 return;1333 }1334 const next = { ...props.shortcuts, [action]: binding };1335 setCapturingAction(null);1336 setConflictMsg(null);1337 void props.onShortcutsChange(next);1338 }1339 function resetAction(action: ShortcutAction) {1340 const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] };1341 void props.onShortcutsChange(next);1342 }1343 function resetAll() {1344 void props.onShortcutsChange({ ...DEFAULT_BINDINGS });1345 }13461347 1348 1349 function handleCaptureKey(e: KeyboardEvent) {1350 e.preventDefault();1351 e.stopPropagation();1352 if (e.key === "Escape") { cancelCapture(); return; }1353 const binding = keybindingFromEvent(e);1354 if (!binding) return; 1355 const action = capturingAction();1356 if (action) commitBinding(action, binding);1357 }13581359 return (1360 <div class="peach-settings-body">1361 <div class="peach-settings-group peach-shortcuts-group">1362 <p class="peach-settings-sub-label">ENTRY VIEW</p>1363 <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry")}>1364 {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1365 </For>1366 </div>13671368 <div class="peach-settings-group peach-shortcuts-group">1369 <p class="peach-settings-sub-label">ANYWHERE IN POPUP</p>1370 <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global")}>1371 {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1372 </For>1373 </div>13741375 <Show when={conflictMsg()}>1376 <div class="peach-shortcuts-conflict">{conflictMsg()}</div>1377 </Show>13781379 <div class="peach-settings-group" style="display:flex;flex-direction:column;gap:8px">1380 <div class="peach-settings-row peach-settings-row--meta">1381 <span>Inline autofill on page</span>1382 <a1383 class="peach-settings-link"1384 href="chrome://extensions/shortcuts"1385 onClick={(e) => {1386 e.preventDefault();1387 chrome.tabs.create({ url: "chrome://extensions/shortcuts" });1388 }}1389 >1390 ⌘⇧P — customize ↗1391 </a>1392 </div>1393 <button1394 class="peach-btn peach-btn--subtle peach-btn--sm"1395 style="align-self:flex-start"1396 onClick={resetAll}1397 >1398 Reset all to defaults1399 </button>1400 </div>1401 </div>1402 );14031404 function renderRow(1405 desc: typeof SHORTCUT_DESCRIPTORS[number],1406 capturing: () => ShortcutAction | null,1407 onStart: (a: ShortcutAction) => void,1408 onCancel: () => void,1409 onReset: (a: ShortcutAction) => void,1410 onKey: (e: KeyboardEvent) => void,1411 ) {1412 const isActive = () => capturing() === desc.action;1413 const binding = () => props.shortcuts[desc.action];1414 const isCustom = () => {1415 const b = binding();1416 const d = DEFAULT_BINDINGS[desc.action];1417 return b.key !== d.key1418 || Boolean(b.ctrl) !== Boolean(d.ctrl)1419 || Boolean(b.meta) !== Boolean(d.meta)1420 || Boolean(b.shift) !== Boolean(d.shift)1421 || Boolean(b.alt) !== Boolean(d.alt);1422 };1423 return (1424 <div class="peach-shortcut-row">1425 <span class="peach-shortcut-row-label">{desc.label}</span>1426 <button1427 class="peach-shortcut-chip"1428 classList={{ "is-capturing": isActive(), "is-custom": isCustom() }}1429 onClick={() => isActive() ? onCancel() : onStart(desc.action)}1430 onKeyDown={(e) => { if (isActive()) onKey(e); }}1431 ref={(el) => {1432 1433 if (el) {1434 queueMicrotask(() => { if (isActive()) el.focus(); });1435 }1436 }}1437 >1438 {isActive() ? "Press a key…" : formatKeybinding(binding())}1439 </button>1440 <Show when={isCustom() && !isActive()}>1441 <button1442 class="peach-shortcut-reset"1443 title="Reset to default"1444 onClick={() => onReset(desc.action)}1445 >1446 ↺1447 </button>1448 </Show>1449 </div>1450 );1451 }1452 }14531454 const PRO_FEATURES: Array<{1455 feature: ProFeature;1456 name: string;1457 desc: string;1458 }> = [1459 { feature: "sync", name: "Peach Sync", desc: "Encrypted · peer-to-peer" },1460 { feature: "codex", name: "Peach Codex", desc: "Restore from paper" },1461 { feature: "passkeys", name: "Passkeys", desc: "Zero-knowledge WebAuthn" },1462 { feature: "ios_app", name: "Mobile apps", desc: "Android · iOS soon" },1463 { feature: "advanced_health", name: "Vault health", desc: "Reuse · age · exposure" },1464 ];14651466 const PRO_SECURITY_FEATURES: Array<{1467 feature: ProFeature;1468 name: string;1469 desc: string;1470 }> = [1471 { feature: "canary_entry", name: "Canary entries", desc: "Detects vault compromise" },1472 { feature: "duress_vault", name: "Duress vault", desc: "Decoy under coercion" },1473 ];14741475 const [licenseKey, setLicenseKey] = createSignal<string | null>(null);1476 const [showKey, setShowKey] = createSignal(false);14771478 const [deactivating, setDeactivating] = createSignal(false);14791480 async function handleDeactivateLicense() {1481 setDeactivating(true);1482 await sendMessage({ type: "DEACTIVATE_LICENSE" });1483 setLicenseInfo(null);1484 await props.onLicenseChange?.();1485 setDeactivating(false);1486 }14871488 async function handleTestCanary(entry: {id: string; title: string; canary: Canary}) {1489 setCanaryTestingId(entry.id);1490 setCanaryTestResult(null);1491 const res = await sendMessage({1492 type: "FIRE_CANARY_PING",1493 payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger: "test" },1494 }) as { ok: boolean };1495 setCanaryTestResult({ id: entry.id, ok: res.ok });1496 setCanaryTestingId(null);1497 setTimeout(() => setCanaryTestResult((r) => r?.id === entry.id ? null : r), 3000);1498 }14991500 function renderLicense() {1501 async function onActivate() {1502 await handleActivateLicense();1503 if ((licenseInfo()?.valid ?? false) && licenseInfo()?.plan === "pro") {1504 await props.onLicenseChange?.();1505 }1506 }15071508 return (1509 <div class="peach-settings-body">1510 <Show1511 when={isPro()}1512 fallback={1513 <div class="peach-pro-upgrade">1514 <p class="peach-pro-eyebrow">PEACH PRO · ONE-TIME</p>15151516 <h2 class="peach-pro-headline">1517 Every<em>thing,</em><br />every<em>where.</em>1518 </h2>15191520 <p class="peach-pro-subtitle">1521 One key unlocks every Pro feature — for the lifetime of the app.1522 </p>15231524 <div class="peach-pro-grid">1525 <For each={PRO_FEATURES}>1526 {(item, i) => (1527 <div1528 class="peach-pro-grid-item"1529 classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}1530 >1531 <span class="peach-pro-grid-num">{String(i() + 1).padStart(2, "0")}</span>1532 <span class="peach-pro-grid-name">{item.name}</span>1533 <span class="peach-pro-grid-desc">{item.desc}</span>1534 </div>1535 )}1536 </For>1537 </div>15381539 <p class="peach-pro-section-label">ADVANCED SECURITY</p>1540 <div class="peach-pro-grid">1541 <For each={PRO_SECURITY_FEATURES}>1542 {(item, i) => (1543 <div1544 class="peach-pro-grid-item"1545 classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}1546 >1547 <span class="peach-pro-grid-num">{String(PRO_FEATURES.length + i() + 1).padStart(2, "0")}</span>1548 <span class="peach-pro-grid-name">{item.name}</span>1549 <span class="peach-pro-grid-desc">{item.desc}</span>1550 </div>1551 )}1552 </For>1553 </div>15541555 <div class="peach-pro-upgrade-actions">1556 <p class="peach-pro-pricing">1557 $39 <span class="peach-pro-pricing-sub">once · yours forever</span>1558 </p>1559 <a1560 href="https://peachpasswords.com/#pricing"1561 target="_blank"1562 rel="noopener noreferrer"1563 class="peach-pro-upgrade-cta"1564 >1565 Get Peach Pro1566 </a>1567 <div class="peach-pro-key-divider"><span>ALREADY HAVE A KEY</span></div>1568 <div class="peach-pro-key-inline">1569 <input1570 class="peach-pro-key-inline-input"1571 type="text"1572 placeholder="PEACH-XXXX-XXXX-XXXX"1573 value={licenseKeyInput()}1574 onInput={(e) => setLicenseKeyInput(e.currentTarget.value)}1575 onKeyDown={(e) => { if (e.key === "Enter") void onActivate(); }}1576 autocomplete="off"1577 spellcheck={false}1578 />1579 <button1580 class="peach-pro-key-apply"1581 onClick={() => void onActivate()}1582 disabled={activatingLicense() || !licenseKeyInput().trim()}1583 >1584 {activatingLicense() ? "…" : "APPLY"}1585 </button>1586 </div>1587 <Show when={licenseError()}>1588 <p class="peach-pro-key-error">{licenseError()}</p>1589 </Show>1590 </div>1591 </div>1592 }1593 >1594 <div class="peach-pro-active">1595 <div class="peach-pro-active-hero">1596 <div class="peach-pro-active-mark-wrap">1597 <PeachMark class="peach-pro-active-mark" />1598 </div>1599 <h2 class="peach-pro-active-title">Peach Pro</h2>1600 <p class="peach-pro-active-since">{formatActivatedSince(licenseInfo()?.issued)}</p>1601 </div>16021603 <Show when={licenseKey()}>1604 <div class="peach-settings-license">1605 <span class="peach-settings-label">LICENSE KEY</span>1606 <div class="peach-settings-license-row">1607 <Show1608 when={showKey()}1609 fallback={1610 <span class="peach-settings-license-key peach-settings-license-key--masked" aria-label="License key hidden">1611 ••••••••••••••••••••••••••••1612 </span>1613 }1614 >1615 <span class="peach-settings-license-key">{licenseKey()!}</span>1616 </Show>1617 <div class="peach-settings-license-actions">1618 <button1619 class="peach-settings-license-copy"1620 title="Copy license key"1621 onClick={() => copyLicenseKey(licenseKey()!)}1622 >1623 {copied() ? "✓" : "copy"}1624 </button>1625 <button1626 class="peach-settings-license-toggle"1627 aria-label={showKey() ? "Hide license key" : "Reveal license key"}1628 title={showKey() ? "Hide" : "Reveal"}1629 onClick={() => setShowKey(v => !v)}1630 >1631 <Show1632 when={showKey()}1633 fallback={1634 <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">1635 <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />1636 <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />1637 </svg>1638 }1639 >1640 <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">1641 <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />1642 <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />1643 <line x1="2" y1="2" x2="12" y2="12" />1644 </svg>1645 </Show>1646 </button>1647 </div>1648 </div>1649 </div>1650 </Show>16511652 <div class="peach-pro-active-features">1653 <For each={[...PRO_FEATURES, ...PRO_SECURITY_FEATURES]}>1654 {(item) => (1655 <div class="peach-pro-active-feature">1656 <span class="peach-pro-active-feature-mark">✦</span>1657 <span class="peach-pro-active-feature-name">{item.name}</span>1658 </div>1659 )}1660 </For>1661 </div>16621663 <button1664 class="peach-pro-deactivate"1665 onClick={() => void handleDeactivateLicense()}1666 disabled={deactivating()}1667 >1668 {deactivating() ? "Deactivating…" : "Deactivate"}1669 </button>1670 </div>1671 </Show>1672 </div>1673 );1674 }16751676 function renderFeedback() {1677 return (1678 <div class="peach-settings-body">1679 <div class="peach-settings-group peach-feedback-group">1680 <p class="peach-feedback-intro">1681 Help us improve Peach. Every report goes directly to our inbox.1682 </p>16831684 <p class="peach-settings-sub-label">TYPE</p>1685 <div class="peach-feedback-chips">1686 <For each={FEEDBACK_CATEGORIES}>1687 {(cat) => (1688 <button1689 class={`peach-feedback-chip ${fbCategory() === cat ? "is-active" : ""}`}1690 onClick={() => setFbCategory(cat)}1691 >1692 {cat}1693 </button>1694 )}1695 </For>1696 </div>16971698 <p class="peach-settings-sub-label">MESSAGE</p>1699 <textarea1700 class="peach-feedback-textarea"1701 placeholder="Describe the issue or idea..."1702 value={fbMessage()}1703 onInput={(e) => setFbMessage(e.currentTarget.value)}1704 />17051706 <p class="peach-feedback-meta">1707 Version {version} · will be included automatically1708 </p>17091710 <Show when={fbResult() === "success"}>1711 <p class="peach-feedback-result peach-feedback-result--ok">1712 Sent — thank you1713 </p>1714 </Show>1715 <Show when={fbResult() === "error"}>1716 <p class="peach-feedback-result peach-feedback-result--err">1717 Failed to send — try again1718 </p>1719 </Show>17201721 <button1722 class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"1723 onClick={() => void handleSubmitFeedback()}1724 disabled={fbSubmitting() || !fbMessage().trim()}1725 >1726 {fbSubmitting() ? "Sending..." : "Send feedback"}1727 </button>17281729 <p class="peach-feedback-anon">1730 Sent anonymously · no account required1731 </p>1732 </div>1733 </div>1734 );1735 }17361737 const bodies: StackBodies = {1738 protection: renderSecurity(),1739 vault: renderVault(),1740 sync: renderSync(),1741 appearance: renderAppearance(),1742 shortcuts: renderShortcuts(),1743 pro: renderLicense(),1744 feedback: renderFeedback(),1745 };17461747 return (1748 <Show1749 when={showNewRecoveryWords()}1750 fallback={1751 <div1752 class="peach-overlay-view peach-settings-overlay"1753 classList={{ "ov-enter-right": !props.isExiting, "ov-exit-right": !!props.isExiting }}1754 style="display:flex;flex-direction:column"1755 >1756 <StackDeck1757 active={activeCard()}1758 onActiveChange={setActiveCard}1759 onBack={props.onBack}1760 cards={cards()}1761 bodies={bodies}1762 version={version}1763 />1764 </div>1765 }1766 >1767 <RecoveryWordDisplayView onDone={() => setShowNewRecoveryWords(false)} />1768 </Show>1769 );1770}