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-settings-advanced-toggle"1060 onClick={() => setHistoryOpen((v) => !v)}1061 aria-expanded={historyOpen()}1062 >1063 <span class="peach-settings-sub-label" style="margin:0">VAULT HISTORY</span>1064 <span class="peach-settings-advanced-chevron">1065 {historyOpen() ? "▲" : "▼"}1066 </span>1067 </button>1068 <Show when={historyOpen()}>1069 <Show when={historyLoading()}>1070 <p class="peach-settings-hint">Loading…</p>1071 </Show>1072 <Show when={!historyLoading() && historySnapshots().length === 0}>1073 <p class="peach-settings-hint">1074 No snapshots yet. Peach saves automatically before every change —1075 saves, syncs, and imports.1076 </p>1077 </Show>1078 <Show when={!historyLoading() && historySnapshots().length > 0}>1079 <p class="peach-settings-hint">1080 Restoring locks your vault. Unlock with the password that1081 encrypted that snapshot to confirm.1082 </p>1083 <div class="peach-history-list">1084 <For each={historySnapshots()}>1085 {(snap) => (1086 <div class="peach-history-row">1087 <div class="peach-history-meta">1088 <span class="peach-history-time">1089 {formatSnapshotTime(snap.savedAt)}1090 </span>1091 <span class="peach-history-sub">1092 {[1093 snap.entryCount !== null1094 ? `${snap.entryCount} ${snap.entryCount === 1 ? "entry" : "entries"}`1095 : null,1096 reasonLabel(snap.reason),1097 ]1098 .filter(Boolean)1099 .join(" · ")}1100 </span>1101 </div>1102 <button1103 class={`peach-btn peach-btn--subtle peach-btn--sm peach-history-btn${1104 historyRestoring() === snap.id ? " is-busy" : ""1105 }`}1106 onClick={() => void handleRestoreVaultSnapshot(snap.id)}1107 disabled={historyRestoring() !== null}1108 >1109 <Show when={historyRestoring() === snap.id} fallback="Restore">1110 …1111 </Show>1112 </button>1113 </div>1114 )}1115 </For>1116 </div>1117 <p class="peach-history-footer">1118 {historySnapshots().length} of 10 snapshots1119 </p>1120 </Show>1121 </Show>11221123 <button1124 class="peach-settings-advanced-toggle"1125 onClick={() => setShowVaultAdvanced((v) => !v)}1126 aria-expanded={showVaultAdvanced()}1127 >1128 <span class="peach-settings-sub-label" style="margin:0">ADVANCED</span>1129 <span class="peach-settings-advanced-chevron">1130 {showVaultAdvanced() ? "▲" : "▼"}1131 </span>1132 </button>11331134 <Show when={showVaultAdvanced()}>1135 <div class="peach-danger-card">1136 <div class="peach-danger-card-head">1137 <span class="peach-danger-card-glyph" aria-hidden="true">⟳</span>1138 <h3 class="peach-danger-card-title">Replace vault from backup</h3>1139 </div>1140 <p class="peach-danger-card-copy">1141 Wipes the current vault and restores from a backup file.1142 Recoverable only if your backup is good.1143 </p>1144 <button1145 class="peach-danger-card-button"1146 onClick={() => props.onOpenFileImport?.()}1147 >1148 Choose backup file1149 </button>1150 </div>11511152 <Show when={props.onOpenPanic}>1153 <div class="peach-danger-card peach-danger-card--hazard">1154 <div class="peach-danger-card-head">1155 <span class="peach-danger-card-glyph" aria-hidden="true">⚠</span>1156 <h3 class="peach-danger-card-title">Destroy vault</h3>1157 </div>1158 <p class="peach-danger-card-copy">1159 Permanently wipes every entry, setting, and license on this1160 device. There is no recovery.1161 </p>1162 <button1163 class="peach-danger-card-button"1164 onClick={() => props.onOpenPanic?.()}1165 >1166 Begin destruction1167 </button>1168 </div>1169 </Show>1170 </Show>1171 </div>1172 </div>1173 );1174 }11751176 function renderSync() {1177 return (1178 <div class="peach-settings-body">1179 <div class="peach-settings-group">1180 <p class="peach-settings-sub-label">PAIRING</p>1181 <div class="peach-settings-row peach-settings-row--top">1182 <div class="peach-settings-row-main">1183 <span>1184 Peach Sync1185 <Show when={!isPro()}>1186 <span class="peach-pro-badge">Pro</span>1187 </Show>1188 </span>1189 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1190 Pair nearby devices by chirp and keep encrypted vault blobs in step without a central account.1191 </span>1192 </div>1193 <span class={`stk-pip ${isPro() ? "is-on" : ""}`} aria-hidden="true" />1194 </div>1195 <div class="peach-settings-row peach-settings-row--meta">1196 <span>Status</span>1197 <span class="peach-settings-value">{isPro() ? "Ready" : "Requires Pro"}</span>1198 </div>1199 <div class="peach-settings-row peach-settings-row--meta">1200 <span>Sync hub</span>1201 <button1202 class="peach-btn peach-btn--subtle peach-btn--sm"1203 onClick={() => props.onOpenSyncPairing?.()}1204 >1205 open1206 </button>1207 </div>12081209 <p class="peach-settings-sub-label">DEVICE FLOW</p>1210 <div class="peach-settings-row peach-settings-row--top">1211 <div class="peach-settings-row-main">1212 <span>Pair new device</span>1213 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1214 Opens the full chirp pairing console with scan, show-code, and advanced diagnostics.1215 </span>1216 </div>1217 <button1218 class="peach-btn peach-btn--subtle peach-btn--sm"1219 onClick={() => props.onOpenSyncPairing?.()}1220 >1221 pair1222 </button>1223 </div>1224 <div class="peach-info-notice" style="margin-top:8px">1225 Sync uses the same zero-knowledge vault format as backup and restore; pairing authorizes a device, not a cloud account.1226 </div>1227 </div>1228 </div>1229 );1230 }12311232 function renderAppearance() {1233 const sizeOptions: Array<{ id: "small" | "medium" | "large"; label: string }> = [1234 { id: "small", label: "Small" },1235 { id: "medium", label: "Default" },1236 { id: "large", label: "Large" },1237 ];1238 return (1239 <div class="peach-settings-body">1240 <div class="peach-settings-group">1241 <p class="peach-settings-sub-label">THEME</p>1242 <div class="peach-settings-row peach-settings-row--meta">1243 <span>Theme</span>1244 <button1245 class="peach-btn peach-btn--subtle peach-btn--sm"1246 onClick={() =>1247 props.onThemeChange(props.theme === "dark" ? "light" : "dark")1248 }1249 >1250 {props.theme === "dark" ? "Switch to light" : "Switch to dark"}1251 </button>1252 </div>12531254 <p class="peach-settings-sub-label">ACCENT</p>1255 <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">1256 <span>Accent</span>1257 <div class="peach-accent-picker">1258 <For each={ACCENT_COLORS}>1259 {(color) => (1260 <button1261 class="peach-accent-swatch peach-swatch"1262 classList={{ "is-active": props.accentColor === color.name }}1263 style={{ background: color.hex }}1264 onClick={() => void props.onAccentChange(color.name)}1265 aria-label={color.name}1266 title={color.name}1267 />1268 )}1269 </For>1270 </div>1271 </div>12721273 <p class="peach-settings-sub-label">TEXT SIZE</p>1274 <div class="peach-settings-row peach-settings-row--meta">1275 <span>Size</span>1276 <div class="peach-pill-group">1277 <For each={sizeOptions}>1278 {(opt) => (1279 <button1280 class="peach-btn peach-btn--ghost peach-btn--sm"1281 classList={{ "is-active": props.textSize === opt.id }}1282 onClick={() => void props.onTextSizeChange(opt.id)}1283 >1284 {opt.label}1285 </button>1286 )}1287 </For>1288 </div>1289 </div>12901291 <p class="peach-settings-sub-label">MOTION</p>1292 <div class="peach-settings-row peach-settings-row--meta">1293 <span>Reduce motion</span>1294 <button1295 class="peach-btn peach-btn--subtle peach-btn--sm"1296 onClick={() => void props.onReduceMotionChange(!props.reduceMotion)}1297 >1298 {props.reduceMotion ? "On" : "Off"}1299 </button>1300 </div>1301 </div>1302 </div>1303 );1304 }13051306 function renderShortcuts() {1307 const [capturingAction, setCapturingAction] = createSignal<ShortcutAction | null>(null);1308 const [conflictMsg, setConflictMsg] = createSignal<string | null>(null);13091310 function startCapture(action: ShortcutAction) {1311 setConflictMsg(null);1312 setCapturingAction(action);1313 }1314 function cancelCapture() {1315 setCapturingAction(null);1316 setConflictMsg(null);1317 }1318 function commitBinding(action: ShortcutAction, binding: Keybinding) {1319 const conflict = findConflict(props.shortcuts, binding, action);1320 if (conflict) {1321 const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict);1322 setConflictMsg(`Already bound to "${other?.label ?? conflict}"`);1323 return;1324 }1325 const next = { ...props.shortcuts, [action]: binding };1326 setCapturingAction(null);1327 setConflictMsg(null);1328 void props.onShortcutsChange(next);1329 }1330 function resetAction(action: ShortcutAction) {1331 const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] };1332 void props.onShortcutsChange(next);1333 }1334 function resetAll() {1335 void props.onShortcutsChange({ ...DEFAULT_BINDINGS });1336 }13371338 1339 1340 function handleCaptureKey(e: KeyboardEvent) {1341 e.preventDefault();1342 e.stopPropagation();1343 if (e.key === "Escape") { cancelCapture(); return; }1344 const binding = keybindingFromEvent(e);1345 if (!binding) return; 1346 const action = capturingAction();1347 if (action) commitBinding(action, binding);1348 }13491350 return (1351 <div class="peach-settings-body">1352 <div class="peach-settings-group peach-shortcuts-group">1353 <p class="peach-settings-sub-label">ENTRY VIEW</p>1354 <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry")}>1355 {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1356 </For>1357 </div>13581359 <div class="peach-settings-group peach-shortcuts-group">1360 <p class="peach-settings-sub-label">ANYWHERE IN POPUP</p>1361 <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global")}>1362 {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1363 </For>1364 </div>13651366 <Show when={conflictMsg()}>1367 <div class="peach-shortcuts-conflict">{conflictMsg()}</div>1368 </Show>13691370 <div class="peach-settings-group" style="display:flex;flex-direction:column;gap:8px">1371 <div class="peach-settings-row peach-settings-row--meta">1372 <span>Inline autofill on page</span>1373 <a1374 class="peach-settings-link"1375 href="chrome://extensions/shortcuts"1376 onClick={(e) => {1377 e.preventDefault();1378 chrome.tabs.create({ url: "chrome://extensions/shortcuts" });1379 }}1380 >1381 ⌘⇧P — customize ↗1382 </a>1383 </div>1384 <button1385 class="peach-btn peach-btn--subtle peach-btn--sm"1386 style="align-self:flex-start"1387 onClick={resetAll}1388 >1389 Reset all to defaults1390 </button>1391 </div>1392 </div>1393 );13941395 function renderRow(1396 desc: typeof SHORTCUT_DESCRIPTORS[number],1397 capturing: () => ShortcutAction | null,1398 onStart: (a: ShortcutAction) => void,1399 onCancel: () => void,1400 onReset: (a: ShortcutAction) => void,1401 onKey: (e: KeyboardEvent) => void,1402 ) {1403 const isActive = () => capturing() === desc.action;1404 const binding = () => props.shortcuts[desc.action];1405 const isCustom = () => {1406 const b = binding();1407 const d = DEFAULT_BINDINGS[desc.action];1408 return b.key !== d.key1409 || Boolean(b.ctrl) !== Boolean(d.ctrl)1410 || Boolean(b.meta) !== Boolean(d.meta)1411 || Boolean(b.shift) !== Boolean(d.shift)1412 || Boolean(b.alt) !== Boolean(d.alt);1413 };1414 return (1415 <div class="peach-shortcut-row">1416 <span class="peach-shortcut-row-label">{desc.label}</span>1417 <button1418 class="peach-shortcut-chip"1419 classList={{ "is-capturing": isActive(), "is-custom": isCustom() }}1420 onClick={() => isActive() ? onCancel() : onStart(desc.action)}1421 onKeyDown={(e) => { if (isActive()) onKey(e); }}1422 ref={(el) => {1423 1424 if (el) {1425 queueMicrotask(() => { if (isActive()) el.focus(); });1426 }1427 }}1428 >1429 {isActive() ? "Press a key…" : formatKeybinding(binding())}1430 </button>1431 <Show when={isCustom() && !isActive()}>1432 <button1433 class="peach-shortcut-reset"1434 title="Reset to default"1435 onClick={() => onReset(desc.action)}1436 >1437 ↺1438 </button>1439 </Show>1440 </div>1441 );1442 }1443 }14441445 const PRO_FEATURES: Array<{1446 feature: ProFeature;1447 name: string;1448 desc: string;1449 }> = [1450 { feature: "sync", name: "Peach Sync", desc: "Encrypted · peer-to-peer" },1451 { feature: "codex", name: "Peach Codex", desc: "Restore from paper" },1452 { feature: "passkeys", name: "Passkeys", desc: "Zero-knowledge WebAuthn" },1453 { feature: "ios_app", name: "Mobile apps", desc: "Android · iOS soon" },1454 { feature: "advanced_health", name: "Vault health", desc: "Reuse · age · exposure" },1455 { feature: "smart_password_change", name: "Smart reset", desc: "Guided password change" },1456 ];14571458 const PRO_SECURITY_FEATURES: Array<{1459 feature: ProFeature;1460 name: string;1461 desc: string;1462 }> = [1463 { feature: "canary_entry", name: "Canary entries", desc: "Detects vault compromise" },1464 { feature: "duress_vault", name: "Duress vault", desc: "Decoy under coercion" },1465 ];14661467 const [licenseKey, setLicenseKey] = createSignal<string | null>(null);1468 const [showKey, setShowKey] = createSignal(false);14691470 const [deactivating, setDeactivating] = createSignal(false);14711472 async function handleDeactivateLicense() {1473 setDeactivating(true);1474 await sendMessage({ type: "DEACTIVATE_LICENSE" });1475 setLicenseInfo(null);1476 await props.onLicenseChange?.();1477 setDeactivating(false);1478 }14791480 async function handleTestCanary(entry: {id: string; title: string; canary: Canary}) {1481 setCanaryTestingId(entry.id);1482 setCanaryTestResult(null);1483 const res = await sendMessage({1484 type: "FIRE_CANARY_PING",1485 payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger: "test" },1486 }) as { ok: boolean };1487 setCanaryTestResult({ id: entry.id, ok: res.ok });1488 setCanaryTestingId(null);1489 setTimeout(() => setCanaryTestResult((r) => r?.id === entry.id ? null : r), 3000);1490 }14911492 function renderLicense() {1493 async function onActivate() {1494 await handleActivateLicense();1495 if ((licenseInfo()?.valid ?? false) && licenseInfo()?.plan === "pro") {1496 await props.onLicenseChange?.();1497 }1498 }14991500 return (1501 <div class="peach-settings-body">1502 <Show1503 when={isPro()}1504 fallback={1505 <div class="peach-pro-upgrade">1506 <p class="peach-pro-eyebrow">PEACH PRO · ONE-TIME</p>15071508 <h2 class="peach-pro-headline">1509 Every<em>thing,</em><br />every<em>where.</em>1510 </h2>15111512 <p class="peach-pro-subtitle">1513 One key unlocks every Pro feature — for the lifetime of the app.1514 </p>15151516 <div class="peach-pro-grid">1517 <For each={PRO_FEATURES}>1518 {(item, i) => (1519 <div1520 class="peach-pro-grid-item"1521 classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}1522 >1523 <span class="peach-pro-grid-num">{String(i() + 1).padStart(2, "0")}</span>1524 <span class="peach-pro-grid-name">{item.name}</span>1525 <span class="peach-pro-grid-desc">{item.desc}</span>1526 </div>1527 )}1528 </For>1529 </div>15301531 <p class="peach-pro-section-label">ADVANCED SECURITY</p>1532 <div class="peach-pro-grid">1533 <For each={PRO_SECURITY_FEATURES}>1534 {(item, i) => (1535 <div1536 class="peach-pro-grid-item"1537 classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}1538 >1539 <span class="peach-pro-grid-num">{String(PRO_FEATURES.length + i() + 1).padStart(2, "0")}</span>1540 <span class="peach-pro-grid-name">{item.name}</span>1541 <span class="peach-pro-grid-desc">{item.desc}</span>1542 </div>1543 )}1544 </For>1545 </div>15461547 <div class="peach-pro-upgrade-actions">1548 <p class="peach-pro-pricing">1549 $39 <span class="peach-pro-pricing-sub">once · yours forever</span>1550 </p>1551 <a1552 href="https://peachpasswords.com/#pricing"1553 target="_blank"1554 rel="noopener noreferrer"1555 class="peach-pro-upgrade-cta"1556 >1557 Get Peach Pro1558 </a>1559 <div class="peach-pro-key-divider"><span>ALREADY HAVE A KEY</span></div>1560 <div class="peach-pro-key-inline">1561 <input1562 class="peach-pro-key-inline-input"1563 type="text"1564 placeholder="PEACH-XXXX-XXXX-XXXX"1565 value={licenseKeyInput()}1566 onInput={(e) => setLicenseKeyInput(e.currentTarget.value)}1567 onKeyDown={(e) => { if (e.key === "Enter") void onActivate(); }}1568 autocomplete="off"1569 spellcheck={false}1570 />1571 <button1572 class="peach-pro-key-apply"1573 onClick={() => void onActivate()}1574 disabled={activatingLicense() || !licenseKeyInput().trim()}1575 >1576 {activatingLicense() ? "…" : "APPLY"}1577 </button>1578 </div>1579 <Show when={licenseError()}>1580 <p class="peach-pro-key-error">{licenseError()}</p>1581 </Show>1582 </div>1583 </div>1584 }1585 >1586 <div class="peach-pro-active">1587 <div class="peach-pro-active-hero">1588 <div class="peach-pro-active-mark-wrap">1589 <PeachMark class="peach-pro-active-mark" />1590 </div>1591 <h2 class="peach-pro-active-title">Peach Pro</h2>1592 <p class="peach-pro-active-since">{formatActivatedSince(licenseInfo()?.issued)}</p>1593 </div>15941595 <Show when={licenseKey()}>1596 <div class="peach-settings-license">1597 <span class="peach-settings-label">LICENSE KEY</span>1598 <div class="peach-settings-license-row">1599 <Show1600 when={showKey()}1601 fallback={1602 <span class="peach-settings-license-key peach-settings-license-key--masked" aria-label="License key hidden">1603 ••••••••••••••••••••••••••••1604 </span>1605 }1606 >1607 <span class="peach-settings-license-key">{licenseKey()!}</span>1608 </Show>1609 <div class="peach-settings-license-actions">1610 <button1611 class="peach-settings-license-copy"1612 title="Copy license key"1613 onClick={() => copyLicenseKey(licenseKey()!)}1614 >1615 {copied() ? "✓" : "copy"}1616 </button>1617 <button1618 class="peach-settings-license-toggle"1619 aria-label={showKey() ? "Hide license key" : "Reveal license key"}1620 title={showKey() ? "Hide" : "Reveal"}1621 onClick={() => setShowKey(v => !v)}1622 >1623 <Show1624 when={showKey()}1625 fallback={1626 <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">1627 <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />1628 <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />1629 </svg>1630 }1631 >1632 <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">1633 <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />1634 <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />1635 <line x1="2" y1="2" x2="12" y2="12" />1636 </svg>1637 </Show>1638 </button>1639 </div>1640 </div>1641 </div>1642 </Show>16431644 <div class="peach-pro-active-features">1645 <For each={[...PRO_FEATURES, ...PRO_SECURITY_FEATURES]}>1646 {(item) => (1647 <div class="peach-pro-active-feature">1648 <span class="peach-pro-active-feature-mark">✦</span>1649 <span class="peach-pro-active-feature-name">{item.name}</span>1650 </div>1651 )}1652 </For>1653 </div>16541655 <button1656 class="peach-pro-deactivate"1657 onClick={() => void handleDeactivateLicense()}1658 disabled={deactivating()}1659 >1660 {deactivating() ? "Deactivating…" : "Deactivate"}1661 </button>1662 </div>1663 </Show>1664 </div>1665 );1666 }16671668 function renderFeedback() {1669 return (1670 <div class="peach-settings-body">1671 <div class="peach-settings-group peach-feedback-group">1672 <p class="peach-feedback-intro">1673 Help us improve Peach. Every report goes directly to our inbox.1674 </p>16751676 <p class="peach-settings-sub-label">TYPE</p>1677 <div class="peach-feedback-chips">1678 <For each={FEEDBACK_CATEGORIES}>1679 {(cat) => (1680 <button1681 class={`peach-feedback-chip ${fbCategory() === cat ? "is-active" : ""}`}1682 onClick={() => setFbCategory(cat)}1683 >1684 {cat}1685 </button>1686 )}1687 </For>1688 </div>16891690 <p class="peach-settings-sub-label">MESSAGE</p>1691 <textarea1692 class="peach-feedback-textarea"1693 placeholder="Describe the issue or idea..."1694 value={fbMessage()}1695 onInput={(e) => setFbMessage(e.currentTarget.value)}1696 />16971698 <p class="peach-feedback-meta">1699 Version {version} · will be included automatically1700 </p>17011702 <Show when={fbResult() === "success"}>1703 <p class="peach-feedback-result peach-feedback-result--ok">1704 Sent — thank you1705 </p>1706 </Show>1707 <Show when={fbResult() === "error"}>1708 <p class="peach-feedback-result peach-feedback-result--err">1709 Failed to send — try again1710 </p>1711 </Show>17121713 <button1714 class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"1715 onClick={() => void handleSubmitFeedback()}1716 disabled={fbSubmitting() || !fbMessage().trim()}1717 >1718 {fbSubmitting() ? "Sending..." : "Send feedback"}1719 </button>17201721 <p class="peach-feedback-anon">1722 Sent anonymously · no account required1723 </p>1724 </div>1725 </div>1726 );1727 }17281729 const bodies: StackBodies = {1730 protection: renderSecurity(),1731 vault: renderVault(),1732 sync: renderSync(),1733 appearance: renderAppearance(),1734 shortcuts: renderShortcuts(),1735 pro: renderLicense(),1736 feedback: renderFeedback(),1737 };17381739 return (1740 <Show1741 when={showNewRecoveryWords()}1742 fallback={1743 <div1744 class="peach-overlay-view"1745 classList={{ "ov-enter-right": !props.isExiting, "ov-exit-right": !!props.isExiting }}1746 style="display:flex;flex-direction:column"1747 >1748 <StackDeck1749 active={activeCard()}1750 onActiveChange={setActiveCard}1751 onBack={props.onBack}1752 cards={cards()}1753 bodies={bodies}1754 />1755 </div>1756 }1757 >1758 <RecoveryWordDisplayView onDone={() => setShowNewRecoveryWords(false)} />1759 </Show>1760 );1761}