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;78const PRO_CELEBRATION_CARD_STEP_MS = 150;79const PRO_CELEBRATION_THANK_YOU_MS = 3200;8081function sanitizeFeedbackMessage(value: string): string {82 return value83 .trim()84 .slice(0, MAX_FEEDBACK_MESSAGE_LENGTH)85 .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "")86 .replace(/&/g, "&")87 .replace(/</g, "<")88 .replace(/>/g, ">")89 .replace(/"/g, """)90 .replace(/'/g, "'");91}9293type DataCollectionPermissionApi = {94 permissions?: {95 getAll?: () => Promise<{ data_collection?: string[] }>;96 request?: (permissions: { data_collection: string[] }) => Promise<boolean>;97 };98};99100async function requestFeedbackDataPermission(): Promise<boolean> {101 const api = ((globalThis as { browser?: DataCollectionPermissionApi }).browser ??102 (typeof chrome !== "undefined" ? chrome : undefined)) as DataCollectionPermissionApi | undefined;103 if (!api?.permissions?.getAll) return true;104105 try {106 const permissions = await api.permissions.getAll();107 if (!("data_collection" in permissions)) return true;108 if (permissions.data_collection?.includes(FEEDBACK_DATA_PERMISSION)) return true;109 return await (api.permissions.request?.({ data_collection: [FEEDBACK_DATA_PERMISSION] }) ?? Promise.resolve(false));110 } catch {111 return false;112 }113}114115function formatActivatedSince(issued: string | undefined): string {116 if (!issued) return "Active";117 try {118 const d = new Date(issued);119 if (Number.isNaN(d.getTime())) return "Active";120 return `Active since ${d.toLocaleDateString(undefined, { month: "long", year: "numeric" })}`;121 } catch {122 return "Active";123 }124}125126function initialSectionToCard(value: string | null | undefined): CardId | null {127 if (!value) return null;128 if (value === "security") return "protection";129 if (value === "license") return "pro";130 if (131 value === "protection" ||132 value === "vault" ||133 value === "sync" ||134 value === "appearance" ||135 value === "shortcuts" ||136 value === "pro" ||137 value === "feedback"138 ) {139 return value;140 }141 return null;142}143144export function SettingsView(props: SettingsProps) {145 const manifest = chrome.runtime.getManifest();146 const version = manifest.version;147 const initial = props.initialSection;148 const [activeCard, setActiveCard] = createSignal<CardId>(149 props.upgradeHighlight ? "pro" : initialSectionToCard(initial) ?? "pro"150 );151152 createEffect(() => { props.onCardChange?.(activeCard()); });153154 const [justActivated, setJustActivated] = createSignal(false);155 const [isFanning, setIsFanning] = createSignal(false);156157 function triggerCelebration() {158 if (isFanning()) return;159 setJustActivated(false);160 setIsFanning(true);161 setActiveCard("pro");162 const fanOrder: CardId[] = ["protection", "vault", "sync", "appearance", "shortcuts", "feedback"];163 fanOrder.forEach((id, i) => {164 window.setTimeout(() => setActiveCard(id), (i + 1) * PRO_CELEBRATION_CARD_STEP_MS);165 });166 window.setTimeout(() => {167 setActiveCard("pro");168 setIsFanning(false);169 setJustActivated(true);170 window.setTimeout(() => setJustActivated(false), PRO_CELEBRATION_THANK_YOU_MS);171 }, (fanOrder.length + 1) * PRO_CELEBRATION_CARD_STEP_MS);172 }173174 function handleProCelebrationClick(e: MouseEvent) {175 e.stopPropagation();176 triggerCelebration();177 }178179 const [checkingBreaches, setCheckingBreaches] = createSignal(false);180 const [breachResult, setBreachResult] = createSignal<string | null>(null);181 const [licenseInfo, setLicenseInfo] = createSignal<LicenseInfo | null>(null);182 const [licenseKeyInput, setLicenseKeyInput] = createSignal("");183 const [licenseError, setLicenseError] = createSignal<string | null>(null);184 const [, setLicenseSuccess] = createSignal<string | null>(null);185 const [activatingLicense, setActivatingLicense] = createSignal(false);186187 const isPro = () => props.isPro ?? false;188 const cards = createMemo<StackCardSpec[]>(() => [189 {190 id: "pro",191 num: "I · FOREVER",192 title: "Peach Pro",193 lede: isPro() ? "active · paid once, yours forever" : "$39 once · yours forever",194 bgVar: "--stack-pro-bg",195 fgVar: "--stack-pro-fg",196 icon: "spark",197 onIconClick: isPro() ? triggerCelebration : undefined,198 },199 {200 id: "protection",201 num: "II · PROTECT",202 title: "Protection",203 lede: `${props.breachMonitoring ? "breaches watched" : "breach watch off"} · ${props.lockOnClose ? "locks on close" : "manual lock"}`,204 bgVar: "--stack-protect-bg",205 fgVar: "--stack-protect-fg",206 icon: "shield",207 },208 {209 id: "vault",210 num: "III · KEEP",211 title: "Vault",212 lede: `${props.entriesCount} ${props.entriesCount === 1 ? "entry" : "entries"} · backups, history, cleanup`,213 bgVar: "--stack-vault-bg",214 fgVar: "--stack-vault-fg",215 icon: "lock",216 },217 {218 id: "sync",219 num: "IV · ECHO",220 title: "Sync",221 lede: isPro() ? "pair by chirp, keep in step" : "Pro · encrypted device pairing",222 bgVar: "--stack-sync-bg",223 fgVar: "--stack-sync-fg",224 icon: "sync",225 },226 {227 id: "appearance",228 num: "V · DRESS",229 title: "Appearance",230 lede: `${props.theme} · ${props.accentColor} · ${props.textSize}`,231 bgVar: "--stack-appear-bg",232 fgVar: "--stack-appear-fg",233 icon: "sun",234 },235 {236 id: "shortcuts",237 num: "VI · KEYS",238 title: "Shortcuts",239 lede: `${SHORTCUT_DESCRIPTORS.length} bindings · what your fingers know`,240 bgVar: "--stack-shortcut-bg",241 fgVar: "--stack-shortcut-fg",242 icon: "keys",243 },244 {245 id: "feedback",246 num: "VII · ECHO",247 title: "Feedback",248 lede: `version ${version} · tell us what would make it better`,249 bgVar: "--stack-feedback-bg",250 fgVar: "--stack-feedback-fg",251 icon: "bubble",252 },253 ]);254 const [biometricSupported, setBiometricSupported] = createSignal(false);255 const [biometricStored, setBiometricStored] = createSignal(false);256 const [biometricBusy, setBiometricBusy] = createSignal(false);257 const [biometricError, setBiometricError] = createSignal<string | null>(null);258 const [passkeyState, setPasskeyState] = createSignal<PasskeyProviderState | null>(null);259 const [passkeySaving, setPasskeySaving] = createSignal(false);260261 const [dedupCount, setDedupCount] = createSignal<number | null>(null);262 const [dedupRunning, setDedupRunning] = createSignal(false);263 const [dedupDone, setDedupDone] = createSignal<number | null>(null);264265 const [declinedCardCount, setDeclinedCardCount] = createSignal(0);266267 268 269 270 const [historySnapshots, setHistorySnapshots] = createSignal<VaultSnapshot[]>([]);271 const [historyLoading, setHistoryLoading] = createSignal(false);272 const [historyRestoring, setHistoryRestoring] = createSignal<string | null>(null);273 const [historyShowAll, setHistoryShowAll] = createSignal(false);274275 async function loadVaultHistory() {276 setHistoryLoading(true);277 try {278 const res = await sendMessage({ type: "GET_VAULT_HISTORY" });279 const list = (res as { ok: boolean; data?: VaultSnapshot[] }).data ?? [];280 setHistorySnapshots(list);281 } finally {282 setHistoryLoading(false);283 }284 }285286 async function handleRestoreVaultSnapshot(id: string) {287 if (historyRestoring()) return;288 setHistoryRestoring(id);289 const res = await sendMessage({290 type: "RESTORE_VAULT_SNAPSHOT",291 payload: { snapshotId: id },292 });293 setHistoryRestoring(null);294 if ((res as { ok: boolean }).ok) {295 296 297 props.onBack();298 }299 }300301 function formatSnapshotTime(savedAt: number): string {302 const diff = Date.now() - savedAt;303 if (diff < 60_000) return "Just now";304 if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;305 const d = new Date(savedAt);306 const isToday = new Date().toDateString() === d.toDateString();307 const isYest =308 new Date(Date.now() - 86_400_000).toDateString() === d.toDateString();309 const t = d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });310 if (isToday) return `Today ${t}`;311 if (isYest) return `Yesterday ${t}`;312 return `${d.toLocaleDateString([], { month: "short", day: "numeric" })} ${t}`;313 }314315 function reasonLabel(reason: VaultSnapshot["reason"]): string {316 return (317 {318 local_save: "saved",319 sync_received: "synced",320 import: "imported",321 restore: "restored",322 } as const323 )[reason] ?? reason;324 }325326 const [showVaultAdvanced, setShowVaultAdvanced] = createSignal(false);327328 const [showChangePw, setShowChangePw] = createSignal(false);329 const [changePwCurrent, setChangePwCurrent] = createSignal("");330 const [changePwNew, setChangePwNew] = createSignal("");331 const [changePwConfirm, setChangePwConfirm] = createSignal("");332 const [changePwSaving, setChangePwSaving] = createSignal(false);333 const [changePwError, setChangePwError] = createSignal<string | null>(null);334 const [changePwSuccess] = createSignal(false);335 const [showNewRecoveryWords, setShowNewRecoveryWords] = createSignal(false);336337 const [duressEnabled, setDuressEnabled] = createSignal(false);338 const [showDuressSetup, setShowDuressSetup] = createSignal(false);339 const [duressPassword, setDuressPassword] = createSignal("");340 const [duressConfirm, setDuressConfirm] = createSignal("");341 const [duressMainPassword, setDuressMainPassword] = createSignal("");342 const [duressSaving, setDuressSaving] = createSignal(false);343 const [duressError, setDuressError] = createSignal<string | null>(null);344 const [duressSuccess, setDuressSuccess] = createSignal<string | null>(null);345346 const [canaryEntries, setCanaryEntries] = createSignal<Array<{id: string; title: string; canary: Canary}>>([]);347348 const [canaryTestingId, setCanaryTestingId] = createSignal<string | null>(null);349 const [canaryTestResult, setCanaryTestResult] = createSignal<{id: string; ok: boolean} | null>(null);350351 const [fbCategory, setFbCategory] = createSignal("Bug report");352 const [fbMessage, setFbMessage] = createSignal("");353 type FeedbackState = 'idle' | 'sending' | 'sent' | 'error';354 const [feedbackState, setFeedbackState] = createSignal<FeedbackState>('idle');355356 createEffect(() => {357 if (props.upgradeHighlight) {358 setActiveCard("pro");359 }360 });361362 onMount(async () => {363 const [licenseRes, passkeyRes, duressRes, settingsRes, keyRes] = await Promise.all([364 sendMessage({ type: "GET_LICENSE" }),365 sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }),366 sendMessage({ type: "IS_DURESS_ENABLED" }),367 sendMessage({ type: "GET_SETTINGS" }),368 sendMessage({ type: "GET_LICENSE_KEY" }),369 ]);370371 372 373 void loadVaultHistory();374375 if ((licenseRes as { ok: boolean }).ok) {376 setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null);377 }378379 if ((passkeyRes as { ok: boolean }).ok) {380 setPasskeyState(381 (passkeyRes as { ok: boolean; data?: PasskeyProviderState }).data ?? null382 );383 }384385 if ((duressRes as { ok: boolean }).ok) {386 setDuressEnabled((duressRes as { ok: boolean; data?: boolean }).data ?? false);387 }388389 if ((settingsRes as { ok: boolean }).ok) {390 const sData = (settingsRes as { ok: boolean; data?: { declinedCardFingerprints?: string[] } }).data;391 setDeclinedCardCount(sData?.declinedCardFingerprints?.length ?? 0);392 }393394 if ((keyRes as { ok: boolean }).ok) {395 setLicenseKey((keyRes as { ok: boolean; data?: string }).data ?? null);396 }397398 const entriesRes = await sendMessage({ type: "GET_ENTRIES" });399 if ((entriesRes as { ok: boolean }).ok) {400 const entries = (entriesRes as { ok: boolean; data?: Array<{id: string; title: string; canary?: Canary}> }).data ?? [];401 setCanaryEntries(402 entries403 .filter((e) => e.canary?.webhookUrl)404 .map((e) => ({ id: e.id, title: e.title, canary: e.canary! }))405 );406 }407408409 if (isNativePlatform()) {410 const biometric = nativePlugin<{ isAvailable: () => Promise<{ available: boolean }> }>("BiometricPlugin");411 const keystore = nativePlugin<{ hasMasterKey: () => Promise<{ exists: boolean }> }>("KeystorePlugin");412 try {413 if (biometric) {414 const a = await biometric.isAvailable();415 setBiometricSupported(a.available);416 }417 if (keystore) {418 const k = await keystore.hasMasterKey();419 setBiometricStored(k.exists);420 }421 } catch { }422 }423 });424425 async function handleEnableBiometrics() {426 setBiometricBusy(true);427 setBiometricError(null);428 try {429 const keyRes = (await sendMessage({ type: "GET_DERIVED_KEY" })) as {430 ok: boolean;431 data?: { masterKeyHex: string };432 error?: string;433 };434 if (!keyRes.ok || !keyRes.data?.masterKeyHex) {435 setBiometricError(keyRes.error === "vault_locked"436 ? "unlock the vault first"437 : "couldn't read derived key");438 return;439 }440 const keystore = nativePlugin<{ storeMasterKey: (p: { masterKeyHex: string }) => Promise<void> }>("KeystorePlugin");441 if (!keystore) {442 setBiometricError("biometric plugin unavailable");443 return;444 }445 await keystore.storeMasterKey({ masterKeyHex: keyRes.data.masterKeyHex });446 await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: true } });447 setBiometricStored(true);448 } catch (e) {449 setBiometricError(e instanceof Error ? e.message : "failed to enable biometrics");450 } finally {451 setBiometricBusy(false);452 }453 }454455 async function handleDisableBiometrics() {456 setBiometricBusy(true);457 setBiometricError(null);458 try {459 const keystore = nativePlugin<{ clearMasterKey: () => Promise<void> }>("KeystorePlugin");460 if (keystore) await keystore.clearMasterKey();461 await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: false } });462 setBiometricStored(false);463 } catch (e) {464 setBiometricError(e instanceof Error ? e.message : "failed to disable biometrics");465 } finally {466 setBiometricBusy(false);467 }468 }469470 async function handleChangePassword() {471 setChangePwError(null);472 if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; }473 if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; }474 if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; }475 if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; }476 setChangePwSaving(true);477 const res = await sendMessage({478 type: "CHANGE_PASSWORD",479 payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() },480 }) as { ok: boolean; error?: string };481 if (res.ok) {482 setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");483 setShowChangePw(false);484 485 486 487 488 await chrome.storage?.local?.set?.({ peach_recovery_pending: "1" });489 setShowNewRecoveryWords(true);490 } else {491 setChangePwError(res.error ?? "failed to change password");492 }493 setChangePwSaving(false);494 }495496 const [copied, setCopied] = createSignal(false);497498 async function copyLicenseKey(key: string) {499 await navigator.clipboard.writeText(key);500 setCopied(true);501 setTimeout(() => setCopied(false), 2000);502 }503504 async function handleActivateLicense() {505 const key = licenseKeyInput().trim();506 if (!key) return;507 setActivatingLicense(true);508 setLicenseError(null);509 setLicenseSuccess(null);510 const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } });511 setActivatingLicense(false);512 if ((res as { ok: boolean }).ok) {513 const data = (res as { ok: boolean; data?: LicenseInfo }).data;514 if (data?.valid) {515 setLicenseInfo(data);516 setLicenseKey(key);517 setLicenseSuccess("License activated!");518 setLicenseKeyInput("");519 } else {520 setLicenseError("Invalid license key");521 }522 } else {523 setLicenseError("Activation failed");524 }525 }526527 async function handleTogglePasskeys() {528 const current = passkeyState();529 if (!current || !current.supported) return;530 setPasskeySaving(true);531 const res = await sendMessage({532 type: "SET_PASSKEY_PROVIDER_ENABLED",533 payload: { enabled: !current.enabled },534 });535 setPasskeySaving(false);536 if ((res as { ok: boolean }).ok) {537 setPasskeyState(538 (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null539 );540 }541 }542543 async function handleCheckAllBreaches() {544 setCheckingBreaches(true);545 setBreachResult(null);546 const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });547 setCheckingBreaches(false);548 if ((res as { ok: boolean }).ok) {549 const data = (res as { ok: boolean; data?: Record<string, number> }).data ?? {};550 const breached = Object.values(data).filter((count) => count > 0).length;551 setBreachResult(552 breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean"553 );554 props.onBreachCheckDone();555 }556 }557558559 async function handleSetupDuress() {560 const mainPw = duressMainPassword().trim();561 const duressPw = duressPassword().trim();562 const confirmPw = duressConfirm().trim();563564 if (!mainPw) { setDuressError("enter your main password to confirm"); return; }565 if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; }566 if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; }567 if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; }568569 setDuressSaving(true);570 setDuressError(null);571572 const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };573 if (!verifyRes.ok || !verifyRes.data) {574 setDuressError("incorrect main password");575 setDuressSaving(false);576 return;577 }578579 const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string };580 setDuressSaving(false);581 if (res.ok) {582 setDuressEnabled(true);583 setShowDuressSetup(false);584 setDuressPassword("");585 setDuressConfirm("");586 setDuressMainPassword("");587 setDuressSuccess("Duress password set. If entered, a decoy vault will be shown.");588 setTimeout(() => setDuressSuccess(null), 5000);589 } else {590 setDuressError(591 res.error === "session_expired"592 ? "Vault session expired — lock and unlock Peach, then try again"593 : res.error ?? "failed to set duress password"594 );595 }596 }597598 async function handleClearDeclinedCards() {599 await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } });600 setDeclinedCardCount(0);601 }602603 async function handleCountDuplicates() {604 setDedupRunning(true);605 setDedupDone(null);606 const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number };607 setDedupRunning(false);608 if (res.ok) setDedupCount(res.data ?? 0);609 }610611 async function handleDeduplicate() {612 setDedupRunning(true);613 const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number };614 setDedupRunning(false);615 if (res.ok) {616 setDedupDone(res.data ?? 0);617 setDedupCount(0);618 }619 }620621 async function handleDisableDuress() {622 const mainPw = duressMainPassword().trim();623 if (!mainPw) { setDuressError("enter your main password to confirm"); return; }624625 setDuressSaving(true);626 setDuressError(null);627628 const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };629 if (!verifyRes.ok || !verifyRes.data) {630 setDuressError("incorrect main password");631 setDuressSaving(false);632 return;633 }634635 const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string };636 setDuressSaving(false);637 if (res.ok) {638 setDuressEnabled(false);639 setShowDuressSetup(false);640 setDuressMainPassword("");641 setDuressSuccess("Duress password disabled.");642 setTimeout(() => setDuressSuccess(null), 4000);643 } else {644 setDuressError(res.error ?? "failed to disable duress password");645 }646 }647648 async function handleSubmitFeedback() {649 const message = sanitizeFeedbackMessage(fbMessage());650 if (!message) return;651652 setFeedbackState('sending');653654 try {655 const hasDataPermission = await requestFeedbackDataPermission();656 if (!hasDataPermission) {657 setFeedbackState('idle');658 return;659 }660661 const category = FEEDBACK_CATEGORIES.includes(fbCategory())662 ? fbCategory()663 : FEEDBACK_CATEGORIES[0];664 const res = await fetch('https://api.web3forms.com/submit', {665 method: 'POST',666 headers: { 'Content-Type': 'application/json' },667 body: JSON.stringify({668 access_key: WEB3FORMS_KEY,669 subject: `Peach Feedback - ${category}`,670 message,671 from_name: 'Peach User',672 673 botcheck: '',674 }),675 });676677 if (!res.ok) throw new Error(`HTTP ${res.status}`);678 const data = await res.json() as { success: boolean; message?: string };679 if (!data.success) throw new Error(data.message ?? 'unknown error');680681 setFeedbackState('sent');682 setFbMessage('');683 setFbCategory(FEEDBACK_CATEGORIES[0]);684 } catch (err) {685 console.error('[feedback]', err);686 setFeedbackState('error');687 setTimeout(() => setFeedbackState('idle'), 4000);688 }689 }690691 function renderSecurity() {692 return (693 <div class="peach-settings-body">694 <div class="peach-settings-group">695 <p class="peach-settings-sub-label">LOCKING</p>696 <div class="peach-settings-row">697 <span>Lock on close</span>698 <button699 class={`peach-toggle ${props.lockOnClose ? "is-on" : ""}`}700 onClick={() => void props.onLockOnCloseChange(!props.lockOnClose)}701 aria-label="Toggle lock on close"702 >703 <span class="peach-toggle-knob"></span>704 </button>705 </div>706 <div class="peach-settings-row">707 <span>Auto-lock after</span>708 <select709 class="peach-settings-select"710 value={String(props.autoLockMinutes)}711 onChange={(e) => void props.onAutoLockMinutesChange(Number(e.currentTarget.value))}712 >713 <option value="1">1 minute</option>714 <option value="5">5 minutes</option>715 <option value="15">15 minutes</option>716 <option value="30">30 minutes</option>717 <option value="0">Never</option>718 </select>719 </div>720721 <Show when={isNativePlatform() && biometricSupported()}>722 <p class="peach-settings-sub-label">BIOMETRICS</p>723 <div class="peach-settings-row">724 <span>Unlock with biometrics</span>725 <button726 class={`peach-toggle ${biometricStored() ? "is-on" : ""}`}727 onClick={() => void (biometricStored() ? handleDisableBiometrics() : handleEnableBiometrics())}728 disabled={biometricBusy()}729 aria-label="Toggle biometric unlock"730 >731 <span class="peach-toggle-knob"></span>732 </button>733 </div>734 <Show when={biometricError()}>735 <div class="peach-settings-row peach-settings-row--meta">736 <span class="peach-hint peach-hint-error">{biometricError()}</span>737 </div>738 </Show>739 </Show>740741 <p class="peach-settings-sub-label">BREACH PROTECTION</p>742 <div class="peach-settings-row">743 <span>Breach monitoring</span>744 <button745 class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}746 onClick={() => void props.onBreachMonitoringChange(!props.breachMonitoring)}747 aria-label="Toggle breach monitoring"748 >749 <span class="peach-toggle-knob"></span>750 </button>751 </div>752 <div class="peach-settings-row peach-settings-row--meta">753 <span>Check all passwords</span>754 <button755 class="peach-btn peach-btn--subtle peach-btn--sm"756 onClick={() => void handleCheckAllBreaches()}757 disabled={checkingBreaches()}758 >759 {checkingBreaches() ? "checking..." : "run"}760 </button>761 </div>762 <Show when={breachResult()}>763 <div class="peach-settings-row peach-settings-row--meta">764 <span>Last check</span>765 <span class="peach-settings-value">{breachResult()}</span>766 </div>767 </Show>768769 <p class="peach-settings-sub-label">MASTER PASSWORD</p>770 <div class="peach-settings-row peach-settings-row--meta">771 <span>Master password</span>772 <button773 class="peach-btn peach-btn--subtle peach-btn--sm"774 onClick={() => { setShowChangePw((v) => !v); setChangePwError(null); }}775 >776 {showChangePw() ? "cancel" : "change"}777 </button>778 </div>779 <Show when={showChangePw()}>780 <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">781 <input782 class="peach-settings-input"783 type="password"784 placeholder="current password"785 value={changePwCurrent()}786 onInput={(e) => setChangePwCurrent((e.target as HTMLInputElement).value)}787 autocomplete="current-password"788 />789 <input790 class="peach-settings-input"791 type="password"792 placeholder="new password (min 12 chars)"793 value={changePwNew()}794 onInput={(e) => setChangePwNew((e.target as HTMLInputElement).value)}795 autocomplete="new-password"796 />797 <input798 class="peach-settings-input"799 type="password"800 placeholder="confirm new password"801 value={changePwConfirm()}802 onInput={(e) => setChangePwConfirm((e.target as HTMLInputElement).value)}803 autocomplete="new-password"804 onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }}805 />806 <Show when={changePwError()}>807 <p class="peach-hint peach-hint-error" style="margin:0">{changePwError()}</p>808 </Show>809 <Show when={changePwSuccess()}>810 <p class="peach-hint" style="margin:0;color:var(--peach-accent)">password changed ✓</p>811 </Show>812 <button813 class="peach-btn peach-btn--accent peach-btn--sm"814 style="align-self:flex-start"815 onClick={() => void handleChangePassword()}816 disabled={changePwSaving() || !changePwCurrent() || !changePwNew() || !changePwConfirm()}817 >818 {changePwSaving() ? "saving…" : "save new password"}819 </button>820 </div>821 </Show>822823 <p class="peach-settings-sub-label">824 DURESS PASSWORD825 <Show when={!isPro()}>826 <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>827 </Show>828 </p>829 <div class="peach-settings-row peach-settings-row--top">830 <div class="peach-settings-row-main">831 <span>832 Duress password833 <Show when={!isPro()}>834 <span class="peach-pro-badge">Pro</span>835 </Show>836 </span>837 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">838 If entered under coercion, shows a decoy empty vault instead of your real one.839 </span>840 </div>841 <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>842 {duressEnabled() ? "on" : "off"}843 </div>844 </div>845846 <Show when={duressEnabled() && !showDuressSetup()}>847 <div class="peach-settings-row peach-settings-row--meta">848 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">849 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.850 </span>851 </div>852 </Show>853854 <Show when={duressSuccess()}>855 <div class="peach-settings-row peach-settings-row--meta">856 <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>857 </div>858 </Show>859860 <Show when={!showDuressSetup()}>861 <div class="peach-settings-row peach-settings-row--meta">862 <button863 class="peach-btn peach-btn--subtle peach-btn--sm"864 onClick={() => {865 866 867 868 869 870 if (!isPro() && !duressEnabled()) {871 props.onProGate?.("duress_vault");872 setActiveCard("pro");873 return;874 }875 setShowDuressSetup(true);876 setDuressError(null);877 setDuressSuccess(null);878 setDuressPassword("");879 setDuressConfirm("");880 setDuressMainPassword("");881 }}882 >883 {duressEnabled() ? "Change / disable" : "Set up"}884 </button>885 </div>886 </Show>887888 <Show when={showDuressSetup()}>889 <div class="peach-duress-form">890 <label class="peach-label">main password (to confirm)</label>891 <input892 class="peach-settings-input"893 type="password"894 placeholder="your current master password"895 autocomplete="current-password"896 value={duressMainPassword()}897 onInput={(e) => setDuressMainPassword(e.currentTarget.value)}898 />899 <Show when={!duressEnabled()}>900 <label class="peach-label">new duress password</label>901 <input902 class="peach-settings-input"903 type="password"904 placeholder="duress password (min 8 chars)"905 autocomplete="new-password"906 value={duressPassword()}907 onInput={(e) => setDuressPassword(e.currentTarget.value)}908 />909 <label class="peach-label">confirm duress password</label>910 <input911 class="peach-settings-input"912 type="password"913 placeholder="confirm duress password"914 autocomplete="new-password"915 value={duressConfirm()}916 onInput={(e) => setDuressConfirm(e.currentTarget.value)}917 />918 </Show>919920 <Show when={duressError()}>921 <p class="peach-hint peach-hint-error">{duressError()}</p>922 </Show>923924 <div class="peach-duress-actions">925 <button926 class="peach-btn peach-btn--subtle peach-btn--sm"927 onClick={() => { setShowDuressSetup(false); setDuressError(null); }}928 >929 cancel930 </button>931 <Show when={duressEnabled()}>932 <button933 class="peach-btn peach-btn--ghost peach-btn--sm"934 style="color:var(--peach-breach)"935 onClick={() => void handleDisableDuress()}936 disabled={duressSaving()}937 >938 {duressSaving() ? "disabling..." : "disable duress"}939 </button>940 </Show>941 <Show when={!duressEnabled()}>942 <button943 class="peach-btn peach-btn--accent peach-btn--sm"944 onClick={() => void handleSetupDuress()}945 disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}946 >947 {duressSaving() ? "saving..." : "save"}948 </button>949 </Show>950 </div>951 </div>952 </Show>953954 <p class="peach-settings-sub-label">955 CANARY ENTRIES956 <Show when={!isPro()}>957 <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>958 </Show>959 </p>960 <div class="peach-settings-row peach-settings-row--top">961 <div class="peach-settings-row-main">962 <span>963 Canary entries964 <Show when={!isPro()}>965 <span class="peach-pro-badge">Pro</span>966 </Show>967 </span>968 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">969 Silently pings a webhook when an entry is accessed. Configure canary on any entry in your vault.970 </span>971 </div>972 <span class="peach-settings-value" style="flex-shrink:0">973 {canaryEntries().length > 0 ? `${canaryEntries().length} active` : "none"}974 </span>975 </div>976 <Show when={canaryEntries().length > 0}>977 <For each={canaryEntries()}>978 {(entry) => (979 <div class="peach-settings-row peach-settings-row--meta">980 <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0">{entry.title}</span>981 <button982 class="peach-btn peach-btn--subtle peach-btn--sm"983 onClick={() => void handleTestCanary(entry)}984 disabled={canaryTestingId() === entry.id}985 style="flex-shrink:0"986 >987 {canaryTestingId() === entry.id988 ? "sending…"989 : canaryTestResult()?.id === entry.id990 ? (canaryTestResult()!.ok ? "sent ✓" : "failed ✗")991 : "test"}992 </button>993 </div>994 )}995 </For>996 </Show>997998 <p class="peach-settings-sub-label">PASSKEYS</p>999 <div class="peach-settings-row peach-settings-row--top">1000 <div class="peach-settings-row-main">1001 <span>Intercept passkey requests</span>1002 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">1003 Peach can handle passkey sign-in and creation on supported Chromium browsers.1004 </span>1005 </div>1006 <button1007 class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}1008 onClick={() => void handleTogglePasskeys()}1009 aria-label="Toggle passkey interception"1010 disabled={!passkeyState()?.supported || passkeySaving()}1011 >1012 <span class="peach-toggle-knob"></span>1013 </button>1014 </div>1015 <div class="peach-settings-row peach-settings-row--meta">1016 <span>Status</span>1017 <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>1018 </div>1019 <div class="peach-settings-row peach-settings-row--meta">1020 <span>Browser</span>1021 <span class="peach-settings-value">1022 {passkeyState()?.browserLabel ?? "Unknown"}1023 {passkeyState()?.supported ? " ✓" : " (interception unavailable)"}1024 </span>1025 </div>1026 <div class="peach-info-notice" style="margin-top:8px">1027 Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.1028 </div>1029 <Show when={passkeyState()?.conflict}>1030 <div class="peach-settings-row peach-settings-row--meta">1031 <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>1032 </div>1033 </Show>1034 </div>1035 </div>1036 );1037 }10381039 function renderVault() {1040 return (1041 <div class="peach-settings-body">1042 <div class="peach-settings-group">1043 <p class="peach-settings-sub-label">ENTRIES</p>1044 <div class="peach-settings-row peach-settings-row--meta">1045 <span>Entries</span>1046 <span class="peach-settings-value">{props.entriesCount}</span>1047 </div>1048 <div class="peach-settings-row peach-settings-row--meta">1049 <span>Duplicates</span>1050 <Show when={dedupDone() !== null} fallback={1051 <button1052 class="peach-btn peach-btn--subtle peach-btn--sm"1053 onClick={() => void handleCountDuplicates()}1054 disabled={dedupRunning()}1055 >1056 {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}1057 </button>1058 }>1059 <span class="peach-settings-value" style="color:var(--peach-sync-ok)">1060 {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}1061 </span>1062 </Show>1063 </div>1064 <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>1065 <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">1066 <div class="peach-restore-warning">1067 {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.1068 </div>1069 <div style="display:flex;gap:6px">1070 <button1071 class="peach-btn peach-btn--subtle peach-btn--sm"1072 onClick={() => setDedupCount(null)}1073 >1074 cancel1075 </button>1076 <button1077 class="peach-btn peach-btn--accent peach-btn--sm"1078 onClick={() => void handleDeduplicate()}1079 disabled={dedupRunning()}1080 >1081 {dedupRunning() ? "removing..." : "remove duplicates"}1082 </button>1083 </div>1084 </div>1085 </Show>10861087 <p class="peach-settings-sub-label">MAINTENANCE</p>1088 <div class="peach-settings-row peach-settings-row--meta">1089 <span>Trash auto-delete</span>1090 <select1091 class="peach-settings-select"1092 value={String(props.trashRetentionDays)}1093 onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}1094 >1095 <option value="7">7 days</option>1096 <option value="30">30 days</option>1097 <option value="90">90 days</option>1098 <option value="0">Never</option>1099 </select>1100 </div>1101 <div class="peach-settings-row peach-settings-row--meta">1102 <span>Advanced Vault Health</span>1103 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>1104 open1105 </button>1106 </div>1107 <Show when={declinedCardCount() > 0}>1108 <div class="peach-settings-row peach-settings-row--meta">1109 <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>1110 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>1111 Clear1112 </button>1113 </div>1114 </Show>11151116 <p class="peach-settings-sub-label">DATA</p>1117 <div class="peach-settings-row peach-settings-row--meta">1118 <span>Import passwords</span>1119 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>1120 open1121 </button>1122 </div>1123 <div class="peach-settings-row peach-settings-row--meta">1124 <span>Export backup</span>1125 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onExport}>1126 open1127 </button>1128 </div>1129 <div class="peach-settings-row peach-settings-row--meta">1130 <span>1131 Peach Codex1132 <Show when={!isPro()}>1133 <span class="peach-pro-badge">Pro</span>1134 </Show>1135 </span>1136 <button1137 class="peach-btn peach-btn--subtle peach-btn--sm"1138 onClick={() => props.onOpenCodexGenerate?.()}1139 >1140 open1141 </button>1142 </div>1143 {1144}1145 <div class="peach-settings-row peach-settings-row--meta">1146 <span>Restore from Codex</span>1147 <button1148 class="peach-btn peach-btn--subtle peach-btn--sm"1149 onClick={() => props.onOpenCodexRestore?.()}1150 >1151 open1152 </button>1153 </div>11541155 <span class="peach-settings-sub-label">VAULT HISTORY</span>1156 <Show when={historyLoading()}>1157 <p class="peach-settings-hint">Loading…</p>1158 </Show>1159 <Show when={!historyLoading() && historySnapshots().length === 0}>1160 <p class="peach-settings-hint">1161 No snapshots yet. Peach saves automatically before every change —1162 saves, syncs, and imports.1163 </p>1164 </Show>1165 <Show when={!historyLoading() && historySnapshots().length > 0}>1166 <p class="peach-settings-hint">1167 Restoring locks your vault. Unlock with the password that1168 encrypted that snapshot to confirm.1169 </p>1170 <div class="peach-history-list">1171 <For each={historySnapshots().slice(0, historyShowAll() ? undefined : 3)}>1172 {(snap) => (1173 <div class="peach-history-row">1174 <div class="peach-history-meta">1175 <span class="peach-history-time">1176 {formatSnapshotTime(snap.savedAt)}1177 </span>1178 <span class="peach-history-sub">1179 {[1180 snap.entryCount !== null1181 ? `${snap.entryCount} ${snap.entryCount === 1 ? "entry" : "entries"}`1182 : null,1183 reasonLabel(snap.reason),1184 ]1185 .filter(Boolean)1186 .join(" · ")}1187 </span>1188 </div>1189 <button1190 class={`peach-btn peach-btn--subtle peach-btn--sm peach-history-btn${1191 historyRestoring() === snap.id ? " is-busy" : ""1192 }`}1193 onClick={() => void handleRestoreVaultSnapshot(snap.id)}1194 disabled={historyRestoring() !== null}1195 >1196 <Show when={historyRestoring() === snap.id} fallback="Restore">1197 …1198 </Show>1199 </button>1200 </div>1201 )}1202 </For>1203 </div>1204 <Show when={historySnapshots().length > 3}>1205 <button1206 class="peach-settings-advanced-toggle"1207 onClick={() => setHistoryShowAll((v) => !v)}1208 aria-expanded={historyShowAll()}1209 >1210 <span class="peach-settings-hint" style="margin:0">1211 {historyShowAll() ? "show less" : `${historySnapshots().length - 3} older`}1212 </span>1213 <span class="peach-settings-advanced-chevron">1214 {historyShowAll() ? "▲" : "▼"}1215 </span>1216 </button>1217 </Show>1218 <p class="peach-history-footer">1219 {historySnapshots().length} of 10 snapshots1220 </p>1221 </Show>12221223 <button1224 class="peach-settings-advanced-toggle"1225 onClick={() => setShowVaultAdvanced((v) => !v)}1226 aria-expanded={showVaultAdvanced()}1227 >1228 <span class="peach-settings-sub-label" style="margin:0">ADVANCED</span>1229 <span class="peach-settings-advanced-chevron">1230 {showVaultAdvanced() ? "▲" : "▼"}1231 </span>1232 </button>12331234 <Show when={showVaultAdvanced()}>1235 <div class="peach-danger-card">1236 <div class="peach-danger-card-head">1237 <span class="peach-danger-card-glyph" aria-hidden="true">⟳</span>1238 <h3 class="peach-danger-card-title">Replace vault from backup</h3>1239 </div>1240 <p class="peach-danger-card-copy">1241 Wipes the current vault and restores from a backup file.1242 Recoverable only if your backup is good.1243 </p>1244 <button1245 class="peach-danger-card-button"1246 onClick={() => props.onOpenFileImport?.()}1247 >1248 Choose backup file1249 </button>1250 </div>12511252 <Show when={props.onOpenPanic}>1253 <div class="peach-danger-card peach-danger-card--hazard">1254 <div class="peach-danger-card-head">1255 <span class="peach-danger-card-glyph" aria-hidden="true">⚠</span>1256 <h3 class="peach-danger-card-title">Destroy vault</h3>1257 </div>1258 <p class="peach-danger-card-copy">1259 Permanently wipes every entry, setting, and license on this1260 device. There is no recovery.1261 </p>1262 <button1263 class="peach-danger-card-button"1264 onClick={() => props.onOpenPanic?.()}1265 >1266 Begin destruction1267 </button>1268 </div>1269 </Show>1270 </Show>1271 </div>1272 </div>1273 );1274 }12751276 function renderSync() {1277 return (1278 <div class="peach-settings-body">1279 <div class="peach-settings-group">1280 <p class="peach-settings-sub-label">PAIRING</p>1281 <div class="peach-settings-row peach-settings-row--top">1282 <div class="peach-settings-row-main">1283 <span>1284 Peach Sync1285 <Show when={!isPro()}>1286 <span class="peach-pro-badge">Pro</span>1287 </Show>1288 </span>1289 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1290 Pair nearby devices by chirp and keep encrypted vault blobs in step without a central account.1291 </span>1292 </div>1293 <span class={`stk-pip ${isPro() ? "is-on" : ""}`} aria-hidden="true" />1294 </div>1295 <div class="peach-settings-row peach-settings-row--meta">1296 <span>Status</span>1297 <span class="peach-settings-value">{isPro() ? "Ready" : "Requires Pro"}</span>1298 </div>1299 <div class="peach-settings-row peach-settings-row--meta">1300 <span>Sync hub</span>1301 <button1302 class="peach-btn peach-btn--subtle peach-btn--sm"1303 onClick={() => props.onOpenSyncPairing?.()}1304 >1305 open1306 </button>1307 </div>13081309 <p class="peach-settings-sub-label">DEVICE FLOW</p>1310 <div class="peach-settings-row peach-settings-row--top">1311 <div class="peach-settings-row-main">1312 <span>Pair new device</span>1313 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1314 Opens the full chirp pairing console with scan, show-code, and advanced diagnostics.1315 </span>1316 </div>1317 <button1318 class="peach-btn peach-btn--subtle peach-btn--sm"1319 onClick={() => props.onOpenSyncPairing?.()}1320 >1321 pair1322 </button>1323 </div>1324 <div class="peach-info-notice" style="margin-top:8px">1325 Sync uses the same zero-knowledge vault format as backup and restore; pairing authorizes a device, not a cloud account.1326 </div>1327 </div>1328 </div>1329 );1330 }13311332 function renderAppearance() {1333 const sizeOptions: Array<{ id: "medium" | "large"; label: string }> = [1334 { id: "medium", label: "Default" },1335 { id: "large", label: "Large" },1336 ];1337 return (1338 <div class="peach-settings-body">1339 <div class="peach-settings-group">1340 <p class="peach-settings-sub-label">THEME</p>1341 <div class="peach-settings-row peach-settings-row--meta">1342 <span>Theme</span>1343 <button1344 class="peach-btn peach-btn--subtle peach-btn--sm"1345 onClick={() =>1346 props.onThemeChange(props.theme === "dark" ? "light" : "dark")1347 }1348 >1349 {props.theme === "dark" ? "Switch to light" : "Switch to dark"}1350 </button>1351 </div>13521353 <p class="peach-settings-sub-label">ACCENT</p>1354 <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">1355 <span>Accent</span>1356 <div class="peach-accent-picker">1357 <For each={ACCENT_COLORS}>1358 {(color) => (1359 <button1360 class="peach-accent-swatch peach-swatch"1361 classList={{ "is-active": props.accentColor === color.name }}1362 style={{ background: color.hex }}1363 onClick={() => void props.onAccentChange(color.name)}1364 aria-label={color.name}1365 title={color.name}1366 />1367 )}1368 </For>1369 </div>1370 </div>13711372 <p class="peach-settings-sub-label">TEXT SIZE</p>1373 <div class="peach-settings-row peach-settings-row--meta">1374 <span>Size</span>1375 <div class="peach-pill-group">1376 <For each={sizeOptions}>1377 {(opt) => (1378 <button1379 class="peach-btn peach-btn--ghost peach-btn--sm"1380 classList={{ "is-active": props.textSize === opt.id }}1381 onClick={() => void props.onTextSizeChange(opt.id)}1382 >1383 {opt.label}1384 </button>1385 )}1386 </For>1387 </div>1388 </div>13891390 <p class="peach-settings-sub-label">MOTION</p>1391 <div class="peach-settings-row peach-settings-row--meta">1392 <span>Reduce motion</span>1393 <button1394 class="peach-btn peach-btn--subtle peach-btn--sm"1395 onClick={() => void props.onReduceMotionChange(!props.reduceMotion)}1396 >1397 {props.reduceMotion ? "On" : "Off"}1398 </button>1399 </div>1400 </div>1401 </div>1402 );1403 }14041405 function renderShortcuts() {1406 const [capturingAction, setCapturingAction] = createSignal<ShortcutAction | null>(null);1407 const [conflictMsg, setConflictMsg] = createSignal<string | null>(null);14081409 function startCapture(action: ShortcutAction) {1410 setConflictMsg(null);1411 setCapturingAction(action);1412 }1413 function cancelCapture() {1414 setCapturingAction(null);1415 setConflictMsg(null);1416 }1417 function commitBinding(action: ShortcutAction, binding: Keybinding) {1418 const conflict = findConflict(props.shortcuts, binding, action);1419 if (conflict) {1420 const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict);1421 setConflictMsg(`Already bound to "${other?.label ?? conflict}"`);1422 return;1423 }1424 const next = { ...props.shortcuts, [action]: binding };1425 setCapturingAction(null);1426 setConflictMsg(null);1427 void props.onShortcutsChange(next);1428 }1429 function resetAction(action: ShortcutAction) {1430 const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] };1431 void props.onShortcutsChange(next);1432 }1433 function resetAll() {1434 void props.onShortcutsChange({ ...DEFAULT_BINDINGS });1435 }14361437 1438 1439 function handleCaptureKey(e: KeyboardEvent) {1440 e.preventDefault();1441 e.stopPropagation();1442 if (e.key === "Escape") { cancelCapture(); return; }1443 const binding = keybindingFromEvent(e);1444 if (!binding) return; 1445 const action = capturingAction();1446 if (action) commitBinding(action, binding);1447 }14481449 return (1450 <div class="peach-settings-body">1451 <div class="peach-settings-group peach-shortcuts-group">1452 <p class="peach-settings-sub-label">ENTRY VIEW</p>1453 <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry")}>1454 {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1455 </For>1456 </div>14571458 <div class="peach-settings-group peach-shortcuts-group">1459 <p class="peach-settings-sub-label">ANYWHERE IN POPUP</p>1460 <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global")}>1461 {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1462 </For>1463 </div>14641465 <Show when={conflictMsg()}>1466 <div class="peach-shortcuts-conflict">{conflictMsg()}</div>1467 </Show>14681469 <div class="peach-settings-group" style="display:flex;flex-direction:column;gap:8px">1470 <div class="peach-settings-row peach-settings-row--meta">1471 <span>Inline autofill on page</span>1472 <a1473 class="peach-settings-link"1474 href="chrome://extensions/shortcuts"1475 onClick={(e) => {1476 e.preventDefault();1477 chrome.tabs.create({ url: "chrome://extensions/shortcuts" });1478 }}1479 >1480 ⌘⇧P — customize ↗1481 </a>1482 </div>1483 <button1484 class="peach-btn peach-btn--subtle peach-btn--sm"1485 style="align-self:flex-start"1486 onClick={resetAll}1487 >1488 Reset all to defaults1489 </button>1490 </div>1491 </div>1492 );14931494 function renderRow(1495 desc: typeof SHORTCUT_DESCRIPTORS[number],1496 capturing: () => ShortcutAction | null,1497 onStart: (a: ShortcutAction) => void,1498 onCancel: () => void,1499 onReset: (a: ShortcutAction) => void,1500 onKey: (e: KeyboardEvent) => void,1501 ) {1502 const isActive = () => capturing() === desc.action;1503 const binding = () => props.shortcuts[desc.action];1504 const isCustom = () => {1505 const b = binding();1506 const d = DEFAULT_BINDINGS[desc.action];1507 return b.key !== d.key1508 || Boolean(b.ctrl) !== Boolean(d.ctrl)1509 || Boolean(b.meta) !== Boolean(d.meta)1510 || Boolean(b.shift) !== Boolean(d.shift)1511 || Boolean(b.alt) !== Boolean(d.alt);1512 };1513 return (1514 <div class="peach-shortcut-row">1515 <span class="peach-shortcut-row-label">{desc.label}</span>1516 <button1517 class="peach-shortcut-chip"1518 classList={{ "is-capturing": isActive(), "is-custom": isCustom() }}1519 onClick={() => isActive() ? onCancel() : onStart(desc.action)}1520 onKeyDown={(e) => { if (isActive()) onKey(e); }}1521 ref={(el) => {1522 1523 if (el) {1524 queueMicrotask(() => { if (isActive()) el.focus(); });1525 }1526 }}1527 >1528 {isActive() ? "Press a key…" : formatKeybinding(binding())}1529 </button>1530 <Show when={isCustom() && !isActive()}>1531 <button1532 class="peach-shortcut-reset"1533 title="Reset to default"1534 onClick={() => onReset(desc.action)}1535 >1536 ↺1537 </button>1538 </Show>1539 </div>1540 );1541 }1542 }15431544 const PRO_FEATURES: Array<{1545 feature: ProFeature;1546 name: string;1547 desc: string;1548 }> = [1549 { feature: "sync", name: "Peach Sync", desc: "Encrypted · peer-to-peer" },1550 { feature: "codex", name: "Peach Codex", desc: "Restore from paper" },1551 { feature: "passkeys", name: "Passkeys", desc: "Zero-knowledge WebAuthn" },1552 { feature: "advanced_health", name: "Advanced Vault Health", desc: "Reuse · age · exposure" },1553 ];15541555 const PRO_SECURITY_FEATURES: Array<{1556 feature: ProFeature;1557 name: string;1558 desc: string;1559 }> = [1560 { feature: "canary_entry", name: "Canary entries", desc: "Detects vault compromise" },1561 { feature: "duress_vault", name: "Duress vault", desc: "Decoy under coercion" },1562 ];15631564 const [licenseKey, setLicenseKey] = createSignal<string | null>(null);1565 const [showKey, setShowKey] = createSignal(false);15661567 const [deactivating, setDeactivating] = createSignal(false);15681569 async function handleDeactivateLicense() {1570 setDeactivating(true);1571 await sendMessage({ type: "DEACTIVATE_LICENSE" });1572 setLicenseInfo(null);1573 await props.onLicenseChange?.();1574 setDeactivating(false);1575 }15761577 async function handleTestCanary(entry: {id: string; title: string; canary: Canary}) {1578 setCanaryTestingId(entry.id);1579 setCanaryTestResult(null);1580 const res = await sendMessage({1581 type: "FIRE_CANARY_PING",1582 payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger: "test" },1583 }) as { ok: boolean };1584 setCanaryTestResult({ id: entry.id, ok: res.ok });1585 setCanaryTestingId(null);1586 setTimeout(() => setCanaryTestResult((r) => r?.id === entry.id ? null : r), 3000);1587 }15881589 function renderLicense() {1590 async function onActivate() {1591 await handleActivateLicense();1592 if ((licenseInfo()?.valid ?? false) && licenseInfo()?.plan === "pro") {1593 await props.onLicenseChange?.();1594 }1595 }15961597 return (1598 <div class="peach-settings-body">1599 <Show1600 when={isPro()}1601 fallback={1602 <div class="peach-pro-upgrade">1603 <p class="peach-pro-eyebrow">PEACH PRO · ONE-TIME</p>16041605 <h2 class="peach-pro-headline">1606 Every<em>thing,</em><br />every<em>where.</em>1607 </h2>16081609 <p class="peach-pro-subtitle">1610 One key unlocks every Pro feature — for the lifetime of the app.1611 </p>16121613 <div class="peach-pro-grid">1614 <For each={PRO_FEATURES}>1615 {(item, i) => (1616 <div1617 class="peach-pro-grid-item"1618 classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}1619 >1620 <span class="peach-pro-grid-num">{String(i() + 1).padStart(2, "0")}</span>1621 <span class="peach-pro-grid-name">{item.name}</span>1622 <span class="peach-pro-grid-desc">{item.desc}</span>1623 </div>1624 )}1625 </For>1626 </div>16271628 <p class="peach-pro-section-label">ADVANCED SECURITY</p>1629 <div class="peach-pro-grid">1630 <For each={PRO_SECURITY_FEATURES}>1631 {(item, i) => (1632 <div1633 class="peach-pro-grid-item"1634 classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}1635 >1636 <span class="peach-pro-grid-num">{String(PRO_FEATURES.length + i() + 1).padStart(2, "0")}</span>1637 <span class="peach-pro-grid-name">{item.name}</span>1638 <span class="peach-pro-grid-desc">{item.desc}</span>1639 </div>1640 )}1641 </For>1642 </div>16431644 <div class="peach-pro-upgrade-actions">1645 <p class="peach-pro-pricing">1646 $39 <span class="peach-pro-pricing-sub">once · yours forever</span>1647 </p>1648 <a1649 href="https://buy.polar.sh/polar_cl_IXSFIPojx67rcOTksn8LkH5mnU3osPHtYqBIl1eyuIz"1650 target="_blank"1651 rel="noopener noreferrer"1652 class="peach-pro-upgrade-cta"1653 >1654 Get Peach Pro1655 </a>1656 <div class="peach-pro-key-divider"><span>ALREADY HAVE A KEY</span></div>1657 <div class="peach-pro-key-inline">1658 <input1659 class="peach-pro-key-inline-input"1660 type="text"1661 placeholder="PEACH-XXXX-XXXX-XXXX"1662 value={licenseKeyInput()}1663 onInput={(e) => setLicenseKeyInput(e.currentTarget.value)}1664 onKeyDown={(e) => { if (e.key === "Enter") void onActivate(); }}1665 autocomplete="off"1666 spellcheck={false}1667 />1668 <button1669 class="peach-pro-key-apply"1670 onClick={() => void onActivate()}1671 disabled={activatingLicense() || !licenseKeyInput().trim()}1672 >1673 {activatingLicense() ? "…" : "APPLY"}1674 </button>1675 </div>1676 <Show when={licenseError()}>1677 <p class="peach-pro-key-error">{licenseError()}</p>1678 </Show>1679 </div>1680 </div>1681 }1682 >1683 <div class="peach-pro-active">1684 <div class="peach-pro-active-hero">1685 <button1686 class="peach-pro-active-mark-wrap"1687 type="button"1688 aria-label="Celebrate"1689 onClick={handleProCelebrationClick}1690 >1691 <PeachMark class="peach-pro-active-mark" />1692 <Show when={justActivated()}>1693 <div class="peach-pro-sparks" aria-hidden="true">1694 <span class="peach-pro-spark" style="--spark-x:0px;--spark-y:-54px;--spark-delay:0ms">✦</span>1695 <span class="peach-pro-spark" style="--spark-x:38px;--spark-y:-38px;--spark-delay:50ms">✦</span>1696 <span class="peach-pro-spark" style="--spark-x:54px;--spark-y:0px;--spark-delay:110ms">·</span>1697 <span class="peach-pro-spark" style="--spark-x:38px;--spark-y:38px;--spark-delay:30ms">✦</span>1698 <span class="peach-pro-spark" style="--spark-x:0px;--spark-y:54px;--spark-delay:80ms">·</span>1699 <span class="peach-pro-spark" style="--spark-x:-38px;--spark-y:38px;--spark-delay:130ms">✦</span>1700 <span class="peach-pro-spark" style="--spark-x:-54px;--spark-y:0px;--spark-delay:55ms">·</span>1701 <span class="peach-pro-spark" style="--spark-x:-38px;--spark-y:-38px;--spark-delay:95ms">✦</span>1702 </div>1703 </Show>1704 </button>1705 <h2 class="peach-pro-active-title">Peach Pro</h2>1706 <p class="peach-pro-active-since">{formatActivatedSince(licenseInfo()?.issued)}</p>1707 <Show when={justActivated()}>1708 <p class="peach-pro-thankyou-text" aria-live="polite">thank you ✦</p>1709 </Show>1710 </div>17111712 <Show when={licenseKey()}>1713 <div class="peach-settings-license">1714 <span class="peach-settings-label">LICENSE KEY</span>1715 <div class="peach-settings-license-row">1716 <Show1717 when={showKey()}1718 fallback={1719 <span class="peach-settings-license-key peach-settings-license-key--masked" aria-label="License key hidden">1720 ••••••••••••••••••••••••••••1721 </span>1722 }1723 >1724 <span class="peach-settings-license-key">{licenseKey()!}</span>1725 </Show>1726 <div class="peach-settings-license-actions">1727 <button1728 class="peach-settings-license-copy"1729 title="Copy license key"1730 onClick={() => copyLicenseKey(licenseKey()!)}1731 >1732 {copied() ? "✓" : "copy"}1733 </button>1734 <button1735 class="peach-settings-license-toggle"1736 aria-label={showKey() ? "Hide license key" : "Reveal license key"}1737 title={showKey() ? "Hide" : "Reveal"}1738 onClick={() => setShowKey(v => !v)}1739 >1740 <Show1741 when={showKey()}1742 fallback={1743 <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">1744 <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />1745 <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />1746 </svg>1747 }1748 >1749 <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">1750 <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />1751 <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />1752 <line x1="2" y1="2" x2="12" y2="12" />1753 </svg>1754 </Show>1755 </button>1756 </div>1757 </div>1758 </div>1759 </Show>17601761 <div class="peach-pro-active-features">1762 <For each={[...PRO_FEATURES, ...PRO_SECURITY_FEATURES]}>1763 {(item) => (1764 <div class="peach-pro-active-feature">1765 <span class="peach-pro-active-feature-mark">✦</span>1766 <span class="peach-pro-active-feature-name">{item.name}</span>1767 </div>1768 )}1769 </For>1770 </div>17711772 <button1773 class="peach-pro-deactivate"1774 onClick={() => void handleDeactivateLicense()}1775 disabled={deactivating()}1776 >1777 {deactivating() ? "Deactivating…" : "Deactivate"}1778 </button>1779 </div>1780 </Show>1781 </div>1782 );1783 }17841785 function renderFeedback() {1786 return (1787 <div class="peach-settings-body">1788 <div class="peach-settings-group peach-feedback-group">1789 <Show1790 when={feedbackState() === 'sent'}1791 fallback={1792 <>1793 <p class="peach-feedback-intro">1794 Help us improve Peach. Every report goes directly to our inbox.1795 </p>17961797 <p class="peach-settings-sub-label">TYPE</p>1798 <div class="peach-feedback-chips">1799 <For each={FEEDBACK_CATEGORIES}>1800 {(cat) => {1801 const selected = () => fbCategory() === cat;1802 return (1803 <button1804 type="button"1805 class={`peach-feedback-chip ${selected() ? "is-active" : ""}`}1806 aria-pressed={selected()}1807 onClick={() => setFbCategory(cat)}1808 >1809 {cat}1810 </button>1811 );1812 }}1813 </For>1814 </div>18151816 <p class="peach-settings-sub-label">MESSAGE</p>1817 <textarea1818 class="peach-feedback-textarea"1819 placeholder="Describe the issue or idea..."1820 maxLength={MAX_FEEDBACK_MESSAGE_LENGTH}1821 value={fbMessage()}1822 onInput={(e) => setFbMessage(e.currentTarget.value)}1823 />18241825 <p class="peach-feedback-meta">1826 Version {version} · will be included automatically1827 </p>18281829 <button1830 type="button"1831 class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"1832 disabled={!fbMessage().trim() || feedbackState() === 'sending'}1833 onClick={() => void handleSubmitFeedback()}1834 >1835 <Switch>1836 <Match when={feedbackState() === 'idle'}>Send</Match>1837 <Match when={feedbackState() === 'sending'}>Sending...</Match>1838 <Match when={feedbackState() === 'error'}>Failed - try again</Match>1839 </Switch>1840 </button>18411842 <p class="peach-feedback-anon">1843 Sent anonymously · no account required1844 </p>1845 </>1846 }1847 >1848 <div class="peach-feedback-thanks" role="status" aria-live="polite">1849 <p class="peach-feedback-thanks-title">Thank you</p>1850 <p>1851 Your note is in the queue. Someone will process it soon.1852 </p>1853 </div>1854 </Show>1855 </div>1856 </div>1857 );1858 }18591860 const bodies: StackBodies = {1861 protection: renderSecurity(),1862 vault: renderVault(),1863 sync: renderSync(),1864 appearance: renderAppearance(),1865 shortcuts: renderShortcuts(),1866 pro: renderLicense(),1867 feedback: renderFeedback(),1868 };18691870 return (1871 <Show1872 when={showNewRecoveryWords()}1873 fallback={1874 <div1875 class="peach-overlay-view peach-settings-overlay"1876 classList={{ "ov-enter-right": !props.isExiting, "ov-exit-right": !!props.isExiting }}1877 style="display:flex;flex-direction:column"1878 >1879 <StackDeck1880 active={activeCard()}1881 onActiveChange={setActiveCard}1882 onBack={props.onBack}1883 cards={cards()}1884 bodies={bodies}1885 version={version}1886 isFanning={isFanning()}1887 celebratingCardId={justActivated() ? "pro" : null}1888 isPro={isPro()}1889 />1890 </div>1891 }1892 >1893 <RecoveryWordDisplayView onDone={() => setShowNewRecoveryWords(false)} />1894 </Show>1895 );1896}