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);205206 async function loadVaultHistory() {207 setHistoryLoading(true);208 try {209 const res = await sendMessage({ type: "GET_VAULT_HISTORY" });210 const list = (res as { ok: boolean; data?: VaultSnapshot[] }).data ?? [];211 setHistorySnapshots(list);212 } finally {213 setHistoryLoading(false);214 }215 }216217 async function handleRestoreVaultSnapshot(id: string) {218 if (historyRestoring()) return;219 setHistoryRestoring(id);220 const res = await sendMessage({221 type: "RESTORE_VAULT_SNAPSHOT",222 payload: { snapshotId: id },223 });224 setHistoryRestoring(null);225 if ((res as { ok: boolean }).ok) {226 227 228 props.onBack();229 }230 }231232 function formatSnapshotTime(savedAt: number): string {233 const diff = Date.now() - savedAt;234 if (diff < 60_000) return "Just now";235 if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;236 const d = new Date(savedAt);237 const isToday = new Date().toDateString() === d.toDateString();238 const isYest =239 new Date(Date.now() - 86_400_000).toDateString() === d.toDateString();240 const t = d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });241 if (isToday) return `Today ${t}`;242 if (isYest) return `Yesterday ${t}`;243 return `${d.toLocaleDateString([], { month: "short", day: "numeric" })} ${t}`;244 }245246 function reasonLabel(reason: VaultSnapshot["reason"]): string {247 return (248 {249 local_save: "saved",250 sync_received: "synced",251 import: "imported",252 restore: "restored",253 } as const254 )[reason] ?? reason;255 }256257 const [showVaultAdvanced, setShowVaultAdvanced] = createSignal(false);258259 const [showChangePw, setShowChangePw] = createSignal(false);260 const [changePwCurrent, setChangePwCurrent] = createSignal("");261 const [changePwNew, setChangePwNew] = createSignal("");262 const [changePwConfirm, setChangePwConfirm] = createSignal("");263 const [changePwSaving, setChangePwSaving] = createSignal(false);264 const [changePwError, setChangePwError] = createSignal<string | null>(null);265 const [changePwSuccess] = createSignal(false);266 const [showNewRecoveryWords, setShowNewRecoveryWords] = createSignal(false);267268 const [duressEnabled, setDuressEnabled] = createSignal(false);269 const [showDuressSetup, setShowDuressSetup] = createSignal(false);270 const [duressPassword, setDuressPassword] = createSignal("");271 const [duressConfirm, setDuressConfirm] = createSignal("");272 const [duressMainPassword, setDuressMainPassword] = createSignal("");273 const [duressSaving, setDuressSaving] = createSignal(false);274 const [duressError, setDuressError] = createSignal<string | null>(null);275 const [duressSuccess, setDuressSuccess] = createSignal<string | null>(null);276277 const [canaryEntries, setCanaryEntries] = createSignal<Array<{id: string; title: string; canary: Canary}>>([]);278279 const [canaryTestingId, setCanaryTestingId] = createSignal<string | null>(null);280 const [canaryTestResult, setCanaryTestResult] = createSignal<{id: string; ok: boolean} | null>(null);281282 const [fbCategory, setFbCategory] = createSignal("Bug report");283 const [fbMessage, setFbMessage] = createSignal("");284 const [fbSubmitting, setFbSubmitting] = createSignal(false);285 const [fbResult, setFbResult] = createSignal<"success" | "error" | null>(null);286287 createEffect(() => {288 if (props.upgradeHighlight) {289 setActiveCard("pro");290 }291 });292293 onMount(async () => {294 const [licenseRes, passkeyRes, duressRes, settingsRes, keyRes] = await Promise.all([295 sendMessage({ type: "GET_LICENSE" }),296 sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }),297 sendMessage({ type: "IS_DURESS_ENABLED" }),298 sendMessage({ type: "GET_SETTINGS" }),299 sendMessage({ type: "GET_LICENSE_KEY" }),300 ]);301302 303 304 void loadVaultHistory();305306 if ((licenseRes as { ok: boolean }).ok) {307 setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null);308 }309310 if ((passkeyRes as { ok: boolean }).ok) {311 setPasskeyState(312 (passkeyRes as { ok: boolean; data?: PasskeyProviderState }).data ?? null313 );314 }315316 if ((duressRes as { ok: boolean }).ok) {317 setDuressEnabled((duressRes as { ok: boolean; data?: boolean }).data ?? false);318 }319320 if ((settingsRes as { ok: boolean }).ok) {321 const sData = (settingsRes as { ok: boolean; data?: { declinedCardFingerprints?: string[] } }).data;322 setDeclinedCardCount(sData?.declinedCardFingerprints?.length ?? 0);323 }324325 if ((keyRes as { ok: boolean }).ok) {326 setLicenseKey((keyRes as { ok: boolean; data?: string }).data ?? null);327 }328329 const entriesRes = await sendMessage({ type: "GET_ENTRIES" });330 if ((entriesRes as { ok: boolean }).ok) {331 const entries = (entriesRes as { ok: boolean; data?: Array<{id: string; title: string; canary?: Canary}> }).data ?? [];332 setCanaryEntries(333 entries334 .filter((e) => e.canary?.webhookUrl)335 .map((e) => ({ id: e.id, title: e.title, canary: e.canary! }))336 );337 }338339340 if (isNativePlatform()) {341 const biometric = nativePlugin<{ isAvailable: () => Promise<{ available: boolean }> }>("BiometricPlugin");342 const keystore = nativePlugin<{ hasMasterKey: () => Promise<{ exists: boolean }> }>("KeystorePlugin");343 try {344 if (biometric) {345 const a = await biometric.isAvailable();346 setBiometricSupported(a.available);347 }348 if (keystore) {349 const k = await keystore.hasMasterKey();350 setBiometricStored(k.exists);351 }352 } catch { }353 }354 });355356 async function handleEnableBiometrics() {357 setBiometricBusy(true);358 setBiometricError(null);359 try {360 const keyRes = (await sendMessage({ type: "GET_DERIVED_KEY" })) as {361 ok: boolean;362 data?: { masterKeyHex: string };363 error?: string;364 };365 if (!keyRes.ok || !keyRes.data?.masterKeyHex) {366 setBiometricError(keyRes.error === "vault_locked"367 ? "unlock the vault first"368 : "couldn't read derived key");369 return;370 }371 const keystore = nativePlugin<{ storeMasterKey: (p: { masterKeyHex: string }) => Promise<void> }>("KeystorePlugin");372 if (!keystore) {373 setBiometricError("biometric plugin unavailable");374 return;375 }376 await keystore.storeMasterKey({ masterKeyHex: keyRes.data.masterKeyHex });377 await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: true } });378 setBiometricStored(true);379 } catch (e) {380 setBiometricError(e instanceof Error ? e.message : "failed to enable biometrics");381 } finally {382 setBiometricBusy(false);383 }384 }385386 async function handleDisableBiometrics() {387 setBiometricBusy(true);388 setBiometricError(null);389 try {390 const keystore = nativePlugin<{ clearMasterKey: () => Promise<void> }>("KeystorePlugin");391 if (keystore) await keystore.clearMasterKey();392 await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: false } });393 setBiometricStored(false);394 } catch (e) {395 setBiometricError(e instanceof Error ? e.message : "failed to disable biometrics");396 } finally {397 setBiometricBusy(false);398 }399 }400401 async function handleChangePassword() {402 setChangePwError(null);403 if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; }404 if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; }405 if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; }406 if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; }407 setChangePwSaving(true);408 const res = await sendMessage({409 type: "CHANGE_PASSWORD",410 payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() },411 }) as { ok: boolean; error?: string };412 if (res.ok) {413 setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");414 setShowChangePw(false);415 setShowNewRecoveryWords(true);416 } else {417 setChangePwError(res.error ?? "failed to change password");418 }419 setChangePwSaving(false);420 }421422 const [copied, setCopied] = createSignal(false);423424 async function copyLicenseKey(key: string) {425 await navigator.clipboard.writeText(key);426 setCopied(true);427 setTimeout(() => setCopied(false), 2000);428 }429430 async function handleActivateLicense() {431 const key = licenseKeyInput().trim();432 if (!key) return;433 setActivatingLicense(true);434 setLicenseError(null);435 setLicenseSuccess(null);436 const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } });437 setActivatingLicense(false);438 if ((res as { ok: boolean }).ok) {439 const data = (res as { ok: boolean; data?: LicenseInfo }).data;440 if (data?.valid) {441 setLicenseInfo(data);442 setLicenseKey(key);443 setLicenseSuccess("License activated!");444 setLicenseKeyInput("");445 } else {446 setLicenseError("Invalid license key");447 }448 } else {449 setLicenseError("Activation failed");450 }451 }452453 async function handleTogglePasskeys() {454 const current = passkeyState();455 if (!current || !current.supported) return;456 setPasskeySaving(true);457 const res = await sendMessage({458 type: "SET_PASSKEY_PROVIDER_ENABLED",459 payload: { enabled: !current.enabled },460 });461 setPasskeySaving(false);462 if ((res as { ok: boolean }).ok) {463 setPasskeyState(464 (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null465 );466 }467 }468469 async function handleCheckAllBreaches() {470 setCheckingBreaches(true);471 setBreachResult(null);472 const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });473 setCheckingBreaches(false);474 if ((res as { ok: boolean }).ok) {475 const data = (res as { ok: boolean; data?: Record<string, number> }).data ?? {};476 const breached = Object.values(data).filter((count) => count > 0).length;477 setBreachResult(478 breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean"479 );480 props.onBreachCheckDone();481 }482 }483484485 async function handleSetupDuress() {486 const mainPw = duressMainPassword().trim();487 const duressPw = duressPassword().trim();488 const confirmPw = duressConfirm().trim();489490 if (!mainPw) { setDuressError("enter your main password to confirm"); return; }491 if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; }492 if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; }493 if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; }494495 setDuressSaving(true);496 setDuressError(null);497498 const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };499 if (!verifyRes.ok || !verifyRes.data) {500 setDuressError("incorrect main password");501 setDuressSaving(false);502 return;503 }504505 const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string };506 setDuressSaving(false);507 if (res.ok) {508 setDuressEnabled(true);509 setShowDuressSetup(false);510 setDuressPassword("");511 setDuressConfirm("");512 setDuressMainPassword("");513 setDuressSuccess("Duress password set. If entered, a decoy vault will be shown.");514 setTimeout(() => setDuressSuccess(null), 5000);515 } else {516 setDuressError(res.error ?? "failed to set duress password");517 }518 }519520 async function handleClearDeclinedCards() {521 await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } });522 setDeclinedCardCount(0);523 }524525 async function handleCountDuplicates() {526 setDedupRunning(true);527 setDedupDone(null);528 const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number };529 setDedupRunning(false);530 if (res.ok) setDedupCount(res.data ?? 0);531 }532533 async function handleDeduplicate() {534 setDedupRunning(true);535 const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number };536 setDedupRunning(false);537 if (res.ok) {538 setDedupDone(res.data ?? 0);539 setDedupCount(0);540 }541 }542543 async function handleDisableDuress() {544 const mainPw = duressMainPassword().trim();545 if (!mainPw) { setDuressError("enter your main password to confirm"); return; }546547 setDuressSaving(true);548 setDuressError(null);549550 const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };551 if (!verifyRes.ok || !verifyRes.data) {552 setDuressError("incorrect main password");553 setDuressSaving(false);554 return;555 }556557 const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string };558 setDuressSaving(false);559 if (res.ok) {560 setDuressEnabled(false);561 setShowDuressSetup(false);562 setDuressMainPassword("");563 setDuressSuccess("Duress password disabled.");564 setTimeout(() => setDuressSuccess(null), 4000);565 } else {566 setDuressError(res.error ?? "failed to disable duress password");567 }568 }569570 async function handleSubmitFeedback() {571 if (!fbMessage().trim() || fbSubmitting()) return;572 setFbSubmitting(true);573 setFbResult(null);574 const res = await sendMessage({575 type: "SUBMIT_FEEDBACK",576 payload: {577 category: fbCategory(),578 message: fbMessage().trim(),579 version,580 browser: navigator.userAgent,581 },582 }) as { ok: boolean; error?: string };583 setFbSubmitting(false);584 if (res.ok) {585 setFbResult("success");586 setFbMessage("");587 setTimeout(() => setFbResult(null), 4000);588 } else {589 setFbResult("error");590 }591 }592593 function renderSecurity() {594 return (595 <div class="peach-settings-body">596 <div class="peach-settings-group">597 <p class="peach-settings-sub-label">LOCKING</p>598 <div class="peach-settings-row">599 <span>Lock on close</span>600 <button601 class={`peach-toggle ${props.lockOnClose ? "is-on" : ""}`}602 onClick={() => void props.onLockOnCloseChange(!props.lockOnClose)}603 aria-label="Toggle lock on close"604 >605 <span class="peach-toggle-knob"></span>606 </button>607 </div>608 <div class="peach-settings-row">609 <span>Auto-lock after</span>610 <select611 class="peach-settings-select"612 value={String(props.autoLockMinutes)}613 onChange={(e) => void props.onAutoLockMinutesChange(Number(e.currentTarget.value))}614 >615 <option value="1">1 minute</option>616 <option value="5">5 minutes</option>617 <option value="15">15 minutes</option>618 <option value="30">30 minutes</option>619 <option value="0">Never</option>620 </select>621 </div>622623 <Show when={isNativePlatform() && biometricSupported()}>624 <p class="peach-settings-sub-label">BIOMETRICS</p>625 <div class="peach-settings-row">626 <span>Unlock with biometrics</span>627 <button628 class={`peach-toggle ${biometricStored() ? "is-on" : ""}`}629 onClick={() => void (biometricStored() ? handleDisableBiometrics() : handleEnableBiometrics())}630 disabled={biometricBusy()}631 aria-label="Toggle biometric unlock"632 >633 <span class="peach-toggle-knob"></span>634 </button>635 </div>636 <Show when={biometricError()}>637 <div class="peach-settings-row peach-settings-row--meta">638 <span class="peach-hint peach-hint-error">{biometricError()}</span>639 </div>640 </Show>641 </Show>642643 <p class="peach-settings-sub-label">BREACH PROTECTION</p>644 <div class="peach-settings-row">645 <span>Breach monitoring</span>646 <button647 class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}648 onClick={() => void props.onBreachMonitoringChange(!props.breachMonitoring)}649 aria-label="Toggle breach monitoring"650 >651 <span class="peach-toggle-knob"></span>652 </button>653 </div>654 <div class="peach-settings-row peach-settings-row--meta">655 <span>Check all passwords</span>656 <button657 class="peach-btn peach-btn--subtle peach-btn--sm"658 onClick={() => void handleCheckAllBreaches()}659 disabled={checkingBreaches()}660 >661 {checkingBreaches() ? "checking..." : "run"}662 </button>663 </div>664 <Show when={breachResult()}>665 <div class="peach-settings-row peach-settings-row--meta">666 <span>Last check</span>667 <span class="peach-settings-value">{breachResult()}</span>668 </div>669 </Show>670671 <p class="peach-settings-sub-label">MASTER PASSWORD</p>672 <div class="peach-settings-row peach-settings-row--meta">673 <span>Master password</span>674 <button675 class="peach-btn peach-btn--subtle peach-btn--sm"676 onClick={() => { setShowChangePw((v) => !v); setChangePwError(null); }}677 >678 {showChangePw() ? "cancel" : "change"}679 </button>680 </div>681 <Show when={showChangePw()}>682 <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">683 <input684 class="peach-settings-input"685 type="password"686 placeholder="current password"687 value={changePwCurrent()}688 onInput={(e) => setChangePwCurrent((e.target as HTMLInputElement).value)}689 autocomplete="current-password"690 />691 <input692 class="peach-settings-input"693 type="password"694 placeholder="new password (min 12 chars)"695 value={changePwNew()}696 onInput={(e) => setChangePwNew((e.target as HTMLInputElement).value)}697 autocomplete="new-password"698 />699 <input700 class="peach-settings-input"701 type="password"702 placeholder="confirm new password"703 value={changePwConfirm()}704 onInput={(e) => setChangePwConfirm((e.target as HTMLInputElement).value)}705 autocomplete="new-password"706 onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }}707 />708 <Show when={changePwError()}>709 <p class="peach-hint peach-hint-error" style="margin:0">{changePwError()}</p>710 </Show>711 <Show when={changePwSuccess()}>712 <p class="peach-hint" style="margin:0;color:var(--peach-accent)">password changed ✓</p>713 </Show>714 <button715 class="peach-btn peach-btn--accent peach-btn--sm"716 style="align-self:flex-start"717 onClick={() => void handleChangePassword()}718 disabled={changePwSaving() || !changePwCurrent() || !changePwNew() || !changePwConfirm()}719 >720 {changePwSaving() ? "saving…" : "save new password"}721 </button>722 </div>723 </Show>724725 <p class="peach-settings-sub-label">726 DURESS PASSWORD727 <Show when={!isPro()}>728 <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>729 </Show>730 </p>731 <div class="peach-settings-row peach-settings-row--top">732 <div class="peach-settings-row-main">733 <span>734 Duress password735 <Show when={!isPro()}>736 <span class="peach-pro-badge">Pro</span>737 </Show>738 </span>739 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">740 If entered under coercion, shows a decoy empty vault instead of your real one.741 </span>742 </div>743 <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>744 {duressEnabled() ? "on" : "off"}745 </div>746 </div>747748 <Show when={duressEnabled() && !showDuressSetup()}>749 <div class="peach-settings-row peach-settings-row--meta">750 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">751 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.752 </span>753 </div>754 </Show>755756 <Show when={duressSuccess()}>757 <div class="peach-settings-row peach-settings-row--meta">758 <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>759 </div>760 </Show>761762 <Show when={!showDuressSetup()}>763 <div class="peach-settings-row peach-settings-row--meta">764 <button765 class="peach-btn peach-btn--subtle peach-btn--sm"766 onClick={() => {767 768 769 770 771 772 if (!isPro() && !duressEnabled()) {773 props.onProGate?.("duress_vault");774 setActiveCard("pro");775 return;776 }777 setShowDuressSetup(true);778 setDuressError(null);779 setDuressSuccess(null);780 setDuressPassword("");781 setDuressConfirm("");782 setDuressMainPassword("");783 }}784 >785 {duressEnabled() ? "Change / disable" : "Set up"}786 </button>787 </div>788 </Show>789790 <Show when={showDuressSetup()}>791 <div class="peach-duress-form">792 <label class="peach-label">main password (to confirm)</label>793 <input794 class="peach-settings-input"795 type="password"796 placeholder="your current master password"797 autocomplete="current-password"798 value={duressMainPassword()}799 onInput={(e) => setDuressMainPassword(e.currentTarget.value)}800 />801 <Show when={!duressEnabled()}>802 <label class="peach-label">new duress password</label>803 <input804 class="peach-settings-input"805 type="password"806 placeholder="duress password (min 8 chars)"807 autocomplete="new-password"808 value={duressPassword()}809 onInput={(e) => setDuressPassword(e.currentTarget.value)}810 />811 <label class="peach-label">confirm duress password</label>812 <input813 class="peach-settings-input"814 type="password"815 placeholder="confirm duress password"816 autocomplete="new-password"817 value={duressConfirm()}818 onInput={(e) => setDuressConfirm(e.currentTarget.value)}819 />820 </Show>821822 <Show when={duressError()}>823 <p class="peach-hint peach-hint-error">{duressError()}</p>824 </Show>825826 <div class="peach-duress-actions">827 <button828 class="peach-btn peach-btn--subtle peach-btn--sm"829 onClick={() => { setShowDuressSetup(false); setDuressError(null); }}830 >831 cancel832 </button>833 <Show when={duressEnabled()}>834 <button835 class="peach-btn peach-btn--ghost peach-btn--sm"836 style="color:var(--peach-breach)"837 onClick={() => void handleDisableDuress()}838 disabled={duressSaving()}839 >840 {duressSaving() ? "disabling..." : "disable duress"}841 </button>842 </Show>843 <Show when={!duressEnabled()}>844 <button845 class="peach-btn peach-btn--accent peach-btn--sm"846 onClick={() => void handleSetupDuress()}847 disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}848 >849 {duressSaving() ? "saving..." : "save"}850 </button>851 </Show>852 </div>853 </div>854 </Show>855856 <p class="peach-settings-sub-label">857 CANARY ENTRIES858 <Show when={!isPro()}>859 <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>860 </Show>861 </p>862 <div class="peach-settings-row peach-settings-row--top">863 <div class="peach-settings-row-main">864 <span>865 Canary entries866 <Show when={!isPro()}>867 <span class="peach-pro-badge">Pro</span>868 </Show>869 </span>870 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">871 Silently pings a webhook when an entry is accessed. Configure canary on any entry in your vault.872 </span>873 </div>874 <span class="peach-settings-value" style="flex-shrink:0">875 {canaryEntries().length > 0 ? `${canaryEntries().length} active` : "none"}876 </span>877 </div>878 <Show when={canaryEntries().length > 0}>879 <For each={canaryEntries()}>880 {(entry) => (881 <div class="peach-settings-row peach-settings-row--meta">882 <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0">{entry.title}</span>883 <button884 class="peach-btn peach-btn--subtle peach-btn--sm"885 onClick={() => void handleTestCanary(entry)}886 disabled={canaryTestingId() === entry.id}887 style="flex-shrink:0"888 >889 {canaryTestingId() === entry.id890 ? "sending…"891 : canaryTestResult()?.id === entry.id892 ? (canaryTestResult()!.ok ? "sent ✓" : "failed ✗")893 : "test"}894 </button>895 </div>896 )}897 </For>898 </Show>899900 <p class="peach-settings-sub-label">PASSKEYS</p>901 <div class="peach-settings-row peach-settings-row--top">902 <div class="peach-settings-row-main">903 <span>Intercept passkey requests</span>904 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">905 Peach can handle passkey sign-in and creation on supported Chromium browsers.906 </span>907 </div>908 <button909 class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}910 onClick={() => void handleTogglePasskeys()}911 aria-label="Toggle passkey interception"912 disabled={!passkeyState()?.supported || passkeySaving()}913 >914 <span class="peach-toggle-knob"></span>915 </button>916 </div>917 <div class="peach-settings-row peach-settings-row--meta">918 <span>Status</span>919 <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>920 </div>921 <div class="peach-settings-row peach-settings-row--meta">922 <span>Browser</span>923 <span class="peach-settings-value">924 {passkeyState()?.browserLabel ?? "Unknown"}925 {passkeyState()?.supported ? " ✓" : " (interception unavailable)"}926 </span>927 </div>928 <div class="peach-info-notice" style="margin-top:8px">929 Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.930 </div>931 <Show when={passkeyState()?.conflict}>932 <div class="peach-settings-row peach-settings-row--meta">933 <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>934 </div>935 </Show>936 </div>937 </div>938 );939 }940941 function renderVault() {942 return (943 <div class="peach-settings-body">944 <div class="peach-settings-group">945 <p class="peach-settings-sub-label">ENTRIES</p>946 <div class="peach-settings-row peach-settings-row--meta">947 <span>Entries</span>948 <span class="peach-settings-value">{props.entriesCount}</span>949 </div>950 <div class="peach-settings-row peach-settings-row--meta">951 <span>Duplicates</span>952 <Show when={dedupDone() !== null} fallback={953 <button954 class="peach-btn peach-btn--subtle peach-btn--sm"955 onClick={() => void handleCountDuplicates()}956 disabled={dedupRunning()}957 >958 {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}959 </button>960 }>961 <span class="peach-settings-value" style="color:var(--peach-sync-ok)">962 {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}963 </span>964 </Show>965 </div>966 <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>967 <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">968 <div class="peach-restore-warning">969 {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.970 </div>971 <div style="display:flex;gap:6px">972 <button973 class="peach-btn peach-btn--subtle peach-btn--sm"974 onClick={() => setDedupCount(null)}975 >976 cancel977 </button>978 <button979 class="peach-btn peach-btn--accent peach-btn--sm"980 onClick={() => void handleDeduplicate()}981 disabled={dedupRunning()}982 >983 {dedupRunning() ? "removing..." : "remove duplicates"}984 </button>985 </div>986 </div>987 </Show>988989 <p class="peach-settings-sub-label">MAINTENANCE</p>990 <div class="peach-settings-row peach-settings-row--meta">991 <span>Trash auto-delete</span>992 <select993 class="peach-settings-select"994 value={String(props.trashRetentionDays)}995 onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}996 >997 <option value="7">7 days</option>998 <option value="30">30 days</option>999 <option value="90">90 days</option>1000 <option value="0">Never</option>1001 </select>1002 </div>1003 <div class="peach-settings-row peach-settings-row--meta">1004 <span>Vault Health</span>1005 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>1006 open1007 </button>1008 </div>1009 <Show when={declinedCardCount() > 0}>1010 <div class="peach-settings-row peach-settings-row--meta">1011 <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>1012 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>1013 Clear1014 </button>1015 </div>1016 </Show>10171018 <p class="peach-settings-sub-label">DATA</p>1019 <div class="peach-settings-row peach-settings-row--meta">1020 <span>Import passwords</span>1021 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>1022 open1023 </button>1024 </div>1025 <div class="peach-settings-row peach-settings-row--meta">1026 <span>Export backup</span>1027 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onExport}>1028 open1029 </button>1030 </div>1031 <div class="peach-settings-row peach-settings-row--meta">1032 <span>1033 Peach Codex1034 <Show when={!isPro()}>1035 <span class="peach-pro-badge">Pro</span>1036 </Show>1037 </span>1038 <button1039 class="peach-btn peach-btn--subtle peach-btn--sm"1040 onClick={() => props.onOpenCodexGenerate?.()}1041 >1042 open1043 </button>1044 </div>1045 {1046}1047 <div class="peach-settings-row peach-settings-row--meta">1048 <span>Restore from Codex</span>1049 <button1050 class="peach-btn peach-btn--subtle peach-btn--sm"1051 onClick={() => props.onOpenCodexRestore?.()}1052 >1053 open1054 </button>1055 </div>10561057 <p class="peach-settings-sub-label">VAULT HISTORY</p>1058 <Show when={historyLoading()}>1059 <p class="peach-settings-hint">Loading…</p>1060 </Show>1061 <Show when={!historyLoading() && historySnapshots().length === 0}>1062 <p class="peach-settings-hint">1063 No snapshots yet. Peach saves automatically before every change —1064 saves, syncs, and imports.1065 </p>1066 </Show>1067 <Show when={!historyLoading() && historySnapshots().length > 0}>1068 <p class="peach-settings-hint">1069 Restoring locks your vault. Unlock with the password that1070 encrypted that snapshot to confirm.1071 </p>1072 <div class="peach-history-list">1073 <For each={historySnapshots()}>1074 {(snap) => (1075 <div class="peach-history-row">1076 <div class="peach-history-meta">1077 <span class="peach-history-time">1078 {formatSnapshotTime(snap.savedAt)}1079 </span>1080 <span class="peach-history-sub">1081 {[1082 snap.entryCount !== null1083 ? `${snap.entryCount} ${snap.entryCount === 1 ? "entry" : "entries"}`1084 : null,1085 reasonLabel(snap.reason),1086 ]1087 .filter(Boolean)1088 .join(" · ")}1089 </span>1090 </div>1091 <button1092 class={`peach-btn peach-btn--subtle peach-btn--sm peach-history-btn${1093 historyRestoring() === snap.id ? " is-busy" : ""1094 }`}1095 onClick={() => void handleRestoreVaultSnapshot(snap.id)}1096 disabled={historyRestoring() !== null}1097 >1098 <Show when={historyRestoring() === snap.id} fallback="Restore">1099 …1100 </Show>1101 </button>1102 </div>1103 )}1104 </For>1105 </div>1106 <p class="peach-history-footer">1107 {historySnapshots().length} of 10 snapshots1108 </p>1109 </Show>11101111 <button1112 class="peach-settings-advanced-toggle"1113 onClick={() => setShowVaultAdvanced((v) => !v)}1114 aria-expanded={showVaultAdvanced()}1115 >1116 <span class="peach-settings-sub-label" style="margin:0">ADVANCED</span>1117 <span class="peach-settings-advanced-chevron">1118 {showVaultAdvanced() ? "▲" : "▼"}1119 </span>1120 </button>11211122 <Show when={showVaultAdvanced()}>1123 <div class="peach-danger-card">1124 <div class="peach-danger-card-head">1125 <span class="peach-danger-card-glyph" aria-hidden="true">⟳</span>1126 <h3 class="peach-danger-card-title">Replace vault from backup</h3>1127 </div>1128 <p class="peach-danger-card-copy">1129 Wipes the current vault and restores from a backup file.1130 Recoverable only if your backup is good.1131 </p>1132 <button1133 class="peach-danger-card-button"1134 onClick={() => props.onOpenFileImport?.()}1135 >1136 Choose backup file1137 </button>1138 </div>11391140 <Show when={props.onOpenPanic}>1141 <div class="peach-danger-card peach-danger-card--hazard">1142 <div class="peach-danger-card-head">1143 <span class="peach-danger-card-glyph" aria-hidden="true">⚠</span>1144 <h3 class="peach-danger-card-title">Destroy vault</h3>1145 </div>1146 <p class="peach-danger-card-copy">1147 Permanently wipes every entry, setting, and license on this1148 device. There is no recovery.1149 </p>1150 <button1151 class="peach-danger-card-button"1152 onClick={() => props.onOpenPanic?.()}1153 >1154 Begin destruction1155 </button>1156 </div>1157 </Show>1158 </Show>1159 </div>1160 </div>1161 );1162 }11631164 function renderSync() {1165 return (1166 <div class="peach-settings-body">1167 <div class="peach-settings-group">1168 <p class="peach-settings-sub-label">PAIRING</p>1169 <div class="peach-settings-row peach-settings-row--top">1170 <div class="peach-settings-row-main">1171 <span>1172 Peach Sync1173 <Show when={!isPro()}>1174 <span class="peach-pro-badge">Pro</span>1175 </Show>1176 </span>1177 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1178 Pair nearby devices by chirp and keep encrypted vault blobs in step without a central account.1179 </span>1180 </div>1181 <span class={`stk-pip ${isPro() ? "is-on" : ""}`} aria-hidden="true" />1182 </div>1183 <div class="peach-settings-row peach-settings-row--meta">1184 <span>Status</span>1185 <span class="peach-settings-value">{isPro() ? "Ready" : "Requires Pro"}</span>1186 </div>1187 <div class="peach-settings-row peach-settings-row--meta">1188 <span>Sync hub</span>1189 <button1190 class="peach-btn peach-btn--subtle peach-btn--sm"1191 onClick={() => props.onOpenSyncPairing?.()}1192 >1193 open1194 </button>1195 </div>11961197 <p class="peach-settings-sub-label">DEVICE FLOW</p>1198 <div class="peach-settings-row peach-settings-row--top">1199 <div class="peach-settings-row-main">1200 <span>Pair new device</span>1201 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1202 Opens the full chirp pairing console with scan, show-code, and advanced diagnostics.1203 </span>1204 </div>1205 <button1206 class="peach-btn peach-btn--subtle peach-btn--sm"1207 onClick={() => props.onOpenSyncPairing?.()}1208 >1209 pair1210 </button>1211 </div>1212 <div class="peach-info-notice" style="margin-top:8px">1213 Sync uses the same zero-knowledge vault format as backup and restore; pairing authorizes a device, not a cloud account.1214 </div>1215 </div>1216 </div>1217 );1218 }12191220 function renderAppearance() {1221 const sizeOptions: Array<{ id: "small" | "medium" | "large"; label: string }> = [1222 { id: "small", label: "Small" },1223 { id: "medium", label: "Default" },1224 { id: "large", label: "Large" },1225 ];1226 return (1227 <div class="peach-settings-body">1228 <div class="peach-settings-group">1229 <p class="peach-settings-sub-label">THEME</p>1230 <div class="peach-settings-row peach-settings-row--meta">1231 <span>Theme</span>1232 <button1233 class="peach-btn peach-btn--subtle peach-btn--sm"1234 onClick={() =>1235 props.onThemeChange(props.theme === "dark" ? "light" : "dark")1236 }1237 >1238 {props.theme === "dark" ? "Switch to light" : "Switch to dark"}1239 </button>1240 </div>12411242 <p class="peach-settings-sub-label">ACCENT</p>1243 <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">1244 <span>Accent</span>1245 <div class="peach-accent-picker">1246 <For each={ACCENT_COLORS}>1247 {(color) => (1248 <button1249 class="peach-accent-swatch peach-swatch"1250 classList={{ "is-active": props.accentColor === color.name }}1251 style={{ background: color.hex }}1252 onClick={() => void props.onAccentChange(color.name)}1253 aria-label={color.name}1254 title={color.name}1255 />1256 )}1257 </For>1258 </div>1259 </div>12601261 <p class="peach-settings-sub-label">TEXT SIZE</p>1262 <div class="peach-settings-row peach-settings-row--meta">1263 <span>Size</span>1264 <div class="peach-pill-group">1265 <For each={sizeOptions}>1266 {(opt) => (1267 <button1268 class="peach-btn peach-btn--ghost peach-btn--sm"1269 classList={{ "is-active": props.textSize === opt.id }}1270 onClick={() => void props.onTextSizeChange(opt.id)}1271 >1272 {opt.label}1273 </button>1274 )}1275 </For>1276 </div>1277 </div>12781279 <p class="peach-settings-sub-label">MOTION</p>1280 <div class="peach-settings-row peach-settings-row--meta">1281 <span>Reduce motion</span>1282 <button1283 class="peach-btn peach-btn--subtle peach-btn--sm"1284 onClick={() => void props.onReduceMotionChange(!props.reduceMotion)}1285 >1286 {props.reduceMotion ? "On" : "Off"}1287 </button>1288 </div>1289 </div>1290 </div>1291 );1292 }12931294 function renderShortcuts() {1295 const [capturingAction, setCapturingAction] = createSignal<ShortcutAction | null>(null);1296 const [conflictMsg, setConflictMsg] = createSignal<string | null>(null);12971298 function startCapture(action: ShortcutAction) {1299 setConflictMsg(null);1300 setCapturingAction(action);1301 }1302 function cancelCapture() {1303 setCapturingAction(null);1304 setConflictMsg(null);1305 }1306 function commitBinding(action: ShortcutAction, binding: Keybinding) {1307 const conflict = findConflict(props.shortcuts, binding, action);1308 if (conflict) {1309 const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict);1310 setConflictMsg(`Already bound to "${other?.label ?? conflict}"`);1311 return;1312 }1313 const next = { ...props.shortcuts, [action]: binding };1314 setCapturingAction(null);1315 setConflictMsg(null);1316 void props.onShortcutsChange(next);1317 }1318 function resetAction(action: ShortcutAction) {1319 const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] };1320 void props.onShortcutsChange(next);1321 }1322 function resetAll() {1323 void props.onShortcutsChange({ ...DEFAULT_BINDINGS });1324 }13251326 1327 1328 function handleCaptureKey(e: KeyboardEvent) {1329 e.preventDefault();1330 e.stopPropagation();1331 if (e.key === "Escape") { cancelCapture(); return; }1332 const binding = keybindingFromEvent(e);1333 if (!binding) return; 1334 const action = capturingAction();1335 if (action) commitBinding(action, binding);1336 }13371338 return (1339 <div class="peach-settings-body">1340 <div class="peach-settings-group peach-shortcuts-group">1341 <p class="peach-settings-sub-label">ENTRY VIEW</p>1342 <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry")}>1343 {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1344 </For>1345 </div>13461347 <div class="peach-settings-group peach-shortcuts-group">1348 <p class="peach-settings-sub-label">ANYWHERE IN POPUP</p>1349 <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global")}>1350 {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1351 </For>1352 </div>13531354 <Show when={conflictMsg()}>1355 <div class="peach-shortcuts-conflict">{conflictMsg()}</div>1356 </Show>13571358 <div class="peach-settings-group" style="display:flex;flex-direction:column;gap:8px">1359 <div class="peach-settings-row peach-settings-row--meta">1360 <span>Inline autofill on page</span>1361 <a1362 class="peach-settings-link"1363 href="chrome://extensions/shortcuts"1364 onClick={(e) => {1365 e.preventDefault();1366 chrome.tabs.create({ url: "chrome://extensions/shortcuts" });1367 }}1368 >1369 ⌘⇧P — customize ↗1370 </a>1371 </div>1372 <button1373 class="peach-btn peach-btn--subtle peach-btn--sm"1374 style="align-self:flex-start"1375 onClick={resetAll}1376 >1377 Reset all to defaults1378 </button>1379 </div>1380 </div>1381 );13821383 function renderRow(1384 desc: typeof SHORTCUT_DESCRIPTORS[number],1385 capturing: () => ShortcutAction | null,1386 onStart: (a: ShortcutAction) => void,1387 onCancel: () => void,1388 onReset: (a: ShortcutAction) => void,1389 onKey: (e: KeyboardEvent) => void,1390 ) {1391 const isActive = () => capturing() === desc.action;1392 const binding = () => props.shortcuts[desc.action];1393 const isCustom = () => {1394 const b = binding();1395 const d = DEFAULT_BINDINGS[desc.action];1396 return b.key !== d.key1397 || Boolean(b.ctrl) !== Boolean(d.ctrl)1398 || Boolean(b.meta) !== Boolean(d.meta)1399 || Boolean(b.shift) !== Boolean(d.shift)1400 || Boolean(b.alt) !== Boolean(d.alt);1401 };1402 return (1403 <div class="peach-shortcut-row">1404 <span class="peach-shortcut-row-label">{desc.label}</span>1405 <button1406 class="peach-shortcut-chip"1407 classList={{ "is-capturing": isActive(), "is-custom": isCustom() }}1408 onClick={() => isActive() ? onCancel() : onStart(desc.action)}1409 onKeyDown={(e) => { if (isActive()) onKey(e); }}1410 ref={(el) => {1411 1412 if (el) {1413 queueMicrotask(() => { if (isActive()) el.focus(); });1414 }1415 }}1416 >1417 {isActive() ? "Press a key…" : formatKeybinding(binding())}1418 </button>1419 <Show when={isCustom() && !isActive()}>1420 <button1421 class="peach-shortcut-reset"1422 title="Reset to default"1423 onClick={() => onReset(desc.action)}1424 >1425 ↺1426 </button>1427 </Show>1428 </div>1429 );1430 }1431 }14321433 const PRO_FEATURES: Array<{1434 feature: ProFeature;1435 name: string;1436 desc: string;1437 }> = [1438 { feature: "sync", name: "Peach Sync", desc: "Encrypted · peer-to-peer" },1439 { feature: "codex", name: "Peach Codex", desc: "Restore from paper" },1440 { feature: "passkeys", name: "Passkeys", desc: "Zero-knowledge WebAuthn" },1441 { feature: "ios_app", name: "Mobile apps", desc: "Android · iOS soon" },1442 { feature: "advanced_health", name: "Vault health", desc: "Reuse · age · exposure" },1443 { feature: "smart_password_change", name: "Smart reset", desc: "Guided password change" },1444 ];14451446 const PRO_SECURITY_FEATURES: Array<{1447 feature: ProFeature;1448 name: string;1449 desc: string;1450 }> = [1451 { feature: "canary_entry", name: "Canary entries", desc: "Detects vault compromise" },1452 { feature: "duress_vault", name: "Duress vault", desc: "Decoy under coercion" },1453 ];14541455 const [licenseKey, setLicenseKey] = createSignal<string | null>(null);1456 const [showKey, setShowKey] = createSignal(false);14571458 const [deactivating, setDeactivating] = createSignal(false);14591460 async function handleDeactivateLicense() {1461 setDeactivating(true);1462 await sendMessage({ type: "DEACTIVATE_LICENSE" });1463 setLicenseInfo(null);1464 await props.onLicenseChange?.();1465 setDeactivating(false);1466 }14671468 async function handleTestCanary(entry: {id: string; title: string; canary: Canary}) {1469 setCanaryTestingId(entry.id);1470 setCanaryTestResult(null);1471 const res = await sendMessage({1472 type: "FIRE_CANARY_PING",1473 payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger: "test" },1474 }) as { ok: boolean };1475 setCanaryTestResult({ id: entry.id, ok: res.ok });1476 setCanaryTestingId(null);1477 setTimeout(() => setCanaryTestResult((r) => r?.id === entry.id ? null : r), 3000);1478 }14791480 function renderLicense() {1481 async function onActivate() {1482 await handleActivateLicense();1483 if ((licenseInfo()?.valid ?? false) && licenseInfo()?.plan === "pro") {1484 await props.onLicenseChange?.();1485 }1486 }14871488 return (1489 <div class="peach-settings-body">1490 <Show1491 when={isPro()}1492 fallback={1493 <div class="peach-pro-upgrade">1494 <p class="peach-pro-eyebrow">PEACH PRO · ONE-TIME</p>14951496 <h2 class="peach-pro-headline">1497 Every<em>thing,</em><br />every<em>where.</em>1498 </h2>14991500 <p class="peach-pro-subtitle">1501 One key unlocks every Pro feature — for the lifetime of the app.1502 </p>15031504 <div class="peach-pro-grid">1505 <For each={PRO_FEATURES}>1506 {(item, i) => (1507 <div1508 class="peach-pro-grid-item"1509 classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}1510 >1511 <span class="peach-pro-grid-num">{String(i() + 1).padStart(2, "0")}</span>1512 <span class="peach-pro-grid-name">{item.name}</span>1513 <span class="peach-pro-grid-desc">{item.desc}</span>1514 </div>1515 )}1516 </For>1517 </div>15181519 <p class="peach-pro-section-label">ADVANCED SECURITY</p>1520 <div class="peach-pro-grid">1521 <For each={PRO_SECURITY_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(PRO_FEATURES.length + 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 <div class="peach-pro-upgrade-actions">1536 <p class="peach-pro-pricing">1537 $39 <span class="peach-pro-pricing-sub">once · yours forever</span>1538 </p>1539 <a1540 href="https://peachpasswords.com/#pricing"1541 target="_blank"1542 rel="noopener noreferrer"1543 class="peach-pro-upgrade-cta"1544 >1545 Get Peach Pro1546 </a>1547 <div class="peach-pro-key-divider"><span>ALREADY HAVE A KEY</span></div>1548 <div class="peach-pro-key-inline">1549 <input1550 class="peach-pro-key-inline-input"1551 type="text"1552 placeholder="PEACH-XXXX-XXXX-XXXX"1553 value={licenseKeyInput()}1554 onInput={(e) => setLicenseKeyInput(e.currentTarget.value)}1555 onKeyDown={(e) => { if (e.key === "Enter") void onActivate(); }}1556 autocomplete="off"1557 spellcheck={false}1558 />1559 <button1560 class="peach-pro-key-apply"1561 onClick={() => void onActivate()}1562 disabled={activatingLicense() || !licenseKeyInput().trim()}1563 >1564 {activatingLicense() ? "…" : "APPLY"}1565 </button>1566 </div>1567 <Show when={licenseError()}>1568 <p class="peach-pro-key-error">{licenseError()}</p>1569 </Show>1570 </div>1571 </div>1572 }1573 >1574 <div class="peach-pro-active">1575 <div class="peach-pro-active-hero">1576 <div class="peach-pro-active-mark-wrap">1577 <PeachMark class="peach-pro-active-mark" />1578 </div>1579 <h2 class="peach-pro-active-title">Peach Pro</h2>1580 <p class="peach-pro-active-since">{formatActivatedSince(licenseInfo()?.issued)}</p>1581 </div>15821583 <Show when={licenseKey()}>1584 <div class="peach-settings-license">1585 <span class="peach-settings-label">LICENSE KEY</span>1586 <div class="peach-settings-license-row">1587 <Show1588 when={showKey()}1589 fallback={1590 <span class="peach-settings-license-key peach-settings-license-key--masked" aria-label="License key hidden">1591 ••••••••••••••••••••••••••••1592 </span>1593 }1594 >1595 <span class="peach-settings-license-key">{licenseKey()!}</span>1596 </Show>1597 <div class="peach-settings-license-actions">1598 <button1599 class="peach-settings-license-copy"1600 title="Copy license key"1601 onClick={() => copyLicenseKey(licenseKey()!)}1602 >1603 {copied() ? "✓" : "copy"}1604 </button>1605 <button1606 class="peach-settings-license-toggle"1607 aria-label={showKey() ? "Hide license key" : "Reveal license key"}1608 title={showKey() ? "Hide" : "Reveal"}1609 onClick={() => setShowKey(v => !v)}1610 >1611 <Show1612 when={showKey()}1613 fallback={1614 <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">1615 <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />1616 <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />1617 </svg>1618 }1619 >1620 <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">1621 <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />1622 <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />1623 <line x1="2" y1="2" x2="12" y2="12" />1624 </svg>1625 </Show>1626 </button>1627 </div>1628 </div>1629 </div>1630 </Show>16311632 <div class="peach-pro-active-features">1633 <For each={[...PRO_FEATURES, ...PRO_SECURITY_FEATURES]}>1634 {(item) => (1635 <div class="peach-pro-active-feature">1636 <span class="peach-pro-active-feature-mark">✦</span>1637 <span class="peach-pro-active-feature-name">{item.name}</span>1638 </div>1639 )}1640 </For>1641 </div>16421643 <button1644 class="peach-pro-deactivate"1645 onClick={() => void handleDeactivateLicense()}1646 disabled={deactivating()}1647 >1648 {deactivating() ? "Deactivating…" : "Deactivate"}1649 </button>1650 </div>1651 </Show>1652 </div>1653 );1654 }16551656 function renderFeedback() {1657 return (1658 <div class="peach-settings-body">1659 <div class="peach-settings-group peach-feedback-group">1660 <p class="peach-feedback-intro">1661 Help us improve Peach. Every report goes directly to our inbox.1662 </p>16631664 <p class="peach-settings-sub-label">TYPE</p>1665 <div class="peach-feedback-chips">1666 <For each={FEEDBACK_CATEGORIES}>1667 {(cat) => (1668 <button1669 class={`peach-feedback-chip ${fbCategory() === cat ? "is-active" : ""}`}1670 onClick={() => setFbCategory(cat)}1671 >1672 {cat}1673 </button>1674 )}1675 </For>1676 </div>16771678 <p class="peach-settings-sub-label">MESSAGE</p>1679 <textarea1680 class="peach-feedback-textarea"1681 placeholder="Describe the issue or idea..."1682 value={fbMessage()}1683 onInput={(e) => setFbMessage(e.currentTarget.value)}1684 />16851686 <p class="peach-feedback-meta">1687 Version {version} · will be included automatically1688 </p>16891690 <Show when={fbResult() === "success"}>1691 <p class="peach-feedback-result peach-feedback-result--ok">1692 Sent — thank you1693 </p>1694 </Show>1695 <Show when={fbResult() === "error"}>1696 <p class="peach-feedback-result peach-feedback-result--err">1697 Failed to send — try again1698 </p>1699 </Show>17001701 <button1702 class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"1703 onClick={() => void handleSubmitFeedback()}1704 disabled={fbSubmitting() || !fbMessage().trim()}1705 >1706 {fbSubmitting() ? "Sending..." : "Send feedback"}1707 </button>17081709 <p class="peach-feedback-anon">1710 Sent anonymously · no account required1711 </p>1712 </div>1713 </div>1714 );1715 }17161717 const bodies: StackBodies = {1718 protection: renderSecurity(),1719 vault: renderVault(),1720 sync: renderSync(),1721 appearance: renderAppearance(),1722 shortcuts: renderShortcuts(),1723 pro: renderLicense(),1724 feedback: renderFeedback(),1725 };17261727 return (1728 <Show1729 when={showNewRecoveryWords()}1730 fallback={1731 <div1732 class="peach-overlay-view"1733 classList={{ "ov-enter-right": !props.isExiting, "ov-exit-right": !!props.isExiting }}1734 style="display:flex;flex-direction:column"1735 >1736 <StackDeck1737 active={activeCard()}1738 onActiveChange={setActiveCard}1739 onBack={props.onBack}1740 cards={cards()}1741 bodies={bodies}1742 />1743 </div>1744 }1745 >1746 <RecoveryWordDisplayView onDone={() => setShowNewRecoveryWords(false)} />1747 </Show>1748 );1749}