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}7273const FEEDBACK_CATEGORIES = ["Bug report", "Feature request", "Question", "Other"];7475function formatActivatedSince(issued: string | undefined): string {76 if (!issued) return "Active";77 try {78 const d = new Date(issued);79 if (Number.isNaN(d.getTime())) return "Active";80 return `Active since ${d.toLocaleDateString(undefined, { month: "long", year: "numeric" })}`;81 } catch {82 return "Active";83 }84}8586function initialSectionToCard(value: string | null | undefined): CardId | null {87 if (!value) return null;88 if (value === "security") return "protection";89 if (value === "license") return "pro";90 if (91 value === "protection" ||92 value === "vault" ||93 value === "sync" ||94 value === "appearance" ||95 value === "shortcuts" ||96 value === "pro" ||97 value === "feedback"98 ) {99 return value;100 }101 return null;102}103104export function SettingsView(props: SettingsProps) {105 const manifest = chrome.runtime.getManifest();106 const version = manifest.version;107 const initial = props.initialSection;108 const [activeCard, setActiveCard] = createSignal<CardId>(109 props.upgradeHighlight ? "pro" : initialSectionToCard(initial) ?? "protection"110 );111112 const [checkingBreaches, setCheckingBreaches] = createSignal(false);113 const [breachResult, setBreachResult] = createSignal<string | null>(null);114 const [licenseInfo, setLicenseInfo] = createSignal<LicenseInfo | null>(null);115 const [licenseKeyInput, setLicenseKeyInput] = createSignal("");116 const [licenseError, setLicenseError] = createSignal<string | null>(null);117 const [, setLicenseSuccess] = createSignal<string | null>(null);118 const [activatingLicense, setActivatingLicense] = createSignal(false);119120 const isPro = () => props.isPro ?? false;121 const cards = createMemo<StackCardSpec[]>(() => [122 {123 id: "protection",124 num: "I · PROTECT",125 title: "Protection",126 lede: `${props.breachMonitoring ? "breaches watched" : "breach watch off"} · ${props.lockOnClose ? "locks on close" : "manual lock"}`,127 bgVar: "--stack-protect-bg",128 fgVar: "--stack-protect-fg",129 icon: "shield",130 },131 {132 id: "vault",133 num: "II · KEEP",134 title: "Vault",135 lede: `${props.entriesCount} ${props.entriesCount === 1 ? "entry" : "entries"} · backups, history, cleanup`,136 bgVar: "--stack-vault-bg",137 fgVar: "--stack-vault-fg",138 icon: "lock",139 },140 {141 id: "sync",142 num: "III · ECHO",143 title: "Sync",144 lede: isPro() ? "pair by chirp, keep in step" : "Pro · encrypted device pairing",145 bgVar: "--stack-sync-bg",146 fgVar: "--stack-sync-fg",147 icon: "sync",148 },149 {150 id: "appearance",151 num: "IV · DRESS",152 title: "Appearance",153 lede: `${props.theme} · ${props.accentColor} · ${props.textSize}`,154 bgVar: "--stack-appear-bg",155 fgVar: "--stack-appear-fg",156 icon: "sun",157 },158 {159 id: "shortcuts",160 num: "V · KEYS",161 title: "Shortcuts",162 lede: `${SHORTCUT_DESCRIPTORS.length} bindings · what your fingers know`,163 bgVar: "--stack-shortcut-bg",164 fgVar: "--stack-shortcut-fg",165 icon: "keys",166 },167 {168 id: "pro",169 num: "VI · FOREVER",170 title: "Peach Pro",171 lede: isPro() ? "active · paid once, yours forever" : "$39 once · yours forever",172 bgVar: "--stack-pro-bg",173 fgVar: "--stack-pro-fg",174 icon: "spark",175 },176 {177 id: "feedback",178 num: "VII · ECHO",179 title: "Feedback",180 lede: `version ${version} · tell us what would make it better`,181 bgVar: "--stack-feedback-bg",182 fgVar: "--stack-feedback-fg",183 icon: "bubble",184 },185 ]);186 const [biometricSupported, setBiometricSupported] = createSignal(false);187 const [biometricStored, setBiometricStored] = createSignal(false);188 const [biometricBusy, setBiometricBusy] = createSignal(false);189 const [biometricError, setBiometricError] = createSignal<string | null>(null);190 const [passkeyState, setPasskeyState] = createSignal<PasskeyProviderState | null>(null);191 const [passkeySaving, setPasskeySaving] = createSignal(false);192193 const [dedupCount, setDedupCount] = createSignal<number | null>(null);194 const [dedupRunning, setDedupRunning] = createSignal(false);195 const [dedupDone, setDedupDone] = createSignal<number | null>(null);196197 const [declinedCardCount, setDeclinedCardCount] = createSignal(0);198199 200 201 202 const [historySnapshots, setHistorySnapshots] = createSignal<VaultSnapshot[]>([]);203 const [historyLoading, setHistoryLoading] = createSignal(false);204 const [historyRestoring, setHistoryRestoring] = createSignal<string | null>(null);205 const [historyOpen, setHistoryOpen] = createSignal(false);206207 async function loadVaultHistory() {208 setHistoryLoading(true);209 try {210 const res = await sendMessage({ type: "GET_VAULT_HISTORY" });211 const list = (res as { ok: boolean; data?: VaultSnapshot[] }).data ?? [];212 setHistorySnapshots(list);213 } finally {214 setHistoryLoading(false);215 }216 }217218 async function handleRestoreVaultSnapshot(id: string) {219 if (historyRestoring()) return;220 setHistoryRestoring(id);221 const res = await sendMessage({222 type: "RESTORE_VAULT_SNAPSHOT",223 payload: { snapshotId: id },224 });225 setHistoryRestoring(null);226 if ((res as { ok: boolean }).ok) {227 228 229 props.onBack();230 }231 }232233 function formatSnapshotTime(savedAt: number): string {234 const diff = Date.now() - savedAt;235 if (diff < 60_000) return "Just now";236 if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;237 const d = new Date(savedAt);238 const isToday = new Date().toDateString() === d.toDateString();239 const isYest =240 new Date(Date.now() - 86_400_000).toDateString() === d.toDateString();241 const t = d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });242 if (isToday) return `Today ${t}`;243 if (isYest) return `Yesterday ${t}`;244 return `${d.toLocaleDateString([], { month: "short", day: "numeric" })} ${t}`;245 }246247 function reasonLabel(reason: VaultSnapshot["reason"]): string {248 return (249 {250 local_save: "saved",251 sync_received: "synced",252 import: "imported",253 restore: "restored",254 } as const255 )[reason] ?? reason;256 }257258 const [showVaultAdvanced, setShowVaultAdvanced] = createSignal(false);259260 const [showChangePw, setShowChangePw] = createSignal(false);261 const [changePwCurrent, setChangePwCurrent] = createSignal("");262 const [changePwNew, setChangePwNew] = createSignal("");263 const [changePwConfirm, setChangePwConfirm] = createSignal("");264 const [changePwSaving, setChangePwSaving] = createSignal(false);265 const [changePwError, setChangePwError] = createSignal<string | null>(null);266 const [changePwSuccess] = createSignal(false);267 const [showNewRecoveryWords, setShowNewRecoveryWords] = createSignal(false);268269 const [duressEnabled, setDuressEnabled] = createSignal(false);270 const [showDuressSetup, setShowDuressSetup] = createSignal(false);271 const [duressPassword, setDuressPassword] = createSignal("");272 const [duressConfirm, setDuressConfirm] = createSignal("");273 const [duressMainPassword, setDuressMainPassword] = createSignal("");274 const [duressSaving, setDuressSaving] = createSignal(false);275 const [duressError, setDuressError] = createSignal<string | null>(null);276 const [duressSuccess, setDuressSuccess] = createSignal<string | null>(null);277278 const [canaryEntries, setCanaryEntries] = createSignal<Array<{id: string; title: string; canary: Canary}>>([]);279280 const [canaryTestingId, setCanaryTestingId] = createSignal<string | null>(null);281 const [canaryTestResult, setCanaryTestResult] = createSignal<{id: string; ok: boolean} | null>(null);282283 const [fbCategory, setFbCategory] = createSignal("Bug report");284 const [fbMessage, setFbMessage] = createSignal("");285 const [fbSubmitting, setFbSubmitting] = createSignal(false);286 const [fbResult, setFbResult] = createSignal<"success" | "error" | null>(null);287288 createEffect(() => {289 if (props.upgradeHighlight) {290 setActiveCard("pro");291 }292 });293294 onMount(async () => {295 const [licenseRes, passkeyRes, duressRes, settingsRes, keyRes] = await Promise.all([296 sendMessage({ type: "GET_LICENSE" }),297 sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }),298 sendMessage({ type: "IS_DURESS_ENABLED" }),299 sendMessage({ type: "GET_SETTINGS" }),300 sendMessage({ type: "GET_LICENSE_KEY" }),301 ]);302303 304 305 void loadVaultHistory();306307 if ((licenseRes as { ok: boolean }).ok) {308 setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null);309 }310311 if ((passkeyRes as { ok: boolean }).ok) {312 setPasskeyState(313 (passkeyRes as { ok: boolean; data?: PasskeyProviderState }).data ?? null314 );315 }316317 if ((duressRes as { ok: boolean }).ok) {318 setDuressEnabled((duressRes as { ok: boolean; data?: boolean }).data ?? false);319 }320321 if ((settingsRes as { ok: boolean }).ok) {322 const sData = (settingsRes as { ok: boolean; data?: { declinedCardFingerprints?: string[] } }).data;323 setDeclinedCardCount(sData?.declinedCardFingerprints?.length ?? 0);324 }325326 if ((keyRes as { ok: boolean }).ok) {327 setLicenseKey((keyRes as { ok: boolean; data?: string }).data ?? null);328 }329330 const entriesRes = await sendMessage({ type: "GET_ENTRIES" });331 if ((entriesRes as { ok: boolean }).ok) {332 const entries = (entriesRes as { ok: boolean; data?: Array<{id: string; title: string; canary?: Canary}> }).data ?? [];333 setCanaryEntries(334 entries335 .filter((e) => e.canary?.webhookUrl)336 .map((e) => ({ id: e.id, title: e.title, canary: e.canary! }))337 );338 }339340341 if (isNativePlatform()) {342 const biometric = nativePlugin<{ isAvailable: () => Promise<{ available: boolean }> }>("BiometricPlugin");343 const keystore = nativePlugin<{ hasMasterKey: () => Promise<{ exists: boolean }> }>("KeystorePlugin");344 try {345 if (biometric) {346 const a = await biometric.isAvailable();347 setBiometricSupported(a.available);348 }349 if (keystore) {350 const k = await keystore.hasMasterKey();351 setBiometricStored(k.exists);352 }353 } catch { }354 }355 });356357 async function handleEnableBiometrics() {358 setBiometricBusy(true);359 setBiometricError(null);360 try {361 const keyRes = (await sendMessage({ type: "GET_DERIVED_KEY" })) as {362 ok: boolean;363 data?: { masterKeyHex: string };364 error?: string;365 };366 if (!keyRes.ok || !keyRes.data?.masterKeyHex) {367 setBiometricError(keyRes.error === "vault_locked"368 ? "unlock the vault first"369 : "couldn't read derived key");370 return;371 }372 const keystore = nativePlugin<{ storeMasterKey: (p: { masterKeyHex: string }) => Promise<void> }>("KeystorePlugin");373 if (!keystore) {374 setBiometricError("biometric plugin unavailable");375 return;376 }377 await keystore.storeMasterKey({ masterKeyHex: keyRes.data.masterKeyHex });378 await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: true } });379 setBiometricStored(true);380 } catch (e) {381 setBiometricError(e instanceof Error ? e.message : "failed to enable biometrics");382 } finally {383 setBiometricBusy(false);384 }385 }386387 async function handleDisableBiometrics() {388 setBiometricBusy(true);389 setBiometricError(null);390 try {391 const keystore = nativePlugin<{ clearMasterKey: () => Promise<void> }>("KeystorePlugin");392 if (keystore) await keystore.clearMasterKey();393 await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: false } });394 setBiometricStored(false);395 } catch (e) {396 setBiometricError(e instanceof Error ? e.message : "failed to disable biometrics");397 } finally {398 setBiometricBusy(false);399 }400 }401402 async function handleChangePassword() {403 setChangePwError(null);404 if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; }405 if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; }406 if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; }407 if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; }408 setChangePwSaving(true);409 const res = await sendMessage({410 type: "CHANGE_PASSWORD",411 payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() },412 }) as { ok: boolean; error?: string };413 if (res.ok) {414 setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");415 setShowChangePw(false);416 setShowNewRecoveryWords(true);417 } else {418 setChangePwError(res.error ?? "failed to change password");419 }420 setChangePwSaving(false);421 }422423 const [copied, setCopied] = createSignal(false);424425 async function copyLicenseKey(key: string) {426 await navigator.clipboard.writeText(key);427 setCopied(true);428 setTimeout(() => setCopied(false), 2000);429 }430431 async function handleActivateLicense() {432 const key = licenseKeyInput().trim();433 if (!key) return;434 setActivatingLicense(true);435 setLicenseError(null);436 setLicenseSuccess(null);437 const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } });438 setActivatingLicense(false);439 if ((res as { ok: boolean }).ok) {440 const data = (res as { ok: boolean; data?: LicenseInfo }).data;441 if (data?.valid) {442 setLicenseInfo(data);443 setLicenseKey(key);444 setLicenseSuccess("License activated!");445 setLicenseKeyInput("");446 } else {447 setLicenseError("Invalid license key");448 }449 } else {450 setLicenseError("Activation failed");451 }452 }453454 async function handleTogglePasskeys() {455 const current = passkeyState();456 if (!current || !current.supported) return;457 setPasskeySaving(true);458 const res = await sendMessage({459 type: "SET_PASSKEY_PROVIDER_ENABLED",460 payload: { enabled: !current.enabled },461 });462 setPasskeySaving(false);463 if ((res as { ok: boolean }).ok) {464 setPasskeyState(465 (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null466 );467 }468 }469470 async function handleCheckAllBreaches() {471 setCheckingBreaches(true);472 setBreachResult(null);473 const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });474 setCheckingBreaches(false);475 if ((res as { ok: boolean }).ok) {476 const data = (res as { ok: boolean; data?: Record<string, number> }).data ?? {};477 const breached = Object.values(data).filter((count) => count > 0).length;478 setBreachResult(479 breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean"480 );481 props.onBreachCheckDone();482 }483 }484485486 async function handleSetupDuress() {487 const mainPw = duressMainPassword().trim();488 const duressPw = duressPassword().trim();489 const confirmPw = duressConfirm().trim();490491 if (!mainPw) { setDuressError("enter your main password to confirm"); return; }492 if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; }493 if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; }494 if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; }495496 setDuressSaving(true);497 setDuressError(null);498499 const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };500 if (!verifyRes.ok || !verifyRes.data) {501 setDuressError("incorrect main password");502 setDuressSaving(false);503 return;504 }505506 const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string };507 setDuressSaving(false);508 if (res.ok) {509 setDuressEnabled(true);510 setShowDuressSetup(false);511 setDuressPassword("");512 setDuressConfirm("");513 setDuressMainPassword("");514 setDuressSuccess("Duress password set. If entered, a decoy vault will be shown.");515 setTimeout(() => setDuressSuccess(null), 5000);516 } else {517 setDuressError(res.error ?? "failed to set duress password");518 }519 }520521 async function handleClearDeclinedCards() {522 await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } });523 setDeclinedCardCount(0);524 }525526 async function handleCountDuplicates() {527 setDedupRunning(true);528 setDedupDone(null);529 const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number };530 setDedupRunning(false);531 if (res.ok) setDedupCount(res.data ?? 0);532 }533534 async function handleDeduplicate() {535 setDedupRunning(true);536 const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number };537 setDedupRunning(false);538 if (res.ok) {539 setDedupDone(res.data ?? 0);540 setDedupCount(0);541 }542 }543544 async function handleDisableDuress() {545 const mainPw = duressMainPassword().trim();546 if (!mainPw) { setDuressError("enter your main password to confirm"); return; }547548 setDuressSaving(true);549 setDuressError(null);550551 const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };552 if (!verifyRes.ok || !verifyRes.data) {553 setDuressError("incorrect main password");554 setDuressSaving(false);555 return;556 }557558 const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string };559 setDuressSaving(false);560 if (res.ok) {561 setDuressEnabled(false);562 setShowDuressSetup(false);563 setDuressMainPassword("");564 setDuressSuccess("Duress password disabled.");565 setTimeout(() => setDuressSuccess(null), 4000);566 } else {567 setDuressError(res.error ?? "failed to disable duress password");568 }569 }570571 async function handleSubmitFeedback() {572 if (!fbMessage().trim() || fbSubmitting()) return;573 setFbSubmitting(true);574 setFbResult(null);575 const res = await sendMessage({576 type: "SUBMIT_FEEDBACK",577 payload: {578 category: fbCategory(),579 message: fbMessage().trim(),580 version,581 browser: navigator.userAgent,582 },583 }) as { ok: boolean; error?: string };584 setFbSubmitting(false);585 if (res.ok) {586 setFbResult("success");587 setFbMessage("");588 setTimeout(() => setFbResult(null), 4000);589 } else {590 setFbResult("error");591 }592 }593594 function renderSecurity() {595 return (596 <div class="peach-settings-body">597 <div class="peach-settings-group">598 <p class="peach-settings-sub-label">LOCKING</p>599 <div class="peach-settings-row">600 <span>Lock on close</span>601 <button602 class={`peach-toggle ${props.lockOnClose ? "is-on" : ""}`}603 onClick={() => void props.onLockOnCloseChange(!props.lockOnClose)}604 aria-label="Toggle lock on close"605 >606 <span class="peach-toggle-knob"></span>607 </button>608 </div>609 <div class="peach-settings-row">610 <span>Auto-lock after</span>611 <select612 class="peach-settings-select"613 value={String(props.autoLockMinutes)}614 onChange={(e) => void props.onAutoLockMinutesChange(Number(e.currentTarget.value))}615 >616 <option value="1">1 minute</option>617 <option value="5">5 minutes</option>618 <option value="15">15 minutes</option>619 <option value="30">30 minutes</option>620 <option value="0">Never</option>621 </select>622 </div>623624 <Show when={isNativePlatform() && biometricSupported()}>625 <p class="peach-settings-sub-label">BIOMETRICS</p>626 <div class="peach-settings-row">627 <span>Unlock with biometrics</span>628 <button629 class={`peach-toggle ${biometricStored() ? "is-on" : ""}`}630 onClick={() => void (biometricStored() ? handleDisableBiometrics() : handleEnableBiometrics())}631 disabled={biometricBusy()}632 aria-label="Toggle biometric unlock"633 >634 <span class="peach-toggle-knob"></span>635 </button>636 </div>637 <Show when={biometricError()}>638 <div class="peach-settings-row peach-settings-row--meta">639 <span class="peach-hint peach-hint-error">{biometricError()}</span>640 </div>641 </Show>642 </Show>643644 <p class="peach-settings-sub-label">BREACH PROTECTION</p>645 <div class="peach-settings-row">646 <span>Breach monitoring</span>647 <button648 class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}649 onClick={() => void props.onBreachMonitoringChange(!props.breachMonitoring)}650 aria-label="Toggle breach monitoring"651 >652 <span class="peach-toggle-knob"></span>653 </button>654 </div>655 <div class="peach-settings-row peach-settings-row--meta">656 <span>Check all passwords</span>657 <button658 class="peach-btn peach-btn--subtle peach-btn--sm"659 onClick={() => void handleCheckAllBreaches()}660 disabled={checkingBreaches()}661 >662 {checkingBreaches() ? "checking..." : "run"}663 </button>664 </div>665 <Show when={breachResult()}>666 <div class="peach-settings-row peach-settings-row--meta">667 <span>Last check</span>668 <span class="peach-settings-value">{breachResult()}</span>669 </div>670 </Show>671672 <p class="peach-settings-sub-label">MASTER PASSWORD</p>673 <div class="peach-settings-row peach-settings-row--meta">674 <span>Master password</span>675 <button676 class="peach-btn peach-btn--subtle peach-btn--sm"677 onClick={() => { setShowChangePw((v) => !v); setChangePwError(null); }}678 >679 {showChangePw() ? "cancel" : "change"}680 </button>681 </div>682 <Show when={showChangePw()}>683 <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">684 <input685 class="peach-settings-input"686 type="password"687 placeholder="current password"688 value={changePwCurrent()}689 onInput={(e) => setChangePwCurrent((e.target as HTMLInputElement).value)}690 autocomplete="current-password"691 />692 <input693 class="peach-settings-input"694 type="password"695 placeholder="new password (min 12 chars)"696 value={changePwNew()}697 onInput={(e) => setChangePwNew((e.target as HTMLInputElement).value)}698 autocomplete="new-password"699 />700 <input701 class="peach-settings-input"702 type="password"703 placeholder="confirm new password"704 value={changePwConfirm()}705 onInput={(e) => setChangePwConfirm((e.target as HTMLInputElement).value)}706 autocomplete="new-password"707 onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }}708 />709 <Show when={changePwError()}>710 <p class="peach-hint peach-hint-error" style="margin:0">{changePwError()}</p>711 </Show>712 <Show when={changePwSuccess()}>713 <p class="peach-hint" style="margin:0;color:var(--peach-accent)">password changed ✓</p>714 </Show>715 <button716 class="peach-btn peach-btn--accent peach-btn--sm"717 style="align-self:flex-start"718 onClick={() => void handleChangePassword()}719 disabled={changePwSaving() || !changePwCurrent() || !changePwNew() || !changePwConfirm()}720 >721 {changePwSaving() ? "saving…" : "save new password"}722 </button>723 </div>724 </Show>725726 <p class="peach-settings-sub-label">727 DURESS PASSWORD728 <Show when={!isPro()}>729 <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>730 </Show>731 </p>732 <div class="peach-settings-row peach-settings-row--top">733 <div class="peach-settings-row-main">734 <span>735 Duress password736 <Show when={!isPro()}>737 <span class="peach-pro-badge">Pro</span>738 </Show>739 </span>740 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">741 If entered under coercion, shows a decoy empty vault instead of your real one.742 </span>743 </div>744 <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>745 {duressEnabled() ? "on" : "off"}746 </div>747 </div>748749 <Show when={duressEnabled() && !showDuressSetup()}>750 <div class="peach-settings-row peach-settings-row--meta">751 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">752 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.753 </span>754 </div>755 </Show>756757 <Show when={duressSuccess()}>758 <div class="peach-settings-row peach-settings-row--meta">759 <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>760 </div>761 </Show>762763 <Show when={!showDuressSetup()}>764 <div class="peach-settings-row peach-settings-row--meta">765 <button766 class="peach-btn peach-btn--subtle peach-btn--sm"767 onClick={() => {768 769 770 771 772 773 if (!isPro() && !duressEnabled()) {774 props.onProGate?.("duress_vault");775 setActiveCard("pro");776 return;777 }778 setShowDuressSetup(true);779 setDuressError(null);780 setDuressSuccess(null);781 setDuressPassword("");782 setDuressConfirm("");783 setDuressMainPassword("");784 }}785 >786 {duressEnabled() ? "Change / disable" : "Set up"}787 </button>788 </div>789 </Show>790791 <Show when={showDuressSetup()}>792 <div class="peach-duress-form">793 <label class="peach-label">main password (to confirm)</label>794 <input795 class="peach-settings-input"796 type="password"797 placeholder="your current master password"798 autocomplete="current-password"799 value={duressMainPassword()}800 onInput={(e) => setDuressMainPassword(e.currentTarget.value)}801 />802 <Show when={!duressEnabled()}>803 <label class="peach-label">new duress password</label>804 <input805 class="peach-settings-input"806 type="password"807 placeholder="duress password (min 8 chars)"808 autocomplete="new-password"809 value={duressPassword()}810 onInput={(e) => setDuressPassword(e.currentTarget.value)}811 />812 <label class="peach-label">confirm duress password</label>813 <input814 class="peach-settings-input"815 type="password"816 placeholder="confirm duress password"817 autocomplete="new-password"818 value={duressConfirm()}819 onInput={(e) => setDuressConfirm(e.currentTarget.value)}820 />821 </Show>822823 <Show when={duressError()}>824 <p class="peach-hint peach-hint-error">{duressError()}</p>825 </Show>826827 <div class="peach-duress-actions">828 <button829 class="peach-btn peach-btn--subtle peach-btn--sm"830 onClick={() => { setShowDuressSetup(false); setDuressError(null); }}831 >832 cancel833 </button>834 <Show when={duressEnabled()}>835 <button836 class="peach-btn peach-btn--ghost peach-btn--sm"837 style="color:var(--peach-breach)"838 onClick={() => void handleDisableDuress()}839 disabled={duressSaving()}840 >841 {duressSaving() ? "disabling..." : "disable duress"}842 </button>843 </Show>844 <Show when={!duressEnabled()}>845 <button846 class="peach-btn peach-btn--accent peach-btn--sm"847 onClick={() => void handleSetupDuress()}848 disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}849 >850 {duressSaving() ? "saving..." : "save"}851 </button>852 </Show>853 </div>854 </div>855 </Show>856857 <p class="peach-settings-sub-label">858 CANARY ENTRIES859 <Show when={!isPro()}>860 <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>861 </Show>862 </p>863 <div class="peach-settings-row peach-settings-row--top">864 <div class="peach-settings-row-main">865 <span>866 Canary entries867 <Show when={!isPro()}>868 <span class="peach-pro-badge">Pro</span>869 </Show>870 </span>871 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">872 Silently pings a webhook when an entry is accessed. Configure canary on any entry in your vault.873 </span>874 </div>875 <span class="peach-settings-value" style="flex-shrink:0">876 {canaryEntries().length > 0 ? `${canaryEntries().length} active` : "none"}877 </span>878 </div>879 <Show when={canaryEntries().length > 0}>880 <For each={canaryEntries()}>881 {(entry) => (882 <div class="peach-settings-row peach-settings-row--meta">883 <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0">{entry.title}</span>884 <button885 class="peach-btn peach-btn--subtle peach-btn--sm"886 onClick={() => void handleTestCanary(entry)}887 disabled={canaryTestingId() === entry.id}888 style="flex-shrink:0"889 >890 {canaryTestingId() === entry.id891 ? "sending…"892 : canaryTestResult()?.id === entry.id893 ? (canaryTestResult()!.ok ? "sent ✓" : "failed ✗")894 : "test"}895 </button>896 </div>897 )}898 </For>899 </Show>900901 <p class="peach-settings-sub-label">PASSKEYS</p>902 <div class="peach-settings-row peach-settings-row--top">903 <div class="peach-settings-row-main">904 <span>Intercept passkey requests</span>905 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">906 Peach can handle passkey sign-in and creation on supported Chromium browsers.907 </span>908 </div>909 <button910 class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}911 onClick={() => void handleTogglePasskeys()}912 aria-label="Toggle passkey interception"913 disabled={!passkeyState()?.supported || passkeySaving()}914 >915 <span class="peach-toggle-knob"></span>916 </button>917 </div>918 <div class="peach-settings-row peach-settings-row--meta">919 <span>Status</span>920 <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>921 </div>922 <div class="peach-settings-row peach-settings-row--meta">923 <span>Browser</span>924 <span class="peach-settings-value">925 {passkeyState()?.browserLabel ?? "Unknown"}926 {passkeyState()?.supported ? " ✓" : " (interception unavailable)"}927 </span>928 </div>929 <div class="peach-info-notice" style="margin-top:8px">930 Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.931 </div>932 <Show when={passkeyState()?.conflict}>933 <div class="peach-settings-row peach-settings-row--meta">934 <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>935 </div>936 </Show>937 </div>938 </div>939 );940 }941942 function renderVault() {943 return (944 <div class="peach-settings-body">945 <div class="peach-settings-group">946 <p class="peach-settings-sub-label">ENTRIES</p>947 <div class="peach-settings-row peach-settings-row--meta">948 <span>Entries</span>949 <span class="peach-settings-value">{props.entriesCount}</span>950 </div>951 <div class="peach-settings-row peach-settings-row--meta">952 <span>Duplicates</span>953 <Show when={dedupDone() !== null} fallback={954 <button955 class="peach-btn peach-btn--subtle peach-btn--sm"956 onClick={() => void handleCountDuplicates()}957 disabled={dedupRunning()}958 >959 {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}960 </button>961 }>962 <span class="peach-settings-value" style="color:var(--peach-sync-ok)">963 {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}964 </span>965 </Show>966 </div>967 <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>968 <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">969 <div class="peach-restore-warning">970 {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.971 </div>972 <div style="display:flex;gap:6px">973 <button974 class="peach-btn peach-btn--subtle peach-btn--sm"975 onClick={() => setDedupCount(null)}976 >977 cancel978 </button>979 <button980 class="peach-btn peach-btn--accent peach-btn--sm"981 onClick={() => void handleDeduplicate()}982 disabled={dedupRunning()}983 >984 {dedupRunning() ? "removing..." : "remove duplicates"}985 </button>986 </div>987 </div>988 </Show>989990 <p class="peach-settings-sub-label">MAINTENANCE</p>991 <div class="peach-settings-row peach-settings-row--meta">992 <span>Trash auto-delete</span>993 <select994 class="peach-settings-select"995 value={String(props.trashRetentionDays)}996 onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}997 >998 <option value="7">7 days</option>999 <option value="30">30 days</option>1000 <option value="90">90 days</option>1001 <option value="0">Never</option>1002 </select>1003 </div>1004 <div class="peach-settings-row peach-settings-row--meta">1005 <span>Vault Health</span>1006 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>1007 open1008 </button>1009 </div>1010 <Show when={declinedCardCount() > 0}>1011 <div class="peach-settings-row peach-settings-row--meta">1012 <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>1013 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>1014 Clear1015 </button>1016 </div>1017 </Show>10181019 <p class="peach-settings-sub-label">DATA</p>1020 <div class="peach-settings-row peach-settings-row--meta">1021 <span>Import passwords</span>1022 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>1023 open1024 </button>1025 </div>1026 <div class="peach-settings-row peach-settings-row--meta">1027 <span>Export backup</span>1028 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onExport}>1029 open1030 </button>1031 </div>1032 <div class="peach-settings-row peach-settings-row--meta">1033 <span>1034 Peach Codex1035 <Show when={!isPro()}>1036 <span class="peach-pro-badge">Pro</span>1037 </Show>1038 </span>1039 <button1040 class="peach-btn peach-btn--subtle peach-btn--sm"1041 onClick={() => props.onOpenCodexGenerate?.()}1042 >1043 open1044 </button>1045 </div>1046 {1047}1048 <div class="peach-settings-row peach-settings-row--meta">1049 <span>Restore from Codex</span>1050 <button1051 class="peach-btn peach-btn--subtle peach-btn--sm"1052 onClick={() => props.onOpenCodexRestore?.()}1053 >1054 open1055 </button>1056 </div>10571058 <button1059 class="peach-history-toggle"1060 onClick={() => setHistoryOpen((v) => !v)}1061 aria-expanded={historyOpen()}1062 >1063 <span>VAULT HISTORY</span>1064 <svg1065 class={`peach-history-chevron${historyOpen() ? " is-open" : ""}`}1066 width="10" height="6" viewBox="0 0 10 6" fill="none"1067 stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"1068 >1069 <path d="M1 1L5 5L9 1" />1070 </svg>1071 </button>1072 <Show when={historyOpen()}>1073 <Show when={historyLoading()}>1074 <p class="peach-settings-hint">Loading…</p>1075 </Show>1076 <Show when={!historyLoading() && historySnapshots().length === 0}>1077 <p class="peach-settings-hint">1078 No snapshots yet. Peach saves automatically before every change —1079 saves, syncs, and imports.1080 </p>1081 </Show>1082 <Show when={!historyLoading() && historySnapshots().length > 0}>1083 <p class="peach-settings-hint">1084 Restoring locks your vault. Unlock with the password that1085 encrypted that snapshot to confirm.1086 </p>1087 <div class="peach-history-list">1088 <For each={historySnapshots()}>1089 {(snap) => (1090 <div class="peach-history-row">1091 <div class="peach-history-meta">1092 <span class="peach-history-time">1093 {formatSnapshotTime(snap.savedAt)}1094 </span>1095 <span class="peach-history-sub">1096 {[1097 snap.entryCount !== null1098 ? `${snap.entryCount} ${snap.entryCount === 1 ? "entry" : "entries"}`1099 : null,1100 reasonLabel(snap.reason),1101 ]1102 .filter(Boolean)1103 .join(" · ")}1104 </span>1105 </div>1106 <button1107 class={`peach-btn peach-btn--subtle peach-btn--sm peach-history-btn${1108 historyRestoring() === snap.id ? " is-busy" : ""1109 }`}1110 onClick={() => void handleRestoreVaultSnapshot(snap.id)}1111 disabled={historyRestoring() !== null}1112 >1113 <Show when={historyRestoring() === snap.id} fallback="Restore">1114 …1115 </Show>1116 </button>1117 </div>1118 )}1119 </For>1120 </div>1121 <p class="peach-history-footer">1122 {historySnapshots().length} of 10 snapshots1123 </p>1124 </Show>1125 </Show>11261127 <button1128 class="peach-settings-advanced-toggle"1129 onClick={() => setShowVaultAdvanced((v) => !v)}1130 aria-expanded={showVaultAdvanced()}1131 >1132 <span class="peach-settings-sub-label" style="margin:0">ADVANCED</span>1133 <span class="peach-settings-advanced-chevron">1134 {showVaultAdvanced() ? "▲" : "▼"}1135 </span>1136 </button>11371138 <Show when={showVaultAdvanced()}>1139 <div class="peach-danger-card">1140 <div class="peach-danger-card-head">1141 <span class="peach-danger-card-glyph" aria-hidden="true">⟳</span>1142 <h3 class="peach-danger-card-title">Replace vault from backup</h3>1143 </div>1144 <p class="peach-danger-card-copy">1145 Wipes the current vault and restores from a backup file.1146 Recoverable only if your backup is good.1147 </p>1148 <button1149 class="peach-danger-card-button"1150 onClick={() => props.onOpenFileImport?.()}1151 >1152 Choose backup file1153 </button>1154 </div>11551156 <Show when={props.onOpenPanic}>1157 <div class="peach-danger-card peach-danger-card--hazard">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">Destroy vault</h3>1161 </div>1162 <p class="peach-danger-card-copy">1163 Permanently wipes every entry, setting, and license on this1164 device. There is no recovery.1165 </p>1166 <button1167 class="peach-danger-card-button"1168 onClick={() => props.onOpenPanic?.()}1169 >1170 Begin destruction1171 </button>1172 </div>1173 </Show>1174 </Show>1175 </div>1176 </div>1177 );1178 }11791180 function renderSync() {1181 return (1182 <div class="peach-settings-body">1183 <div class="peach-settings-group">1184 <p class="peach-settings-sub-label">PAIRING</p>1185 <div class="peach-settings-row peach-settings-row--top">1186 <div class="peach-settings-row-main">1187 <span>1188 Peach Sync1189 <Show when={!isPro()}>1190 <span class="peach-pro-badge">Pro</span>1191 </Show>1192 </span>1193 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1194 Pair nearby devices by chirp and keep encrypted vault blobs in step without a central account.1195 </span>1196 </div>1197 <span class={`stk-pip ${isPro() ? "is-on" : ""}`} aria-hidden="true" />1198 </div>1199 <div class="peach-settings-row peach-settings-row--meta">1200 <span>Status</span>1201 <span class="peach-settings-value">{isPro() ? "Ready" : "Requires Pro"}</span>1202 </div>1203 <div class="peach-settings-row peach-settings-row--meta">1204 <span>Sync hub</span>1205 <button1206 class="peach-btn peach-btn--subtle peach-btn--sm"1207 onClick={() => props.onOpenSyncPairing?.()}1208 >1209 open1210 </button>1211 </div>12121213 <p class="peach-settings-sub-label">DEVICE FLOW</p>1214 <div class="peach-settings-row peach-settings-row--top">1215 <div class="peach-settings-row-main">1216 <span>Pair new device</span>1217 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1218 Opens the full chirp pairing console with scan, show-code, and advanced diagnostics.1219 </span>1220 </div>1221 <button1222 class="peach-btn peach-btn--subtle peach-btn--sm"1223 onClick={() => props.onOpenSyncPairing?.()}1224 >1225 pair1226 </button>1227 </div>1228 <div class="peach-info-notice" style="margin-top:8px">1229 Sync uses the same zero-knowledge vault format as backup and restore; pairing authorizes a device, not a cloud account.1230 </div>1231 </div>1232 </div>1233 );1234 }12351236 function renderAppearance() {1237 const sizeOptions: Array<{ id: "small" | "medium" | "large"; label: string }> = [1238 { id: "small", label: "Small" },1239 { id: "medium", label: "Default" },1240 { id: "large", label: "Large" },1241 ];1242 return (1243 <div class="peach-settings-body">1244 <div class="peach-settings-group">1245 <p class="peach-settings-sub-label">THEME</p>1246 <div class="peach-settings-row peach-settings-row--meta">1247 <span>Theme</span>1248 <button1249 class="peach-btn peach-btn--subtle peach-btn--sm"1250 onClick={() =>1251 props.onThemeChange(props.theme === "dark" ? "light" : "dark")1252 }1253 >1254 {props.theme === "dark" ? "Switch to light" : "Switch to dark"}1255 </button>1256 </div>12571258 <p class="peach-settings-sub-label">ACCENT</p>1259 <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">1260 <span>Accent</span>1261 <div class="peach-accent-picker">1262 <For each={ACCENT_COLORS}>1263 {(color) => (1264 <button1265 class="peach-accent-swatch peach-swatch"1266 classList={{ "is-active": props.accentColor === color.name }}1267 style={{ background: color.hex }}1268 onClick={() => void props.onAccentChange(color.name)}1269 aria-label={color.name}1270 title={color.name}1271 />1272 )}1273 </For>1274 </div>1275 </div>12761277 <p class="peach-settings-sub-label">TEXT SIZE</p>1278 <div class="peach-settings-row peach-settings-row--meta">1279 <span>Size</span>1280 <div class="peach-pill-group">1281 <For each={sizeOptions}>1282 {(opt) => (1283 <button1284 class="peach-btn peach-btn--ghost peach-btn--sm"1285 classList={{ "is-active": props.textSize === opt.id }}1286 onClick={() => void props.onTextSizeChange(opt.id)}1287 >1288 {opt.label}1289 </button>1290 )}1291 </For>1292 </div>1293 </div>12941295 <p class="peach-settings-sub-label">MOTION</p>1296 <div class="peach-settings-row peach-settings-row--meta">1297 <span>Reduce motion</span>1298 <button1299 class="peach-btn peach-btn--subtle peach-btn--sm"1300 onClick={() => void props.onReduceMotionChange(!props.reduceMotion)}1301 >1302 {props.reduceMotion ? "On" : "Off"}1303 </button>1304 </div>1305 </div>1306 </div>1307 );1308 }13091310 function renderShortcuts() {1311 const [capturingAction, setCapturingAction] = createSignal<ShortcutAction | null>(null);1312 const [conflictMsg, setConflictMsg] = createSignal<string | null>(null);13131314 function startCapture(action: ShortcutAction) {1315 setConflictMsg(null);1316 setCapturingAction(action);1317 }1318 function cancelCapture() {1319 setCapturingAction(null);1320 setConflictMsg(null);1321 }1322 function commitBinding(action: ShortcutAction, binding: Keybinding) {1323 const conflict = findConflict(props.shortcuts, binding, action);1324 if (conflict) {1325 const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict);1326 setConflictMsg(`Already bound to "${other?.label ?? conflict}"`);1327 return;1328 }1329 const next = { ...props.shortcuts, [action]: binding };1330 setCapturingAction(null);1331 setConflictMsg(null);1332 void props.onShortcutsChange(next);1333 }1334 function resetAction(action: ShortcutAction) {1335 const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] };1336 void props.onShortcutsChange(next);1337 }1338 function resetAll() {1339 void props.onShortcutsChange({ ...DEFAULT_BINDINGS });1340 }13411342 1343 1344 function handleCaptureKey(e: KeyboardEvent) {1345 e.preventDefault();1346 e.stopPropagation();1347 if (e.key === "Escape") { cancelCapture(); return; }1348 const binding = keybindingFromEvent(e);1349 if (!binding) return; 1350 const action = capturingAction();1351 if (action) commitBinding(action, binding);1352 }13531354 return (1355 <div class="peach-settings-body">1356 <div class="peach-settings-group peach-shortcuts-group">1357 <p class="peach-settings-sub-label">ENTRY VIEW</p>1358 <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry")}>1359 {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1360 </For>1361 </div>13621363 <div class="peach-settings-group peach-shortcuts-group">1364 <p class="peach-settings-sub-label">ANYWHERE IN POPUP</p>1365 <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global")}>1366 {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1367 </For>1368 </div>13691370 <Show when={conflictMsg()}>1371 <div class="peach-shortcuts-conflict">{conflictMsg()}</div>1372 </Show>13731374 <div class="peach-settings-group" style="display:flex;flex-direction:column;gap:8px">1375 <div class="peach-settings-row peach-settings-row--meta">1376 <span>Inline autofill on page</span>1377 <a1378 class="peach-settings-link"1379 href="chrome://extensions/shortcuts"1380 onClick={(e) => {1381 e.preventDefault();1382 chrome.tabs.create({ url: "chrome://extensions/shortcuts" });1383 }}1384 >1385 ⌘⇧P — customize ↗1386 </a>1387 </div>1388 <button1389 class="peach-btn peach-btn--subtle peach-btn--sm"1390 style="align-self:flex-start"1391 onClick={resetAll}1392 >1393 Reset all to defaults1394 </button>1395 </div>1396 </div>1397 );13981399 function renderRow(1400 desc: typeof SHORTCUT_DESCRIPTORS[number],1401 capturing: () => ShortcutAction | null,1402 onStart: (a: ShortcutAction) => void,1403 onCancel: () => void,1404 onReset: (a: ShortcutAction) => void,1405 onKey: (e: KeyboardEvent) => void,1406 ) {1407 const isActive = () => capturing() === desc.action;1408 const binding = () => props.shortcuts[desc.action];1409 const isCustom = () => {1410 const b = binding();1411 const d = DEFAULT_BINDINGS[desc.action];1412 return b.key !== d.key1413 || Boolean(b.ctrl) !== Boolean(d.ctrl)1414 || Boolean(b.meta) !== Boolean(d.meta)1415 || Boolean(b.shift) !== Boolean(d.shift)1416 || Boolean(b.alt) !== Boolean(d.alt);1417 };1418 return (1419 <div class="peach-shortcut-row">1420 <span class="peach-shortcut-row-label">{desc.label}</span>1421 <button1422 class="peach-shortcut-chip"1423 classList={{ "is-capturing": isActive(), "is-custom": isCustom() }}1424 onClick={() => isActive() ? onCancel() : onStart(desc.action)}1425 onKeyDown={(e) => { if (isActive()) onKey(e); }}1426 ref={(el) => {1427 1428 if (el) {1429 queueMicrotask(() => { if (isActive()) el.focus(); });1430 }1431 }}1432 >1433 {isActive() ? "Press a key…" : formatKeybinding(binding())}1434 </button>1435 <Show when={isCustom() && !isActive()}>1436 <button1437 class="peach-shortcut-reset"1438 title="Reset to default"1439 onClick={() => onReset(desc.action)}1440 >1441 ↺1442 </button>1443 </Show>1444 </div>1445 );1446 }1447 }14481449 const PRO_FEATURES: Array<{1450 feature: ProFeature;1451 name: string;1452 desc: string;1453 }> = [1454 { feature: "sync", name: "Peach Sync", desc: "Encrypted · peer-to-peer" },1455 { feature: "codex", name: "Peach Codex", desc: "Restore from paper" },1456 { feature: "passkeys", name: "Passkeys", desc: "Zero-knowledge WebAuthn" },1457 { feature: "ios_app", name: "Mobile apps", desc: "Android · iOS soon" },1458 { feature: "advanced_health", name: "Vault health", desc: "Reuse · age · exposure" },1459 { feature: "smart_password_change", name: "Smart reset", desc: "Guided password change" },1460 ];14611462 const PRO_SECURITY_FEATURES: Array<{1463 feature: ProFeature;1464 name: string;1465 desc: string;1466 }> = [1467 { feature: "canary_entry", name: "Canary entries", desc: "Detects vault compromise" },1468 { feature: "duress_vault", name: "Duress vault", desc: "Decoy under coercion" },1469 ];14701471 const [licenseKey, setLicenseKey] = createSignal<string | null>(null);1472 const [showKey, setShowKey] = createSignal(false);14731474 const [deactivating, setDeactivating] = createSignal(false);14751476 async function handleDeactivateLicense() {1477 setDeactivating(true);1478 await sendMessage({ type: "DEACTIVATE_LICENSE" });1479 setLicenseInfo(null);1480 await props.onLicenseChange?.();1481 setDeactivating(false);1482 }14831484 async function handleTestCanary(entry: {id: string; title: string; canary: Canary}) {1485 setCanaryTestingId(entry.id);1486 setCanaryTestResult(null);1487 const res = await sendMessage({1488 type: "FIRE_CANARY_PING",1489 payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger: "test" },1490 }) as { ok: boolean };1491 setCanaryTestResult({ id: entry.id, ok: res.ok });1492 setCanaryTestingId(null);1493 setTimeout(() => setCanaryTestResult((r) => r?.id === entry.id ? null : r), 3000);1494 }14951496 function renderLicense() {1497 async function onActivate() {1498 await handleActivateLicense();1499 if ((licenseInfo()?.valid ?? false) && licenseInfo()?.plan === "pro") {1500 await props.onLicenseChange?.();1501 }1502 }15031504 return (1505 <div class="peach-settings-body">1506 <Show1507 when={isPro()}1508 fallback={1509 <div class="peach-pro-upgrade">1510 <p class="peach-pro-eyebrow">PEACH PRO · ONE-TIME</p>15111512 <h2 class="peach-pro-headline">1513 Every<em>thing,</em><br />every<em>where.</em>1514 </h2>15151516 <p class="peach-pro-subtitle">1517 One key unlocks every Pro feature — for the lifetime of the app.1518 </p>15191520 <div class="peach-pro-grid">1521 <For each={PRO_FEATURES}>1522 {(item, i) => (1523 <div1524 class="peach-pro-grid-item"1525 classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}1526 >1527 <span class="peach-pro-grid-num">{String(i() + 1).padStart(2, "0")}</span>1528 <span class="peach-pro-grid-name">{item.name}</span>1529 <span class="peach-pro-grid-desc">{item.desc}</span>1530 </div>1531 )}1532 </For>1533 </div>15341535 <p class="peach-pro-section-label">ADVANCED SECURITY</p>1536 <div class="peach-pro-grid">1537 <For each={PRO_SECURITY_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(PRO_FEATURES.length + 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 <div class="peach-pro-upgrade-actions">1552 <p class="peach-pro-pricing">1553 $39 <span class="peach-pro-pricing-sub">once · yours forever</span>1554 </p>1555 <a1556 href="https://peachpasswords.com/#pricing"1557 target="_blank"1558 rel="noopener noreferrer"1559 class="peach-pro-upgrade-cta"1560 >1561 Get Peach Pro1562 </a>1563 <div class="peach-pro-key-divider"><span>ALREADY HAVE A KEY</span></div>1564 <div class="peach-pro-key-inline">1565 <input1566 class="peach-pro-key-inline-input"1567 type="text"1568 placeholder="PEACH-XXXX-XXXX-XXXX"1569 value={licenseKeyInput()}1570 onInput={(e) => setLicenseKeyInput(e.currentTarget.value)}1571 onKeyDown={(e) => { if (e.key === "Enter") void onActivate(); }}1572 autocomplete="off"1573 spellcheck={false}1574 />1575 <button1576 class="peach-pro-key-apply"1577 onClick={() => void onActivate()}1578 disabled={activatingLicense() || !licenseKeyInput().trim()}1579 >1580 {activatingLicense() ? "…" : "APPLY"}1581 </button>1582 </div>1583 <Show when={licenseError()}>1584 <p class="peach-pro-key-error">{licenseError()}</p>1585 </Show>1586 </div>1587 </div>1588 }1589 >1590 <div class="peach-pro-active">1591 <div class="peach-pro-active-hero">1592 <div class="peach-pro-active-mark-wrap">1593 <PeachMark class="peach-pro-active-mark" />1594 </div>1595 <h2 class="peach-pro-active-title">Peach Pro</h2>1596 <p class="peach-pro-active-since">{formatActivatedSince(licenseInfo()?.issued)}</p>1597 </div>15981599 <Show when={licenseKey()}>1600 <div class="peach-settings-license">1601 <span class="peach-settings-label">LICENSE KEY</span>1602 <div class="peach-settings-license-row">1603 <Show1604 when={showKey()}1605 fallback={1606 <span class="peach-settings-license-key peach-settings-license-key--masked" aria-label="License key hidden">1607 ••••••••••••••••••••••••••••1608 </span>1609 }1610 >1611 <span class="peach-settings-license-key">{licenseKey()!}</span>1612 </Show>1613 <div class="peach-settings-license-actions">1614 <button1615 class="peach-settings-license-copy"1616 title="Copy license key"1617 onClick={() => copyLicenseKey(licenseKey()!)}1618 >1619 {copied() ? "✓" : "copy"}1620 </button>1621 <button1622 class="peach-settings-license-toggle"1623 aria-label={showKey() ? "Hide license key" : "Reveal license key"}1624 title={showKey() ? "Hide" : "Reveal"}1625 onClick={() => setShowKey(v => !v)}1626 >1627 <Show1628 when={showKey()}1629 fallback={1630 <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">1631 <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />1632 <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />1633 </svg>1634 }1635 >1636 <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">1637 <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />1638 <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />1639 <line x1="2" y1="2" x2="12" y2="12" />1640 </svg>1641 </Show>1642 </button>1643 </div>1644 </div>1645 </div>1646 </Show>16471648 <div class="peach-pro-active-features">1649 <For each={[...PRO_FEATURES, ...PRO_SECURITY_FEATURES]}>1650 {(item) => (1651 <div class="peach-pro-active-feature">1652 <span class="peach-pro-active-feature-mark">✦</span>1653 <span class="peach-pro-active-feature-name">{item.name}</span>1654 </div>1655 )}1656 </For>1657 </div>16581659 <button1660 class="peach-pro-deactivate"1661 onClick={() => void handleDeactivateLicense()}1662 disabled={deactivating()}1663 >1664 {deactivating() ? "Deactivating…" : "Deactivate"}1665 </button>1666 </div>1667 </Show>1668 </div>1669 );1670 }16711672 function renderFeedback() {1673 return (1674 <div class="peach-settings-body">1675 <div class="peach-settings-group peach-feedback-group">1676 <p class="peach-feedback-intro">1677 Help us improve Peach. Every report goes directly to our inbox.1678 </p>16791680 <p class="peach-settings-sub-label">TYPE</p>1681 <div class="peach-feedback-chips">1682 <For each={FEEDBACK_CATEGORIES}>1683 {(cat) => (1684 <button1685 class={`peach-feedback-chip ${fbCategory() === cat ? "is-active" : ""}`}1686 onClick={() => setFbCategory(cat)}1687 >1688 {cat}1689 </button>1690 )}1691 </For>1692 </div>16931694 <p class="peach-settings-sub-label">MESSAGE</p>1695 <textarea1696 class="peach-feedback-textarea"1697 placeholder="Describe the issue or idea..."1698 value={fbMessage()}1699 onInput={(e) => setFbMessage(e.currentTarget.value)}1700 />17011702 <p class="peach-feedback-meta">1703 Version {version} · will be included automatically1704 </p>17051706 <Show when={fbResult() === "success"}>1707 <p class="peach-feedback-result peach-feedback-result--ok">1708 Sent — thank you1709 </p>1710 </Show>1711 <Show when={fbResult() === "error"}>1712 <p class="peach-feedback-result peach-feedback-result--err">1713 Failed to send — try again1714 </p>1715 </Show>17161717 <button1718 class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"1719 onClick={() => void handleSubmitFeedback()}1720 disabled={fbSubmitting() || !fbMessage().trim()}1721 >1722 {fbSubmitting() ? "Sending..." : "Send feedback"}1723 </button>17241725 <p class="peach-feedback-anon">1726 Sent anonymously · no account required1727 </p>1728 </div>1729 </div>1730 );1731 }17321733 const bodies: StackBodies = {1734 protection: renderSecurity(),1735 vault: renderVault(),1736 sync: renderSync(),1737 appearance: renderAppearance(),1738 shortcuts: renderShortcuts(),1739 pro: renderLicense(),1740 feedback: renderFeedback(),1741 };17421743 return (1744 <Show1745 when={showNewRecoveryWords()}1746 fallback={1747 <div1748 class="peach-overlay-view"1749 classList={{ "ov-enter-right": !props.isExiting, "ov-exit-right": !!props.isExiting }}1750 style="display:flex;flex-direction:column"1751 >1752 <StackDeck1753 active={activeCard()}1754 onActiveChange={setActiveCard}1755 onBack={props.onBack}1756 cards={cards()}1757 bodies={bodies}1758 />1759 </div>1760 }1761 >1762 <RecoveryWordDisplayView onDone={() => setShowNewRecoveryWords(false)} />1763 </Show>1764 );1765}