1import { createEffect, createMemo, createSignal, For, Match, onMount, Show, Switch } from "solid-js";2import { sendMessage } from "../App";3import { PeachMark } from "../components/PeachMark";4import { StackDeck } from "../components/settings/StackDeck";5import type { CardId, StackBodies, StackCardSpec } from "../components/settings/types";6import { RecoveryWordDisplayView } from "./RecoveryWordDisplay";7import type { Canary } from "../../shared/types";8import { ACCENT_COLORS } from "../../shared/types";9import type {10 AccentColorName,11 PasskeyProviderState,12 ProFeature,13 VaultSnapshot,14} from "../../shared/types";15import {16 DEFAULT_BINDINGS,17 findConflict,18 formatKeybinding,19 keybindingFromEvent,20 SHORTCUT_DESCRIPTORS,21 type Keybinding,22 type ShortcutAction,23} from "../../shared/shortcuts";24import { isNativePlatform, nativePlugin } from "../../shared/native";25import { WEB3FORMS_KEY } from "../../shared/config";2627interface LicenseInfo {28 valid: boolean;29 plan: "free" | "pro";30 issued?: string;31}3233interface SettingsProps {34 entriesCount: number;35 breachMonitoring: boolean;36 lockOnClose: boolean;37 autoLockMinutes: number;38 trashRetentionDays: number;39 theme: "dark" | "light";40 accentColor: AccentColorName;41 textSize: "small" | "medium" | "large";42 reduceMotion: boolean;43 isPro?: boolean;44 initialSection?: string | null;45 upgradeHighlight?: ProFeature | null;46 onBreachMonitoringChange: (enabled: boolean) => void | Promise<void>;47 onLockOnCloseChange: (enabled: boolean) => void | Promise<void>;48 onAutoLockMinutesChange: (minutes: number) => void | Promise<void>;49 onTrashRetentionChange: (days: number) => void | Promise<void>;50 onThemeChange: (theme: "dark" | "light") => void;51 onAccentChange: (name: AccentColorName) => void | Promise<void>;52 onTextSizeChange: (size: "small" | "medium" | "large") => void | Promise<void>;53 onReduceMotionChange: (enabled: boolean) => void | Promise<void>;54 shortcuts: Record<ShortcutAction, Keybinding>;55 onShortcutsChange: (active: Record<ShortcutAction, Keybinding>) => void | Promise<void>;56 onLicenseChange?: () => void | Promise<void>;57 isExiting?: boolean;58 onBack: () => void;59 onImport: () => void;60 onExport: () => void;61 onBreachCheckDone: () => void;62 onOpenHealth?: () => void;63 onOpenSyncPairing?: () => void;64 onOpenFileImport?: () => void;65 onOpenPanic?: () => void;66 onOpenCodexGenerate?: () => void;67 onOpenCodexRestore?: () => void;68 697071 onProGate?: (feature: ProFeature) => void;72 onCardChange?: (id: CardId) => void;73}7475const FEEDBACK_CATEGORIES = ["Bug report", "Feature request", "Question", "Other"];76const FEEDBACK_DATA_PERMISSION = "technicalAndInteraction";77const MAX_FEEDBACK_MESSAGE_LENGTH = 4000;7879function sanitizeFeedbackMessage(value: string): string {80 return value81 .trim()82 .slice(0, MAX_FEEDBACK_MESSAGE_LENGTH)83 .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "")84 .replace(/&/g, "&")85 .replace(/</g, "<")86 .replace(/>/g, ">")87 .replace(/"/g, """)88 .replace(/'/g, "'");89}9091type DataCollectionPermissionApi = {92 permissions?: {93 getAll?: () => Promise<{ data_collection?: string[] }>;94 request?: (permissions: { data_collection: string[] }) => Promise<boolean>;95 };96};9798async function requestFeedbackDataPermission(): Promise<boolean> {99 const api = ((globalThis as { browser?: DataCollectionPermissionApi }).browser ??100 (typeof chrome !== "undefined" ? chrome : undefined)) as DataCollectionPermissionApi | undefined;101 if (!api?.permissions?.getAll) return true;102103 try {104 const permissions = await api.permissions.getAll();105 if (!("data_collection" in permissions)) return true;106 if (permissions.data_collection?.includes(FEEDBACK_DATA_PERMISSION)) return true;107 return await (api.permissions.request?.({ data_collection: [FEEDBACK_DATA_PERMISSION] }) ?? Promise.resolve(false));108 } catch {109 return false;110 }111}112113function formatActivatedSince(issued: string | undefined): string {114 if (!issued) return "Active";115 try {116 const d = new Date(issued);117 if (Number.isNaN(d.getTime())) return "Active";118 return `Active since ${d.toLocaleDateString(undefined, { month: "long", year: "numeric" })}`;119 } catch {120 return "Active";121 }122}123124function initialSectionToCard(value: string | null | undefined): CardId | null {125 if (!value) return null;126 if (value === "security") return "protection";127 if (value === "license") return "pro";128 if (129 value === "protection" ||130 value === "vault" ||131 value === "sync" ||132 value === "appearance" ||133 value === "shortcuts" ||134 value === "pro" ||135 value === "feedback"136 ) {137 return value;138 }139 return null;140}141142export function SettingsView(props: SettingsProps) {143 const manifest = chrome.runtime.getManifest();144 const version = manifest.version;145 const initial = props.initialSection;146 const [activeCard, setActiveCard] = createSignal<CardId>(147 props.upgradeHighlight ? "pro" : initialSectionToCard(initial) ?? "pro"148 );149150 createEffect(() => { props.onCardChange?.(activeCard()); });151152 const [justActivated, setJustActivated] = createSignal(false);153 const [isFanning, setIsFanning] = createSignal(false);154155 function triggerCelebration() {156 if (isFanning()) return;157 setIsFanning(true);158 setActiveCard("pro");159 const fanOrder: CardId[] = ["protection", "vault", "sync", "appearance", "shortcuts", "feedback"];160 fanOrder.forEach((id, i) => {161 window.setTimeout(() => setActiveCard(id), (i + 1) * 80);162 });163 window.setTimeout(() => {164 setActiveCard("pro");165 setIsFanning(false);166 setJustActivated(true);167 window.setTimeout(() => setJustActivated(false), 2800);168 }, (fanOrder.length + 1) * 80);169 }170171 const [checkingBreaches, setCheckingBreaches] = createSignal(false);172 const [breachResult, setBreachResult] = createSignal<string | null>(null);173 const [licenseInfo, setLicenseInfo] = createSignal<LicenseInfo | null>(null);174 const [licenseKeyInput, setLicenseKeyInput] = createSignal("");175 const [licenseError, setLicenseError] = createSignal<string | null>(null);176 const [, setLicenseSuccess] = createSignal<string | null>(null);177 const [activatingLicense, setActivatingLicense] = createSignal(false);178179 const isPro = () => props.isPro ?? false;180 const cards = createMemo<StackCardSpec[]>(() => [181 {182 id: "pro",183 num: "I · FOREVER",184 title: "Peach Pro",185 lede: isPro() ? "active · paid once, yours forever" : "$39 once · yours forever",186 bgVar: "--stack-pro-bg",187 fgVar: "--stack-pro-fg",188 icon: "spark",189 onIconClick: isPro() ? triggerCelebration : undefined,190 },191 {192 id: "protection",193 num: "II · PROTECT",194 title: "Protection",195 lede: `${props.breachMonitoring ? "breaches watched" : "breach watch off"} · ${props.lockOnClose ? "locks on close" : "manual lock"}`,196 bgVar: "--stack-protect-bg",197 fgVar: "--stack-protect-fg",198 icon: "shield",199 },200 {201 id: "vault",202 num: "III · KEEP",203 title: "Vault",204 lede: `${props.entriesCount} ${props.entriesCount === 1 ? "entry" : "entries"} · backups, history, cleanup`,205 bgVar: "--stack-vault-bg",206 fgVar: "--stack-vault-fg",207 icon: "lock",208 },209 {210 id: "sync",211 num: "IV · ECHO",212 title: "Sync",213 lede: isPro() ? "pair by chirp, keep in step" : "Pro · encrypted device pairing",214 bgVar: "--stack-sync-bg",215 fgVar: "--stack-sync-fg",216 icon: "sync",217 },218 {219 id: "appearance",220 num: "V · DRESS",221 title: "Appearance",222 lede: `${props.theme} · ${props.accentColor} · ${props.textSize}`,223 bgVar: "--stack-appear-bg",224 fgVar: "--stack-appear-fg",225 icon: "sun",226 },227 {228 id: "shortcuts",229 num: "VI · KEYS",230 title: "Shortcuts",231 lede: `${SHORTCUT_DESCRIPTORS.length} bindings · what your fingers know`,232 bgVar: "--stack-shortcut-bg",233 fgVar: "--stack-shortcut-fg",234 icon: "keys",235 },236 {237 id: "feedback",238 num: "VII · ECHO",239 title: "Feedback",240 lede: `version ${version} · tell us what would make it better`,241 bgVar: "--stack-feedback-bg",242 fgVar: "--stack-feedback-fg",243 icon: "bubble",244 },245 ]);246 const [biometricSupported, setBiometricSupported] = createSignal(false);247 const [biometricStored, setBiometricStored] = createSignal(false);248 const [biometricBusy, setBiometricBusy] = createSignal(false);249 const [biometricError, setBiometricError] = createSignal<string | null>(null);250 const [passkeyState, setPasskeyState] = createSignal<PasskeyProviderState | null>(null);251 const [passkeySaving, setPasskeySaving] = createSignal(false);252253 const [dedupCount, setDedupCount] = createSignal<number | null>(null);254 const [dedupRunning, setDedupRunning] = createSignal(false);255 const [dedupDone, setDedupDone] = createSignal<number | null>(null);256257 const [declinedCardCount, setDeclinedCardCount] = createSignal(0);258259 260 261 262 const [historySnapshots, setHistorySnapshots] = createSignal<VaultSnapshot[]>([]);263 const [historyLoading, setHistoryLoading] = createSignal(false);264 const [historyRestoring, setHistoryRestoring] = createSignal<string | null>(null);265 const [historyShowAll, setHistoryShowAll] = createSignal(false);266267 async function loadVaultHistory() {268 setHistoryLoading(true);269 try {270 const res = await sendMessage({ type: "GET_VAULT_HISTORY" });271 const list = (res as { ok: boolean; data?: VaultSnapshot[] }).data ?? [];272 setHistorySnapshots(list);273 } finally {274 setHistoryLoading(false);275 }276 }277278 async function handleRestoreVaultSnapshot(id: string) {279 if (historyRestoring()) return;280 setHistoryRestoring(id);281 const res = await sendMessage({282 type: "RESTORE_VAULT_SNAPSHOT",283 payload: { snapshotId: id },284 });285 setHistoryRestoring(null);286 if ((res as { ok: boolean }).ok) {287 288 289 props.onBack();290 }291 }292293 function formatSnapshotTime(savedAt: number): string {294 const diff = Date.now() - savedAt;295 if (diff < 60_000) return "Just now";296 if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;297 const d = new Date(savedAt);298 const isToday = new Date().toDateString() === d.toDateString();299 const isYest =300 new Date(Date.now() - 86_400_000).toDateString() === d.toDateString();301 const t = d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });302 if (isToday) return `Today ${t}`;303 if (isYest) return `Yesterday ${t}`;304 return `${d.toLocaleDateString([], { month: "short", day: "numeric" })} ${t}`;305 }306307 function reasonLabel(reason: VaultSnapshot["reason"]): string {308 return (309 {310 local_save: "saved",311 sync_received: "synced",312 import: "imported",313 restore: "restored",314 } as const315 )[reason] ?? reason;316 }317318 const [showVaultAdvanced, setShowVaultAdvanced] = createSignal(false);319320 const [showChangePw, setShowChangePw] = createSignal(false);321 const [changePwCurrent, setChangePwCurrent] = createSignal("");322 const [changePwNew, setChangePwNew] = createSignal("");323 const [changePwConfirm, setChangePwConfirm] = createSignal("");324 const [changePwSaving, setChangePwSaving] = createSignal(false);325 const [changePwError, setChangePwError] = createSignal<string | null>(null);326 const [changePwSuccess] = createSignal(false);327 const [showNewRecoveryWords, setShowNewRecoveryWords] = createSignal(false);328329 const [duressEnabled, setDuressEnabled] = createSignal(false);330 const [showDuressSetup, setShowDuressSetup] = createSignal(false);331 const [duressPassword, setDuressPassword] = createSignal("");332 const [duressConfirm, setDuressConfirm] = createSignal("");333 const [duressMainPassword, setDuressMainPassword] = createSignal("");334 const [duressSaving, setDuressSaving] = createSignal(false);335 const [duressError, setDuressError] = createSignal<string | null>(null);336 const [duressSuccess, setDuressSuccess] = createSignal<string | null>(null);337338 const [canaryEntries, setCanaryEntries] = createSignal<Array<{id: string; title: string; canary: Canary}>>([]);339340 const [canaryTestingId, setCanaryTestingId] = createSignal<string | null>(null);341 const [canaryTestResult, setCanaryTestResult] = createSignal<{id: string; ok: boolean} | null>(null);342343 const [fbCategory, setFbCategory] = createSignal("Bug report");344 const [fbMessage, setFbMessage] = createSignal("");345 type FeedbackState = 'idle' | 'sending' | 'sent' | 'error';346 const [feedbackState, setFeedbackState] = createSignal<FeedbackState>('idle');347348 createEffect(() => {349 if (props.upgradeHighlight) {350 setActiveCard("pro");351 }352 });353354 onMount(async () => {355 const [licenseRes, passkeyRes, duressRes, settingsRes, keyRes] = await Promise.all([356 sendMessage({ type: "GET_LICENSE" }),357 sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }),358 sendMessage({ type: "IS_DURESS_ENABLED" }),359 sendMessage({ type: "GET_SETTINGS" }),360 sendMessage({ type: "GET_LICENSE_KEY" }),361 ]);362363 364 365 void loadVaultHistory();366367 if ((licenseRes as { ok: boolean }).ok) {368 setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null);369 }370371 if ((passkeyRes as { ok: boolean }).ok) {372 setPasskeyState(373 (passkeyRes as { ok: boolean; data?: PasskeyProviderState }).data ?? null374 );375 }376377 if ((duressRes as { ok: boolean }).ok) {378 setDuressEnabled((duressRes as { ok: boolean; data?: boolean }).data ?? false);379 }380381 if ((settingsRes as { ok: boolean }).ok) {382 const sData = (settingsRes as { ok: boolean; data?: { declinedCardFingerprints?: string[] } }).data;383 setDeclinedCardCount(sData?.declinedCardFingerprints?.length ?? 0);384 }385386 if ((keyRes as { ok: boolean }).ok) {387 setLicenseKey((keyRes as { ok: boolean; data?: string }).data ?? null);388 }389390 const entriesRes = await sendMessage({ type: "GET_ENTRIES" });391 if ((entriesRes as { ok: boolean }).ok) {392 const entries = (entriesRes as { ok: boolean; data?: Array<{id: string; title: string; canary?: Canary}> }).data ?? [];393 setCanaryEntries(394 entries395 .filter((e) => e.canary?.webhookUrl)396 .map((e) => ({ id: e.id, title: e.title, canary: e.canary! }))397 );398 }399400401 if (isNativePlatform()) {402 const biometric = nativePlugin<{ isAvailable: () => Promise<{ available: boolean }> }>("BiometricPlugin");403 const keystore = nativePlugin<{ hasMasterKey: () => Promise<{ exists: boolean }> }>("KeystorePlugin");404 try {405 if (biometric) {406 const a = await biometric.isAvailable();407 setBiometricSupported(a.available);408 }409 if (keystore) {410 const k = await keystore.hasMasterKey();411 setBiometricStored(k.exists);412 }413 } catch { }414 }415 });416417 async function handleEnableBiometrics() {418 setBiometricBusy(true);419 setBiometricError(null);420 try {421 const keyRes = (await sendMessage({ type: "GET_DERIVED_KEY" })) as {422 ok: boolean;423 data?: { masterKeyHex: string };424 error?: string;425 };426 if (!keyRes.ok || !keyRes.data?.masterKeyHex) {427 setBiometricError(keyRes.error === "vault_locked"428 ? "unlock the vault first"429 : "couldn't read derived key");430 return;431 }432 const keystore = nativePlugin<{ storeMasterKey: (p: { masterKeyHex: string }) => Promise<void> }>("KeystorePlugin");433 if (!keystore) {434 setBiometricError("biometric plugin unavailable");435 return;436 }437 await keystore.storeMasterKey({ masterKeyHex: keyRes.data.masterKeyHex });438 await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: true } });439 setBiometricStored(true);440 } catch (e) {441 setBiometricError(e instanceof Error ? e.message : "failed to enable biometrics");442 } finally {443 setBiometricBusy(false);444 }445 }446447 async function handleDisableBiometrics() {448 setBiometricBusy(true);449 setBiometricError(null);450 try {451 const keystore = nativePlugin<{ clearMasterKey: () => Promise<void> }>("KeystorePlugin");452 if (keystore) await keystore.clearMasterKey();453 await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: false } });454 setBiometricStored(false);455 } catch (e) {456 setBiometricError(e instanceof Error ? e.message : "failed to disable biometrics");457 } finally {458 setBiometricBusy(false);459 }460 }461462 async function handleChangePassword() {463 setChangePwError(null);464 if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; }465 if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; }466 if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; }467 if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; }468 setChangePwSaving(true);469 const res = await sendMessage({470 type: "CHANGE_PASSWORD",471 payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() },472 }) as { ok: boolean; error?: string };473 if (res.ok) {474 setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");475 setShowChangePw(false);476 setShowNewRecoveryWords(true);477 } else {478 setChangePwError(res.error ?? "failed to change password");479 }480 setChangePwSaving(false);481 }482483 const [copied, setCopied] = createSignal(false);484485 async function copyLicenseKey(key: string) {486 await navigator.clipboard.writeText(key);487 setCopied(true);488 setTimeout(() => setCopied(false), 2000);489 }490491 async function handleActivateLicense() {492 const key = licenseKeyInput().trim();493 if (!key) return;494 setActivatingLicense(true);495 setLicenseError(null);496 setLicenseSuccess(null);497 const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } });498 setActivatingLicense(false);499 if ((res as { ok: boolean }).ok) {500 const data = (res as { ok: boolean; data?: LicenseInfo }).data;501 if (data?.valid) {502 setLicenseInfo(data);503 setLicenseKey(key);504 setLicenseSuccess("License activated!");505 setLicenseKeyInput("");506 } else {507 setLicenseError("Invalid license key");508 }509 } else {510 setLicenseError("Activation failed");511 }512 }513514 async function handleTogglePasskeys() {515 const current = passkeyState();516 if (!current || !current.supported) return;517 setPasskeySaving(true);518 const res = await sendMessage({519 type: "SET_PASSKEY_PROVIDER_ENABLED",520 payload: { enabled: !current.enabled },521 });522 setPasskeySaving(false);523 if ((res as { ok: boolean }).ok) {524 setPasskeyState(525 (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null526 );527 }528 }529530 async function handleCheckAllBreaches() {531 setCheckingBreaches(true);532 setBreachResult(null);533 const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });534 setCheckingBreaches(false);535 if ((res as { ok: boolean }).ok) {536 const data = (res as { ok: boolean; data?: Record<string, number> }).data ?? {};537 const breached = Object.values(data).filter((count) => count > 0).length;538 setBreachResult(539 breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean"540 );541 props.onBreachCheckDone();542 }543 }544545546 async function handleSetupDuress() {547 const mainPw = duressMainPassword().trim();548 const duressPw = duressPassword().trim();549 const confirmPw = duressConfirm().trim();550551 if (!mainPw) { setDuressError("enter your main password to confirm"); return; }552 if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; }553 if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; }554 if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; }555556 setDuressSaving(true);557 setDuressError(null);558559 const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };560 if (!verifyRes.ok || !verifyRes.data) {561 setDuressError("incorrect main password");562 setDuressSaving(false);563 return;564 }565566 const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string };567 setDuressSaving(false);568 if (res.ok) {569 setDuressEnabled(true);570 setShowDuressSetup(false);571 setDuressPassword("");572 setDuressConfirm("");573 setDuressMainPassword("");574 setDuressSuccess("Duress password set. If entered, a decoy vault will be shown.");575 setTimeout(() => setDuressSuccess(null), 5000);576 } else {577 setDuressError(578 res.error === "session_expired"579 ? "Vault session expired — lock and unlock Peach, then try again"580 : res.error ?? "failed to set duress password"581 );582 }583 }584585 async function handleClearDeclinedCards() {586 await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } });587 setDeclinedCardCount(0);588 }589590 async function handleCountDuplicates() {591 setDedupRunning(true);592 setDedupDone(null);593 const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number };594 setDedupRunning(false);595 if (res.ok) setDedupCount(res.data ?? 0);596 }597598 async function handleDeduplicate() {599 setDedupRunning(true);600 const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number };601 setDedupRunning(false);602 if (res.ok) {603 setDedupDone(res.data ?? 0);604 setDedupCount(0);605 }606 }607608 async function handleDisableDuress() {609 const mainPw = duressMainPassword().trim();610 if (!mainPw) { setDuressError("enter your main password to confirm"); return; }611612 setDuressSaving(true);613 setDuressError(null);614615 const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };616 if (!verifyRes.ok || !verifyRes.data) {617 setDuressError("incorrect main password");618 setDuressSaving(false);619 return;620 }621622 const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string };623 setDuressSaving(false);624 if (res.ok) {625 setDuressEnabled(false);626 setShowDuressSetup(false);627 setDuressMainPassword("");628 setDuressSuccess("Duress password disabled.");629 setTimeout(() => setDuressSuccess(null), 4000);630 } else {631 setDuressError(res.error ?? "failed to disable duress password");632 }633 }634635 async function handleSubmitFeedback() {636 const message = sanitizeFeedbackMessage(fbMessage());637 if (!message) return;638639 setFeedbackState('sending');640641 try {642 const hasDataPermission = await requestFeedbackDataPermission();643 if (!hasDataPermission) {644 setFeedbackState('idle');645 return;646 }647648 const category = FEEDBACK_CATEGORIES.includes(fbCategory())649 ? fbCategory()650 : FEEDBACK_CATEGORIES[0];651 const res = await fetch('https://api.web3forms.com/submit', {652 method: 'POST',653 headers: { 'Content-Type': 'application/json' },654 body: JSON.stringify({655 access_key: WEB3FORMS_KEY,656 subject: `Peach Feedback - ${category}`,657 message,658 from_name: 'Peach User',659 660 botcheck: '',661 }),662 });663664 if (!res.ok) throw new Error(`HTTP ${res.status}`);665 const data = await res.json() as { success: boolean; message?: string };666 if (!data.success) throw new Error(data.message ?? 'unknown error');667668 setFeedbackState('sent');669 setFbMessage('');670 setFbCategory(FEEDBACK_CATEGORIES[0]);671 } catch (err) {672 console.error('[feedback]', err);673 setFeedbackState('error');674 setTimeout(() => setFeedbackState('idle'), 4000);675 }676 }677678 function renderSecurity() {679 return (680 <div class="peach-settings-body">681 <div class="peach-settings-group">682 <p class="peach-settings-sub-label">LOCKING</p>683 <div class="peach-settings-row">684 <span>Lock on close</span>685 <button686 class={`peach-toggle ${props.lockOnClose ? "is-on" : ""}`}687 onClick={() => void props.onLockOnCloseChange(!props.lockOnClose)}688 aria-label="Toggle lock on close"689 >690 <span class="peach-toggle-knob"></span>691 </button>692 </div>693 <div class="peach-settings-row">694 <span>Auto-lock after</span>695 <select696 class="peach-settings-select"697 value={String(props.autoLockMinutes)}698 onChange={(e) => void props.onAutoLockMinutesChange(Number(e.currentTarget.value))}699 >700 <option value="1">1 minute</option>701 <option value="5">5 minutes</option>702 <option value="15">15 minutes</option>703 <option value="30">30 minutes</option>704 <option value="0">Never</option>705 </select>706 </div>707708 <Show when={isNativePlatform() && biometricSupported()}>709 <p class="peach-settings-sub-label">BIOMETRICS</p>710 <div class="peach-settings-row">711 <span>Unlock with biometrics</span>712 <button713 class={`peach-toggle ${biometricStored() ? "is-on" : ""}`}714 onClick={() => void (biometricStored() ? handleDisableBiometrics() : handleEnableBiometrics())}715 disabled={biometricBusy()}716 aria-label="Toggle biometric unlock"717 >718 <span class="peach-toggle-knob"></span>719 </button>720 </div>721 <Show when={biometricError()}>722 <div class="peach-settings-row peach-settings-row--meta">723 <span class="peach-hint peach-hint-error">{biometricError()}</span>724 </div>725 </Show>726 </Show>727728 <p class="peach-settings-sub-label">BREACH PROTECTION</p>729 <div class="peach-settings-row">730 <span>Breach monitoring</span>731 <button732 class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}733 onClick={() => void props.onBreachMonitoringChange(!props.breachMonitoring)}734 aria-label="Toggle breach monitoring"735 >736 <span class="peach-toggle-knob"></span>737 </button>738 </div>739 <div class="peach-settings-row peach-settings-row--meta">740 <span>Check all passwords</span>741 <button742 class="peach-btn peach-btn--subtle peach-btn--sm"743 onClick={() => void handleCheckAllBreaches()}744 disabled={checkingBreaches()}745 >746 {checkingBreaches() ? "checking..." : "run"}747 </button>748 </div>749 <Show when={breachResult()}>750 <div class="peach-settings-row peach-settings-row--meta">751 <span>Last check</span>752 <span class="peach-settings-value">{breachResult()}</span>753 </div>754 </Show>755756 <p class="peach-settings-sub-label">MASTER PASSWORD</p>757 <div class="peach-settings-row peach-settings-row--meta">758 <span>Master password</span>759 <button760 class="peach-btn peach-btn--subtle peach-btn--sm"761 onClick={() => { setShowChangePw((v) => !v); setChangePwError(null); }}762 >763 {showChangePw() ? "cancel" : "change"}764 </button>765 </div>766 <Show when={showChangePw()}>767 <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">768 <input769 class="peach-settings-input"770 type="password"771 placeholder="current password"772 value={changePwCurrent()}773 onInput={(e) => setChangePwCurrent((e.target as HTMLInputElement).value)}774 autocomplete="current-password"775 />776 <input777 class="peach-settings-input"778 type="password"779 placeholder="new password (min 12 chars)"780 value={changePwNew()}781 onInput={(e) => setChangePwNew((e.target as HTMLInputElement).value)}782 autocomplete="new-password"783 />784 <input785 class="peach-settings-input"786 type="password"787 placeholder="confirm new password"788 value={changePwConfirm()}789 onInput={(e) => setChangePwConfirm((e.target as HTMLInputElement).value)}790 autocomplete="new-password"791 onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }}792 />793 <Show when={changePwError()}>794 <p class="peach-hint peach-hint-error" style="margin:0">{changePwError()}</p>795 </Show>796 <Show when={changePwSuccess()}>797 <p class="peach-hint" style="margin:0;color:var(--peach-accent)">password changed ✓</p>798 </Show>799 <button800 class="peach-btn peach-btn--accent peach-btn--sm"801 style="align-self:flex-start"802 onClick={() => void handleChangePassword()}803 disabled={changePwSaving() || !changePwCurrent() || !changePwNew() || !changePwConfirm()}804 >805 {changePwSaving() ? "saving…" : "save new password"}806 </button>807 </div>808 </Show>809810 <p class="peach-settings-sub-label">811 DURESS PASSWORD812 <Show when={!isPro()}>813 <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>814 </Show>815 </p>816 <div class="peach-settings-row peach-settings-row--top">817 <div class="peach-settings-row-main">818 <span>819 Duress password820 <Show when={!isPro()}>821 <span class="peach-pro-badge">Pro</span>822 </Show>823 </span>824 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">825 If entered under coercion, shows a decoy empty vault instead of your real one.826 </span>827 </div>828 <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>829 {duressEnabled() ? "on" : "off"}830 </div>831 </div>832833 <Show when={duressEnabled() && !showDuressSetup()}>834 <div class="peach-settings-row peach-settings-row--meta">835 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">836 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.837 </span>838 </div>839 </Show>840841 <Show when={duressSuccess()}>842 <div class="peach-settings-row peach-settings-row--meta">843 <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>844 </div>845 </Show>846847 <Show when={!showDuressSetup()}>848 <div class="peach-settings-row peach-settings-row--meta">849 <button850 class="peach-btn peach-btn--subtle peach-btn--sm"851 onClick={() => {852 853 854 855 856 857 if (!isPro() && !duressEnabled()) {858 props.onProGate?.("duress_vault");859 setActiveCard("pro");860 return;861 }862 setShowDuressSetup(true);863 setDuressError(null);864 setDuressSuccess(null);865 setDuressPassword("");866 setDuressConfirm("");867 setDuressMainPassword("");868 }}869 >870 {duressEnabled() ? "Change / disable" : "Set up"}871 </button>872 </div>873 </Show>874875 <Show when={showDuressSetup()}>876 <div class="peach-duress-form">877 <label class="peach-label">main password (to confirm)</label>878 <input879 class="peach-settings-input"880 type="password"881 placeholder="your current master password"882 autocomplete="current-password"883 value={duressMainPassword()}884 onInput={(e) => setDuressMainPassword(e.currentTarget.value)}885 />886 <Show when={!duressEnabled()}>887 <label class="peach-label">new duress password</label>888 <input889 class="peach-settings-input"890 type="password"891 placeholder="duress password (min 8 chars)"892 autocomplete="new-password"893 value={duressPassword()}894 onInput={(e) => setDuressPassword(e.currentTarget.value)}895 />896 <label class="peach-label">confirm duress password</label>897 <input898 class="peach-settings-input"899 type="password"900 placeholder="confirm duress password"901 autocomplete="new-password"902 value={duressConfirm()}903 onInput={(e) => setDuressConfirm(e.currentTarget.value)}904 />905 </Show>906907 <Show when={duressError()}>908 <p class="peach-hint peach-hint-error">{duressError()}</p>909 </Show>910911 <div class="peach-duress-actions">912 <button913 class="peach-btn peach-btn--subtle peach-btn--sm"914 onClick={() => { setShowDuressSetup(false); setDuressError(null); }}915 >916 cancel917 </button>918 <Show when={duressEnabled()}>919 <button920 class="peach-btn peach-btn--ghost peach-btn--sm"921 style="color:var(--peach-breach)"922 onClick={() => void handleDisableDuress()}923 disabled={duressSaving()}924 >925 {duressSaving() ? "disabling..." : "disable duress"}926 </button>927 </Show>928 <Show when={!duressEnabled()}>929 <button930 class="peach-btn peach-btn--accent peach-btn--sm"931 onClick={() => void handleSetupDuress()}932 disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}933 >934 {duressSaving() ? "saving..." : "save"}935 </button>936 </Show>937 </div>938 </div>939 </Show>940941 <p class="peach-settings-sub-label">942 CANARY ENTRIES943 <Show when={!isPro()}>944 <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>945 </Show>946 </p>947 <div class="peach-settings-row peach-settings-row--top">948 <div class="peach-settings-row-main">949 <span>950 Canary entries951 <Show when={!isPro()}>952 <span class="peach-pro-badge">Pro</span>953 </Show>954 </span>955 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">956 Silently pings a webhook when an entry is accessed. Configure canary on any entry in your vault.957 </span>958 </div>959 <span class="peach-settings-value" style="flex-shrink:0">960 {canaryEntries().length > 0 ? `${canaryEntries().length} active` : "none"}961 </span>962 </div>963 <Show when={canaryEntries().length > 0}>964 <For each={canaryEntries()}>965 {(entry) => (966 <div class="peach-settings-row peach-settings-row--meta">967 <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0">{entry.title}</span>968 <button969 class="peach-btn peach-btn--subtle peach-btn--sm"970 onClick={() => void handleTestCanary(entry)}971 disabled={canaryTestingId() === entry.id}972 style="flex-shrink:0"973 >974 {canaryTestingId() === entry.id975 ? "sending…"976 : canaryTestResult()?.id === entry.id977 ? (canaryTestResult()!.ok ? "sent ✓" : "failed ✗")978 : "test"}979 </button>980 </div>981 )}982 </For>983 </Show>984985 <p class="peach-settings-sub-label">PASSKEYS</p>986 <div class="peach-settings-row peach-settings-row--top">987 <div class="peach-settings-row-main">988 <span>Intercept passkey requests</span>989 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">990 Peach can handle passkey sign-in and creation on supported Chromium browsers.991 </span>992 </div>993 <button994 class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}995 onClick={() => void handleTogglePasskeys()}996 aria-label="Toggle passkey interception"997 disabled={!passkeyState()?.supported || passkeySaving()}998 >999 <span class="peach-toggle-knob"></span>1000 </button>1001 </div>1002 <div class="peach-settings-row peach-settings-row--meta">1003 <span>Status</span>1004 <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>1005 </div>1006 <div class="peach-settings-row peach-settings-row--meta">1007 <span>Browser</span>1008 <span class="peach-settings-value">1009 {passkeyState()?.browserLabel ?? "Unknown"}1010 {passkeyState()?.supported ? " ✓" : " (interception unavailable)"}1011 </span>1012 </div>1013 <div class="peach-info-notice" style="margin-top:8px">1014 Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.1015 </div>1016 <Show when={passkeyState()?.conflict}>1017 <div class="peach-settings-row peach-settings-row--meta">1018 <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>1019 </div>1020 </Show>1021 </div>1022 </div>1023 );1024 }10251026 function renderVault() {1027 return (1028 <div class="peach-settings-body">1029 <div class="peach-settings-group">1030 <p class="peach-settings-sub-label">ENTRIES</p>1031 <div class="peach-settings-row peach-settings-row--meta">1032 <span>Entries</span>1033 <span class="peach-settings-value">{props.entriesCount}</span>1034 </div>1035 <div class="peach-settings-row peach-settings-row--meta">1036 <span>Duplicates</span>1037 <Show when={dedupDone() !== null} fallback={1038 <button1039 class="peach-btn peach-btn--subtle peach-btn--sm"1040 onClick={() => void handleCountDuplicates()}1041 disabled={dedupRunning()}1042 >1043 {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}1044 </button>1045 }>1046 <span class="peach-settings-value" style="color:var(--peach-sync-ok)">1047 {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}1048 </span>1049 </Show>1050 </div>1051 <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>1052 <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">1053 <div class="peach-restore-warning">1054 {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.1055 </div>1056 <div style="display:flex;gap:6px">1057 <button1058 class="peach-btn peach-btn--subtle peach-btn--sm"1059 onClick={() => setDedupCount(null)}1060 >1061 cancel1062 </button>1063 <button1064 class="peach-btn peach-btn--accent peach-btn--sm"1065 onClick={() => void handleDeduplicate()}1066 disabled={dedupRunning()}1067 >1068 {dedupRunning() ? "removing..." : "remove duplicates"}1069 </button>1070 </div>1071 </div>1072 </Show>10731074 <p class="peach-settings-sub-label">MAINTENANCE</p>1075 <div class="peach-settings-row peach-settings-row--meta">1076 <span>Trash auto-delete</span>1077 <select1078 class="peach-settings-select"1079 value={String(props.trashRetentionDays)}1080 onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}1081 >1082 <option value="7">7 days</option>1083 <option value="30">30 days</option>1084 <option value="90">90 days</option>1085 <option value="0">Never</option>1086 </select>1087 </div>1088 <div class="peach-settings-row peach-settings-row--meta">1089 <span>Advanced Vault Health</span>1090 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>1091 open1092 </button>1093 </div>1094 <Show when={declinedCardCount() > 0}>1095 <div class="peach-settings-row peach-settings-row--meta">1096 <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>1097 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>1098 Clear1099 </button>1100 </div>1101 </Show>11021103 <p class="peach-settings-sub-label">DATA</p>1104 <div class="peach-settings-row peach-settings-row--meta">1105 <span>Import passwords</span>1106 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>1107 open1108 </button>1109 </div>1110 <div class="peach-settings-row peach-settings-row--meta">1111 <span>Export backup</span>1112 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onExport}>1113 open1114 </button>1115 </div>1116 <div class="peach-settings-row peach-settings-row--meta">1117 <span>1118 Peach Codex1119 <Show when={!isPro()}>1120 <span class="peach-pro-badge">Pro</span>1121 </Show>1122 </span>1123 <button1124 class="peach-btn peach-btn--subtle peach-btn--sm"1125 onClick={() => props.onOpenCodexGenerate?.()}1126 >1127 open1128 </button>1129 </div>1130 {1131}1132 <div class="peach-settings-row peach-settings-row--meta">1133 <span>Restore from Codex</span>1134 <button1135 class="peach-btn peach-btn--subtle peach-btn--sm"1136 onClick={() => props.onOpenCodexRestore?.()}1137 >1138 open1139 </button>1140 </div>11411142 <span class="peach-settings-sub-label">VAULT HISTORY</span>1143 <Show when={historyLoading()}>1144 <p class="peach-settings-hint">Loading…</p>1145 </Show>1146 <Show when={!historyLoading() && historySnapshots().length === 0}>1147 <p class="peach-settings-hint">1148 No snapshots yet. Peach saves automatically before every change —1149 saves, syncs, and imports.1150 </p>1151 </Show>1152 <Show when={!historyLoading() && historySnapshots().length > 0}>1153 <p class="peach-settings-hint">1154 Restoring locks your vault. Unlock with the password that1155 encrypted that snapshot to confirm.1156 </p>1157 <div class="peach-history-list">1158 <For each={historySnapshots().slice(0, historyShowAll() ? undefined : 3)}>1159 {(snap) => (1160 <div class="peach-history-row">1161 <div class="peach-history-meta">1162 <span class="peach-history-time">1163 {formatSnapshotTime(snap.savedAt)}1164 </span>1165 <span class="peach-history-sub">1166 {[1167 snap.entryCount !== null1168 ? `${snap.entryCount} ${snap.entryCount === 1 ? "entry" : "entries"}`1169 : null,1170 reasonLabel(snap.reason),1171 ]1172 .filter(Boolean)1173 .join(" · ")}1174 </span>1175 </div>1176 <button1177 class={`peach-btn peach-btn--subtle peach-btn--sm peach-history-btn${1178 historyRestoring() === snap.id ? " is-busy" : ""1179 }`}1180 onClick={() => void handleRestoreVaultSnapshot(snap.id)}1181 disabled={historyRestoring() !== null}1182 >1183 <Show when={historyRestoring() === snap.id} fallback="Restore">1184 …1185 </Show>1186 </button>1187 </div>1188 )}1189 </For>1190 </div>1191 <Show when={historySnapshots().length > 3}>1192 <button1193 class="peach-settings-advanced-toggle"1194 onClick={() => setHistoryShowAll((v) => !v)}1195 aria-expanded={historyShowAll()}1196 >1197 <span class="peach-settings-hint" style="margin:0">1198 {historyShowAll() ? "show less" : `${historySnapshots().length - 3} older`}1199 </span>1200 <span class="peach-settings-advanced-chevron">1201 {historyShowAll() ? "▲" : "▼"}1202 </span>1203 </button>1204 </Show>1205 <p class="peach-history-footer">1206 {historySnapshots().length} of 10 snapshots1207 </p>1208 </Show>12091210 <button1211 class="peach-settings-advanced-toggle"1212 onClick={() => setShowVaultAdvanced((v) => !v)}1213 aria-expanded={showVaultAdvanced()}1214 >1215 <span class="peach-settings-sub-label" style="margin:0">ADVANCED</span>1216 <span class="peach-settings-advanced-chevron">1217 {showVaultAdvanced() ? "▲" : "▼"}1218 </span>1219 </button>12201221 <Show when={showVaultAdvanced()}>1222 <div class="peach-danger-card">1223 <div class="peach-danger-card-head">1224 <span class="peach-danger-card-glyph" aria-hidden="true">⟳</span>1225 <h3 class="peach-danger-card-title">Replace vault from backup</h3>1226 </div>1227 <p class="peach-danger-card-copy">1228 Wipes the current vault and restores from a backup file.1229 Recoverable only if your backup is good.1230 </p>1231 <button1232 class="peach-danger-card-button"1233 onClick={() => props.onOpenFileImport?.()}1234 >1235 Choose backup file1236 </button>1237 </div>12381239 <Show when={props.onOpenPanic}>1240 <div class="peach-danger-card peach-danger-card--hazard">1241 <div class="peach-danger-card-head">1242 <span class="peach-danger-card-glyph" aria-hidden="true">⚠</span>1243 <h3 class="peach-danger-card-title">Destroy vault</h3>1244 </div>1245 <p class="peach-danger-card-copy">1246 Permanently wipes every entry, setting, and license on this1247 device. There is no recovery.1248 </p>1249 <button1250 class="peach-danger-card-button"1251 onClick={() => props.onOpenPanic?.()}1252 >1253 Begin destruction1254 </button>1255 </div>1256 </Show>1257 </Show>1258 </div>1259 </div>1260 );1261 }12621263 function renderSync() {1264 return (1265 <div class="peach-settings-body">1266 <div class="peach-settings-group">1267 <p class="peach-settings-sub-label">PAIRING</p>1268 <div class="peach-settings-row peach-settings-row--top">1269 <div class="peach-settings-row-main">1270 <span>1271 Peach Sync1272 <Show when={!isPro()}>1273 <span class="peach-pro-badge">Pro</span>1274 </Show>1275 </span>1276 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1277 Pair nearby devices by chirp and keep encrypted vault blobs in step without a central account.1278 </span>1279 </div>1280 <span class={`stk-pip ${isPro() ? "is-on" : ""}`} aria-hidden="true" />1281 </div>1282 <div class="peach-settings-row peach-settings-row--meta">1283 <span>Status</span>1284 <span class="peach-settings-value">{isPro() ? "Ready" : "Requires Pro"}</span>1285 </div>1286 <div class="peach-settings-row peach-settings-row--meta">1287 <span>Sync hub</span>1288 <button1289 class="peach-btn peach-btn--subtle peach-btn--sm"1290 onClick={() => props.onOpenSyncPairing?.()}1291 >1292 open1293 </button>1294 </div>12951296 <p class="peach-settings-sub-label">DEVICE FLOW</p>1297 <div class="peach-settings-row peach-settings-row--top">1298 <div class="peach-settings-row-main">1299 <span>Pair new device</span>1300 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1301 Opens the full chirp pairing console with scan, show-code, and advanced diagnostics.1302 </span>1303 </div>1304 <button1305 class="peach-btn peach-btn--subtle peach-btn--sm"1306 onClick={() => props.onOpenSyncPairing?.()}1307 >1308 pair1309 </button>1310 </div>1311 <div class="peach-info-notice" style="margin-top:8px">1312 Sync uses the same zero-knowledge vault format as backup and restore; pairing authorizes a device, not a cloud account.1313 </div>1314 </div>1315 </div>1316 );1317 }13181319 function renderAppearance() {1320 const sizeOptions: Array<{ id: "medium" | "large"; label: string }> = [1321 { id: "medium", label: "Default" },1322 { id: "large", label: "Large" },1323 ];1324 return (1325 <div class="peach-settings-body">1326 <div class="peach-settings-group">1327 <p class="peach-settings-sub-label">THEME</p>1328 <div class="peach-settings-row peach-settings-row--meta">1329 <span>Theme</span>1330 <button1331 class="peach-btn peach-btn--subtle peach-btn--sm"1332 onClick={() =>1333 props.onThemeChange(props.theme === "dark" ? "light" : "dark")1334 }1335 >1336 {props.theme === "dark" ? "Switch to light" : "Switch to dark"}1337 </button>1338 </div>13391340 <p class="peach-settings-sub-label">ACCENT</p>1341 <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">1342 <span>Accent</span>1343 <div class="peach-accent-picker">1344 <For each={ACCENT_COLORS}>1345 {(color) => (1346 <button1347 class="peach-accent-swatch peach-swatch"1348 classList={{ "is-active": props.accentColor === color.name }}1349 style={{ background: color.hex }}1350 onClick={() => void props.onAccentChange(color.name)}1351 aria-label={color.name}1352 title={color.name}1353 />1354 )}1355 </For>1356 </div>1357 </div>13581359 <p class="peach-settings-sub-label">TEXT SIZE</p>1360 <div class="peach-settings-row peach-settings-row--meta">1361 <span>Size</span>1362 <div class="peach-pill-group">1363 <For each={sizeOptions}>1364 {(opt) => (1365 <button1366 class="peach-btn peach-btn--ghost peach-btn--sm"1367 classList={{ "is-active": props.textSize === opt.id }}1368 onClick={() => void props.onTextSizeChange(opt.id)}1369 >1370 {opt.label}1371 </button>1372 )}1373 </For>1374 </div>1375 </div>13761377 <p class="peach-settings-sub-label">MOTION</p>1378 <div class="peach-settings-row peach-settings-row--meta">1379 <span>Reduce motion</span>1380 <button1381 class="peach-btn peach-btn--subtle peach-btn--sm"1382 onClick={() => void props.onReduceMotionChange(!props.reduceMotion)}1383 >1384 {props.reduceMotion ? "On" : "Off"}1385 </button>1386 </div>1387 </div>1388 </div>1389 );1390 }13911392 function renderShortcuts() {1393 const [capturingAction, setCapturingAction] = createSignal<ShortcutAction | null>(null);1394 const [conflictMsg, setConflictMsg] = createSignal<string | null>(null);13951396 function startCapture(action: ShortcutAction) {1397 setConflictMsg(null);1398 setCapturingAction(action);1399 }1400 function cancelCapture() {1401 setCapturingAction(null);1402 setConflictMsg(null);1403 }1404 function commitBinding(action: ShortcutAction, binding: Keybinding) {1405 const conflict = findConflict(props.shortcuts, binding, action);1406 if (conflict) {1407 const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict);1408 setConflictMsg(`Already bound to "${other?.label ?? conflict}"`);1409 return;1410 }1411 const next = { ...props.shortcuts, [action]: binding };1412 setCapturingAction(null);1413 setConflictMsg(null);1414 void props.onShortcutsChange(next);1415 }1416 function resetAction(action: ShortcutAction) {1417 const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] };1418 void props.onShortcutsChange(next);1419 }1420 function resetAll() {1421 void props.onShortcutsChange({ ...DEFAULT_BINDINGS });1422 }14231424 1425 1426 function handleCaptureKey(e: KeyboardEvent) {1427 e.preventDefault();1428 e.stopPropagation();1429 if (e.key === "Escape") { cancelCapture(); return; }1430 const binding = keybindingFromEvent(e);1431 if (!binding) return; 1432 const action = capturingAction();1433 if (action) commitBinding(action, binding);1434 }14351436 return (1437 <div class="peach-settings-body">1438 <div class="peach-settings-group peach-shortcuts-group">1439 <p class="peach-settings-sub-label">ENTRY VIEW</p>1440 <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry")}>1441 {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1442 </For>1443 </div>14441445 <div class="peach-settings-group peach-shortcuts-group">1446 <p class="peach-settings-sub-label">ANYWHERE IN POPUP</p>1447 <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global")}>1448 {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1449 </For>1450 </div>14511452 <Show when={conflictMsg()}>1453 <div class="peach-shortcuts-conflict">{conflictMsg()}</div>1454 </Show>14551456 <div class="peach-settings-group" style="display:flex;flex-direction:column;gap:8px">1457 <div class="peach-settings-row peach-settings-row--meta">1458 <span>Inline autofill on page</span>1459 <a1460 class="peach-settings-link"1461 href="chrome://extensions/shortcuts"1462 onClick={(e) => {1463 e.preventDefault();1464 chrome.tabs.create({ url: "chrome://extensions/shortcuts" });1465 }}1466 >1467 ⌘⇧P — customize ↗1468 </a>1469 </div>1470 <button1471 class="peach-btn peach-btn--subtle peach-btn--sm"1472 style="align-self:flex-start"1473 onClick={resetAll}1474 >1475 Reset all to defaults1476 </button>1477 </div>1478 </div>1479 );14801481 function renderRow(1482 desc: typeof SHORTCUT_DESCRIPTORS[number],1483 capturing: () => ShortcutAction | null,1484 onStart: (a: ShortcutAction) => void,1485 onCancel: () => void,1486 onReset: (a: ShortcutAction) => void,1487 onKey: (e: KeyboardEvent) => void,1488 ) {1489 const isActive = () => capturing() === desc.action;1490 const binding = () => props.shortcuts[desc.action];1491 const isCustom = () => {1492 const b = binding();1493 const d = DEFAULT_BINDINGS[desc.action];1494 return b.key !== d.key1495 || Boolean(b.ctrl) !== Boolean(d.ctrl)1496 || Boolean(b.meta) !== Boolean(d.meta)1497 || Boolean(b.shift) !== Boolean(d.shift)1498 || Boolean(b.alt) !== Boolean(d.alt);1499 };1500 return (1501 <div class="peach-shortcut-row">1502 <span class="peach-shortcut-row-label">{desc.label}</span>1503 <button1504 class="peach-shortcut-chip"1505 classList={{ "is-capturing": isActive(), "is-custom": isCustom() }}1506 onClick={() => isActive() ? onCancel() : onStart(desc.action)}1507 onKeyDown={(e) => { if (isActive()) onKey(e); }}1508 ref={(el) => {1509 1510 if (el) {1511 queueMicrotask(() => { if (isActive()) el.focus(); });1512 }1513 }}1514 >1515 {isActive() ? "Press a key…" : formatKeybinding(binding())}1516 </button>1517 <Show when={isCustom() && !isActive()}>1518 <button1519 class="peach-shortcut-reset"1520 title="Reset to default"1521 onClick={() => onReset(desc.action)}1522 >1523 ↺1524 </button>1525 </Show>1526 </div>1527 );1528 }1529 }15301531 const PRO_FEATURES: Array<{1532 feature: ProFeature;1533 name: string;1534 desc: string;1535 }> = [1536 { feature: "sync", name: "Peach Sync", desc: "Encrypted · peer-to-peer" },1537 { feature: "codex", name: "Peach Codex", desc: "Restore from paper" },1538 { feature: "passkeys", name: "Passkeys", desc: "Zero-knowledge WebAuthn" },1539 { feature: "advanced_health", name: "Advanced Vault Health", desc: "Reuse · age · exposure" },1540 ];15411542 const PRO_SECURITY_FEATURES: Array<{1543 feature: ProFeature;1544 name: string;1545 desc: string;1546 }> = [1547 { feature: "canary_entry", name: "Canary entries", desc: "Detects vault compromise" },1548 { feature: "duress_vault", name: "Duress vault", desc: "Decoy under coercion" },1549 ];15501551 const [licenseKey, setLicenseKey] = createSignal<string | null>(null);1552 const [showKey, setShowKey] = createSignal(false);15531554 const [deactivating, setDeactivating] = createSignal(false);15551556 async function handleDeactivateLicense() {1557 setDeactivating(true);1558 await sendMessage({ type: "DEACTIVATE_LICENSE" });1559 setLicenseInfo(null);1560 await props.onLicenseChange?.();1561 setDeactivating(false);1562 }15631564 async function handleTestCanary(entry: {id: string; title: string; canary: Canary}) {1565 setCanaryTestingId(entry.id);1566 setCanaryTestResult(null);1567 const res = await sendMessage({1568 type: "FIRE_CANARY_PING",1569 payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger: "test" },1570 }) as { ok: boolean };1571 setCanaryTestResult({ id: entry.id, ok: res.ok });1572 setCanaryTestingId(null);1573 setTimeout(() => setCanaryTestResult((r) => r?.id === entry.id ? null : r), 3000);1574 }15751576 function renderLicense() {1577 async function onActivate() {1578 await handleActivateLicense();1579 if ((licenseInfo()?.valid ?? false) && licenseInfo()?.plan === "pro") {1580 await props.onLicenseChange?.();1581 triggerCelebration();1582 }1583 }15841585 return (1586 <div class="peach-settings-body">1587 <Show1588 when={isPro()}1589 fallback={1590 <div class="peach-pro-upgrade">1591 <p class="peach-pro-eyebrow">PEACH PRO · ONE-TIME</p>15921593 <h2 class="peach-pro-headline">1594 Every<em>thing,</em><br />every<em>where.</em>1595 </h2>15961597 <p class="peach-pro-subtitle">1598 One key unlocks every Pro feature — for the lifetime of the app.1599 </p>16001601 <div class="peach-pro-grid">1602 <For each={PRO_FEATURES}>1603 {(item, i) => (1604 <div1605 class="peach-pro-grid-item"1606 classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}1607 >1608 <span class="peach-pro-grid-num">{String(i() + 1).padStart(2, "0")}</span>1609 <span class="peach-pro-grid-name">{item.name}</span>1610 <span class="peach-pro-grid-desc">{item.desc}</span>1611 </div>1612 )}1613 </For>1614 </div>16151616 <p class="peach-pro-section-label">ADVANCED SECURITY</p>1617 <div class="peach-pro-grid">1618 <For each={PRO_SECURITY_FEATURES}>1619 {(item, i) => (1620 <div1621 class="peach-pro-grid-item"1622 classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}1623 >1624 <span class="peach-pro-grid-num">{String(PRO_FEATURES.length + i() + 1).padStart(2, "0")}</span>1625 <span class="peach-pro-grid-name">{item.name}</span>1626 <span class="peach-pro-grid-desc">{item.desc}</span>1627 </div>1628 )}1629 </For>1630 </div>16311632 <div class="peach-pro-upgrade-actions">1633 <p class="peach-pro-pricing">1634 $39 <span class="peach-pro-pricing-sub">once · yours forever</span>1635 </p>1636 <a1637 href="https://peachpasswords.com/#pricing"1638 target="_blank"1639 rel="noopener noreferrer"1640 class="peach-pro-upgrade-cta"1641 >1642 Get Peach Pro1643 </a>1644 <div class="peach-pro-key-divider"><span>ALREADY HAVE A KEY</span></div>1645 <div class="peach-pro-key-inline">1646 <input1647 class="peach-pro-key-inline-input"1648 type="text"1649 placeholder="PEACH-XXXX-XXXX-XXXX"1650 value={licenseKeyInput()}1651 onInput={(e) => setLicenseKeyInput(e.currentTarget.value)}1652 onKeyDown={(e) => { if (e.key === "Enter") void onActivate(); }}1653 autocomplete="off"1654 spellcheck={false}1655 />1656 <button1657 class="peach-pro-key-apply"1658 onClick={() => void onActivate()}1659 disabled={activatingLicense() || !licenseKeyInput().trim()}1660 >1661 {activatingLicense() ? "…" : "APPLY"}1662 </button>1663 </div>1664 <Show when={licenseError()}>1665 <p class="peach-pro-key-error">{licenseError()}</p>1666 </Show>1667 </div>1668 </div>1669 }1670 >1671 <div class="peach-pro-active">1672 <div class="peach-pro-active-hero">1673 <button1674 class="peach-pro-active-mark-wrap"1675 type="button"1676 aria-label="Celebrate"1677 onClick={() => triggerCelebration()}1678 >1679 <PeachMark class="peach-pro-active-mark" />1680 <Show when={justActivated()}>1681 <div class="peach-pro-sparks" aria-hidden="true">1682 <span class="peach-pro-spark" style="--spark-x:0px;--spark-y:-54px;--spark-delay:0ms">✦</span>1683 <span class="peach-pro-spark" style="--spark-x:38px;--spark-y:-38px;--spark-delay:50ms">✦</span>1684 <span class="peach-pro-spark" style="--spark-x:54px;--spark-y:0px;--spark-delay:110ms">·</span>1685 <span class="peach-pro-spark" style="--spark-x:38px;--spark-y:38px;--spark-delay:30ms">✦</span>1686 <span class="peach-pro-spark" style="--spark-x:0px;--spark-y:54px;--spark-delay:80ms">·</span>1687 <span class="peach-pro-spark" style="--spark-x:-38px;--spark-y:38px;--spark-delay:130ms">✦</span>1688 <span class="peach-pro-spark" style="--spark-x:-54px;--spark-y:0px;--spark-delay:55ms">·</span>1689 <span class="peach-pro-spark" style="--spark-x:-38px;--spark-y:-38px;--spark-delay:95ms">✦</span>1690 </div>1691 </Show>1692 </button>1693 <h2 class="peach-pro-active-title">Peach Pro</h2>1694 <p class="peach-pro-active-since">{formatActivatedSince(licenseInfo()?.issued)}</p>1695 <Show when={justActivated()}>1696 <p class="peach-pro-thankyou-text" aria-live="polite">thank you ✦</p>1697 </Show>1698 </div>16991700 <Show when={licenseKey()}>1701 <div class="peach-settings-license">1702 <span class="peach-settings-label">LICENSE KEY</span>1703 <div class="peach-settings-license-row">1704 <Show1705 when={showKey()}1706 fallback={1707 <span class="peach-settings-license-key peach-settings-license-key--masked" aria-label="License key hidden">1708 ••••••••••••••••••••••••••••1709 </span>1710 }1711 >1712 <span class="peach-settings-license-key">{licenseKey()!}</span>1713 </Show>1714 <div class="peach-settings-license-actions">1715 <button1716 class="peach-settings-license-copy"1717 title="Copy license key"1718 onClick={() => copyLicenseKey(licenseKey()!)}1719 >1720 {copied() ? "✓" : "copy"}1721 </button>1722 <button1723 class="peach-settings-license-toggle"1724 aria-label={showKey() ? "Hide license key" : "Reveal license key"}1725 title={showKey() ? "Hide" : "Reveal"}1726 onClick={() => setShowKey(v => !v)}1727 >1728 <Show1729 when={showKey()}1730 fallback={1731 <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">1732 <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />1733 <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />1734 </svg>1735 }1736 >1737 <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">1738 <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />1739 <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />1740 <line x1="2" y1="2" x2="12" y2="12" />1741 </svg>1742 </Show>1743 </button>1744 </div>1745 </div>1746 </div>1747 </Show>17481749 <div class="peach-pro-active-features">1750 <For each={[...PRO_FEATURES, ...PRO_SECURITY_FEATURES]}>1751 {(item) => (1752 <div class="peach-pro-active-feature">1753 <span class="peach-pro-active-feature-mark">✦</span>1754 <span class="peach-pro-active-feature-name">{item.name}</span>1755 </div>1756 )}1757 </For>1758 </div>17591760 <button1761 class="peach-pro-deactivate"1762 onClick={() => void handleDeactivateLicense()}1763 disabled={deactivating()}1764 >1765 {deactivating() ? "Deactivating…" : "Deactivate"}1766 </button>1767 </div>1768 </Show>1769 </div>1770 );1771 }17721773 function renderFeedback() {1774 return (1775 <div class="peach-settings-body">1776 <div class="peach-settings-group peach-feedback-group">1777 <Show1778 when={feedbackState() === 'sent'}1779 fallback={1780 <>1781 <p class="peach-feedback-intro">1782 Help us improve Peach. Every report goes directly to our inbox.1783 </p>17841785 <p class="peach-settings-sub-label">TYPE</p>1786 <div class="peach-feedback-chips">1787 <For each={FEEDBACK_CATEGORIES}>1788 {(cat) => {1789 const selected = () => fbCategory() === cat;1790 return (1791 <button1792 type="button"1793 class={`peach-feedback-chip ${selected() ? "is-active" : ""}`}1794 aria-pressed={selected()}1795 onClick={() => setFbCategory(cat)}1796 >1797 {cat}1798 </button>1799 );1800 }}1801 </For>1802 </div>18031804 <p class="peach-settings-sub-label">MESSAGE</p>1805 <textarea1806 class="peach-feedback-textarea"1807 placeholder="Describe the issue or idea..."1808 maxLength={MAX_FEEDBACK_MESSAGE_LENGTH}1809 value={fbMessage()}1810 onInput={(e) => setFbMessage(e.currentTarget.value)}1811 />18121813 <p class="peach-feedback-meta">1814 Version {version} · will be included automatically1815 </p>18161817 <button1818 type="button"1819 class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"1820 disabled={!fbMessage().trim() || feedbackState() === 'sending'}1821 onClick={() => void handleSubmitFeedback()}1822 >1823 <Switch>1824 <Match when={feedbackState() === 'idle'}>Send</Match>1825 <Match when={feedbackState() === 'sending'}>Sending...</Match>1826 <Match when={feedbackState() === 'error'}>Failed - try again</Match>1827 </Switch>1828 </button>18291830 <p class="peach-feedback-anon">1831 Sent anonymously · no account required1832 </p>1833 </>1834 }1835 >1836 <div class="peach-feedback-thanks" role="status" aria-live="polite">1837 <p class="peach-feedback-thanks-title">Thank you</p>1838 <p>1839 Your note is in the queue. Someone will process it soon.1840 </p>1841 </div>1842 </Show>1843 </div>1844 </div>1845 );1846 }18471848 const bodies: StackBodies = {1849 protection: renderSecurity(),1850 vault: renderVault(),1851 sync: renderSync(),1852 appearance: renderAppearance(),1853 shortcuts: renderShortcuts(),1854 pro: renderLicense(),1855 feedback: renderFeedback(),1856 };18571858 return (1859 <Show1860 when={showNewRecoveryWords()}1861 fallback={1862 <div1863 class="peach-overlay-view peach-settings-overlay"1864 classList={{ "ov-enter-right": !props.isExiting, "ov-exit-right": !!props.isExiting }}1865 style="display:flex;flex-direction:column"1866 >1867 <StackDeck1868 active={activeCard()}1869 onActiveChange={setActiveCard}1870 onBack={props.onBack}1871 cards={cards()}1872 bodies={bodies}1873 version={version}1874 isFanning={isFanning()}1875 celebratingCardId={justActivated() ? "pro" : null}1876 isPro={isPro()}1877 />1878 </div>1879 }1880 >1881 <RecoveryWordDisplayView onDone={() => setShowNewRecoveryWords(false)} />1882 </Show>1883 );1884}