1import { createEffect, createMemo, createSignal, For, Match, onCleanup, onMount, Show, Switch } from "solid-js";2import { sendMessage } from "../App";3import { PeachMark } from "../components/PeachMark";4import { SyncModeSelector } from "../components/SyncModeSelector";5import { StackDeck } from "../components/settings/StackDeck";6import type { CardId, StackBodies, StackCardSpec } from "../components/settings/types";7import { RecoveryWordDisplayView } from "./RecoveryWordDisplay";8import { WhatsNewView } from "./WhatsNew";9import type { Canary } from "../../shared/types";10import { ACCENT_COLORS } from "../../shared/types";11import { PEACH_VERSION, hasReleaseNotes } from "../../shared/release-notes";12import type {13 AccentColorName,14 PasskeyProviderState,15 ProFeature,16 VaultSnapshot,17} from "../../shared/types";18import type { PairedDevice } from "../../shared/sync-types";19import {20 describeSyncOutcome,21 syncIndicator,22 syncIndicatorLabel,23 type SyncOutcomeSummary,24} from "../../shared/sync-outcome";25import {26 DEFAULT_BINDINGS,27 findConflict,28 formatKeybinding,29 keybindingFromEvent,30 SHORTCUT_DESCRIPTORS,31 type Keybinding,32 type ShortcutAction,33} from "../../shared/shortcuts";34import { WEB3FORMS_KEY } from "../../shared/config";3536interface LicenseInfo {37 valid: boolean;38 plan: "free" | "pro";39 issued?: string;40}4142interface SettingsProps {43 entriesCount: number;44 breachMonitoring: boolean;45 lockOnClose: boolean;46 autoLockMinutes: number;47 trashRetentionDays: number;48 theme: "dark" | "light";49 accentColor: AccentColorName;50 textSize: "small" | "medium" | "large";51 reduceMotion: boolean;52 isPro?: boolean;53 initialSection?: string | null;54 upgradeHighlight?: ProFeature | null;55 onBreachMonitoringChange: (enabled: boolean) => void | Promise<void>;56 onLockOnCloseChange: (enabled: boolean) => void | Promise<void>;57 onAutoLockMinutesChange: (minutes: number) => void | Promise<void>;58 onTrashRetentionChange: (days: number) => void | Promise<void>;59 onThemeChange: (theme: "dark" | "light") => void;60 onAccentChange: (name: AccentColorName) => void | Promise<void>;61 onTextSizeChange: (size: "small" | "medium" | "large") => void | Promise<void>;62 onReduceMotionChange: (enabled: boolean) => void | Promise<void>;63 shortcuts: Record<ShortcutAction, Keybinding>;64 onShortcutsChange: (active: Record<ShortcutAction, Keybinding>) => void | Promise<void>;65 onLicenseChange?: () => void | Promise<void>;66 isExiting?: boolean;67 onBack: () => void;68 onImport: () => void;69 onExport: () => void;70 onBreachCheckDone: () => void;71 onOpenHealth?: () => void;72 onOpenSyncPairing?: (method?: "code") => void;73 onOpenFileImport?: () => void;74 onOpenPanic?: () => void;75 onOpenCodexGenerate?: () => void;76 onOpenCodexRestore?: () => void;77 787980 onProGate?: (feature: ProFeature) => void;81 onCardChange?: (id: CardId) => void;82}8384const FEEDBACK_CATEGORIES = ["Bug report", "Feature request", "Question", "Other"];85const MAX_FEEDBACK_MESSAGE_LENGTH = 4000;86const PRO_CELEBRATION_CARD_STEP_MS = 150;87const PRO_CELEBRATION_THANK_YOU_MS = 3200;8889function sanitizeFeedbackMessage(value: string): string {90 return value91 .trim()92 .slice(0, MAX_FEEDBACK_MESSAGE_LENGTH)93 94 .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "")95 .replace(/&/g, "&")96 .replace(/</g, "<")97 .replace(/>/g, ">")98 .replace(/"/g, """)99 .replace(/'/g, "'");100}101102function formatActivatedSince(issued: string | undefined): string {103 if (!issued) return "Active";104 try {105 const d = new Date(issued);106 if (Number.isNaN(d.getTime())) return "Active";107 return `Active since ${d.toLocaleDateString(undefined, { month: "long", year: "numeric" })}`;108 } catch {109 return "Active";110 }111}112113function initialSectionToCard(value: string | null | undefined): CardId | null {114 if (!value) return null;115 if (value === "security") return "protection";116 if (value === "license") return "pro";117 if (118 value === "protection" ||119 value === "vault" ||120 value === "sync" ||121 value === "appearance" ||122 value === "shortcuts" ||123 value === "pro" ||124 value === "feedback"125 ) {126 return value;127 }128 return null;129}130131export function SettingsView(props: SettingsProps) {132 const manifest = chrome.runtime.getManifest();133 const version = manifest.version;134 const initial = props.initialSection;135 const [activeCard, setActiveCard] = createSignal<CardId>(136 props.upgradeHighlight ? "pro" : initialSectionToCard(initial) ?? "pro"137 );138139 createEffect(() => { props.onCardChange?.(activeCard()); });140141 const [justActivated, setJustActivated] = createSignal(false);142 const [isFanning, setIsFanning] = createSignal(false);143144 function triggerCelebration() {145 if (isFanning()) return;146 setJustActivated(false);147 setIsFanning(true);148 setActiveCard("pro");149 const fanOrder: CardId[] = ["protection", "vault", "sync", "appearance", "shortcuts", "feedback"];150 fanOrder.forEach((id, i) => {151 window.setTimeout(() => setActiveCard(id), (i + 1) * PRO_CELEBRATION_CARD_STEP_MS);152 });153 window.setTimeout(() => {154 setActiveCard("pro");155 setIsFanning(false);156 setJustActivated(true);157 window.setTimeout(() => setJustActivated(false), PRO_CELEBRATION_THANK_YOU_MS);158 }, (fanOrder.length + 1) * PRO_CELEBRATION_CARD_STEP_MS);159 }160161 function handleProCelebrationClick(e: MouseEvent) {162 e.stopPropagation();163 triggerCelebration();164 }165166 const [checkingBreaches, setCheckingBreaches] = createSignal(false);167 const [breachResult, setBreachResult] = createSignal<string | null>(null);168 const [licenseInfo, setLicenseInfo] = createSignal<LicenseInfo | null>(null);169 const [licenseKeyInput, setLicenseKeyInput] = createSignal("");170 const [licenseError, setLicenseError] = createSignal<string | null>(null);171 const [, setLicenseSuccess] = createSignal<string | null>(null);172 const [activatingLicense, setActivatingLicense] = createSignal(false);173174 const isPro = () => props.isPro ?? false;175 const cards = createMemo<StackCardSpec[]>(() => [176 {177 id: "pro",178 num: "I · FOREVER",179 title: "Peach Pro",180 lede: isPro() ? "active · paid once, yours forever" : "$39 once · yours forever",181 bgVar: "--stack-pro-bg",182 fgVar: "--stack-pro-fg",183 icon: "spark",184 onIconClick: isPro() ? triggerCelebration : undefined,185 },186 {187 id: "protection",188 num: "II · PROTECT",189 title: "Protection",190 lede: `${props.breachMonitoring ? "breaches watched" : "breach watch off"} · ${props.lockOnClose ? "locks on close" : "manual lock"}`,191 bgVar: "--stack-protect-bg",192 fgVar: "--stack-protect-fg",193 icon: "shield",194 },195 {196 id: "vault",197 num: "III · KEEP",198 title: "Vault",199 lede: `${props.entriesCount} ${props.entriesCount === 1 ? "entry" : "entries"} · backups, history, cleanup`,200 bgVar: "--stack-vault-bg",201 fgVar: "--stack-vault-fg",202 icon: "lock",203 },204 {205 id: "sync",206 num: "IV · ECHO",207 title: "Sync",208 lede: isPro() ? "your devices, one vault" : "Pro · encrypted device sync",209 bgVar: "--stack-sync-bg",210 fgVar: "--stack-sync-fg",211 icon: "sync",212 },213 {214 id: "appearance",215 num: "V · DRESS",216 title: "Appearance",217 lede: `${props.theme} · ${props.accentColor} · ${props.textSize}`,218 bgVar: "--stack-appear-bg",219 fgVar: "--stack-appear-fg",220 icon: "sun",221 },222 {223 id: "shortcuts",224 num: "VI · KEYS",225 title: "Shortcuts",226 lede: `${SHORTCUT_DESCRIPTORS.length} bindings · what your fingers know`,227 bgVar: "--stack-shortcut-bg",228 fgVar: "--stack-shortcut-fg",229 icon: "keys",230 },231 {232 id: "feedback",233 num: "VII · ECHO",234 title: "Feedback",235 lede: `version ${version} · tell us what would make it better`,236 bgVar: "--stack-feedback-bg",237 fgVar: "--stack-feedback-fg",238 icon: "bubble",239 },240 ]);241 const [passkeyState, setPasskeyState] = createSignal<PasskeyProviderState | null>(null);242 const [passkeySaving, setPasskeySaving] = createSignal(false);243244 245 246 interface PendingVouched {247 deviceId: string;248 deviceName: string;249 longTermPublicKeyHex: string;250 receivedAt: number;251 vouchedByDeviceId: string;252 }253 const [pendingVouched, setPendingVouched] = createSignal<PendingVouched[]>([]);254 const [vouchedBusy, setVouchedBusy] = createSignal<string | null>(null);255 256 const [pairedDevices, setPairedDevices] = createSignal<PairedDevice[]>([]);257 const [confirmUnpair, setConfirmUnpair] = createSignal<string | null>(null);258 const [renameDeviceId, setRenameDeviceId] = createSignal<string | null>(null);259 const [renameDraft, setRenameDraft] = createSignal("");260 const [syncStatus, setSyncStatus] = createSignal<"idle" | "syncing" | "error">("idle");261 const [syncFlash, setSyncFlash] = createSignal<string | null>(null);262 263 264 const [syncSummary, setSyncSummary] = createSignal<SyncOutcomeSummary>({});265 const syncLight = () =>266 syncStatus() === "syncing"267 ? "busy"268 : syncIndicator(syncSummary(), pairedDevices().length);269 const syncLightLabel = () => syncIndicatorLabel(syncLight(), syncSummary());270 const lastSyncLine = () => {271 const s = syncSummary();272 if (!s.outcome) return "No sync attempted yet";273 return `${syncRelativeTime(s.finishedAt)} · ${describeSyncOutcome(s)}`;274 };275 async function loadSyncState() {276 const r = (await sendMessage({ type: "GET_SYNC_STATE" })) as {277 ok: boolean;278 data?: SyncOutcomeSummary;279 };280 if (r.ok && r.data) {281 setSyncSummary(r.data);282 setSyncStatus(r.data.status ?? "idle");283 }284 }285286 const STALE_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000;287 function syncRelativeTime(when: number | undefined): string {288 if (!when) return "Not synced yet";289 const elapsed = Date.now() - when;290 if (elapsed < 60_000) return "Just now";291 const minutes = Math.floor(elapsed / 60_000);292 if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"} ago`;293 const hours = Math.floor(minutes / 60);294 if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;295 const days = Math.floor(hours / 24);296 return days < 7 ? `${days} day${days === 1 ? "" : "s"} ago` : "Not seen recently";297 }298 function isSyncStale(d: PairedDevice): boolean {299 return d.lastSyncedAt !== undefined && Date.now() - d.lastSyncedAt > STALE_THRESHOLD_MS;300 }301 function deviceTypeLabel(t: PairedDevice["deviceType"]): string {302 switch (t) {303 case "android": return "Android";304 case "ios": return "iPhone";305 case "extension": return "Browser";306 default: return "Device";307 }308 }309310 async function loadPairedDevices() {311 if (!isPro()) return;312 const r = (await sendMessage({ type: "LIST_PAIRED_DEVICES" })) as {313 ok: boolean;314 data?: { devices: PairedDevice[] };315 };316 if (r.ok) setPairedDevices(r.data?.devices ?? []);317 }318 async function unpairDevice(deviceId: string) {319 await sendMessage({ type: "UNPAIR_DEVICE", payload: { deviceId } });320 setConfirmUnpair(null);321 await loadPairedDevices();322 }323 async function renameDevice(deviceId: string) {324 const name = renameDraft().trim();325 if (!name) return;326 const result = (await sendMessage({327 type: "RENAME_PAIRED_DEVICE",328 payload: { deviceId, name },329 })) as { ok: boolean };330 if (!result.ok) return;331 setRenameDeviceId(null);332 await loadPairedDevices();333 }334 async function syncNow() {335 setSyncStatus("syncing");336 setSyncFlash(null);337 const res = (await sendMessage({338 type: "TRIGGER_SYNC",339 payload: { reason: "manual", force: true },340 })) as { ok: boolean; error?: string };341 if (!res.ok) {342 setSyncStatus("error");343 setSyncSummary({ status: "error", outcome: "run_failed", detail: res.error });344 return;345 }346 347 348 349 let s: SyncOutcomeSummary = { status: "syncing" };350 for (let i = 0; i < 40 && s.status === "syncing"; i++) {351 await new Promise((r) => setTimeout(r, 250));352 const state = (await sendMessage({ type: "GET_SYNC_STATE" })) as {353 ok: boolean;354 data?: SyncOutcomeSummary;355 };356 s = state.ok ? state.data ?? { status: "idle" } : { status: "error", outcome: "run_failed" };357 }358 setSyncSummary(s);359 setSyncStatus(s.status ?? "idle");360 361 362 setSyncFlash(describeSyncOutcome(s));363 setTimeout(() => setSyncFlash(null), 4000);364 await loadPairedDevices();365 }366 367 const [rotateConfirmOpen, setRotateConfirmOpen] = createSignal(false);368 const [rotating, setRotating] = createSignal(false);369 const [rotateError, setRotateError] = createSignal<string | null>(null);370 const [showSyncConnection, setShowSyncConnection] = createSignal(false);371 const [showSyncRecovery, setShowSyncRecovery] = createSignal(false);372373 async function loadPendingVouched() {374 const r = (await sendMessage({ type: "GET_PENDING_VOUCHED_DEVICES" })) as {375 ok: boolean;376 data?: PendingVouched[];377 };378 if (r.ok) setPendingVouched(r.data ?? []);379 }380 async function confirmVouched(deviceId: string) {381 setVouchedBusy(deviceId);382 try {383 await sendMessage({ type: "CONFIRM_VOUCHED_DEVICE", payload: { deviceId } });384 await loadPendingVouched();385 } finally {386 setVouchedBusy(null);387 }388 }389 async function rejectVouched(deviceId: string) {390 setVouchedBusy(deviceId);391 try {392 await sendMessage({ type: "REJECT_VOUCHED_DEVICE", payload: { deviceId } });393 await loadPendingVouched();394 } finally {395 setVouchedBusy(null);396 }397 }398 async function doRotateSyncIdentity() {399 setRotateError(null);400 setRotating(true);401 try {402 const r = (await sendMessage({ type: "ROTATE_SYNC_IDENTITY" })) as {403 ok: boolean;404 error?: string;405 };406 if (!r.ok) {407 setRotateError(r.error ?? "Rotation failed.");408 return;409 }410 setRotateConfirmOpen(false);411 412 413 setShowNewRecoveryWords(true);414 } finally {415 setRotating(false);416 }417 }418419 const [dedupCount, setDedupCount] = createSignal<number | null>(null);420 const [dedupRunning, setDedupRunning] = createSignal(false);421 const [dedupDone, setDedupDone] = createSignal<number | null>(null);422423 const [declinedCardCount, setDeclinedCardCount] = createSignal(0);424425 426 427 428 const [historySnapshots, setHistorySnapshots] = createSignal<VaultSnapshot[]>([]);429 const [historyLoading, setHistoryLoading] = createSignal(false);430 const [historyRestoring, setHistoryRestoring] = createSignal<string | null>(null);431 const [historyShowAll, setHistoryShowAll] = createSignal(false);432433 async function loadVaultHistory() {434 setHistoryLoading(true);435 try {436 const res = await sendMessage({ type: "GET_VAULT_HISTORY" });437 const list = (res as { ok: boolean; data?: VaultSnapshot[] }).data ?? [];438 setHistorySnapshots(list);439 } finally {440 setHistoryLoading(false);441 }442 }443444 async function handleRestoreVaultSnapshot(id: string) {445 if (historyRestoring()) return;446 setHistoryRestoring(id);447 const res = await sendMessage({448 type: "RESTORE_VAULT_SNAPSHOT",449 payload: { snapshotId: id },450 });451 setHistoryRestoring(null);452 if ((res as { ok: boolean }).ok) {453 454 455 props.onBack();456 }457 }458459 function formatSnapshotTime(savedAt: number): string {460 const diff = Date.now() - savedAt;461 if (diff < 60_000) return "Just now";462 if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;463 const d = new Date(savedAt);464 const isToday = new Date().toDateString() === d.toDateString();465 const isYest =466 new Date(Date.now() - 86_400_000).toDateString() === d.toDateString();467 const t = d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });468 if (isToday) return `Today ${t}`;469 if (isYest) return `Yesterday ${t}`;470 return `${d.toLocaleDateString([], { month: "short", day: "numeric" })} ${t}`;471 }472473 function reasonLabel(reason: VaultSnapshot["reason"]): string {474 return (475 {476 local_save: "saved",477 sync_received: "synced",478 import: "imported",479 restore: "restored",480 } as const481 )[reason] ?? reason;482 }483484 const [showVaultAdvanced, setShowVaultAdvanced] = createSignal(false);485 const [showWhatsNew, setShowWhatsNew] = createSignal(false);486487 const [showChangePw, setShowChangePw] = createSignal(false);488 const [changePwCurrent, setChangePwCurrent] = createSignal("");489 const [changePwNew, setChangePwNew] = createSignal("");490 const [changePwConfirm, setChangePwConfirm] = createSignal("");491 const [changePwSaving, setChangePwSaving] = createSignal(false);492 const [changePwError, setChangePwError] = createSignal<string | null>(null);493 const [changePwSuccess] = createSignal(false);494 const [showNewRecoveryWords, setShowNewRecoveryWords] = createSignal(false);495496 const [pinEnabled, setPinEnabled] = createSignal(false);497 const [pinFlow, setPinFlow] = createSignal<"setup" | "disable" | null>(null);498 const [pinCurrentPassword, setPinCurrentPassword] = createSignal("");499 const [pinValue, setPinValue] = createSignal("");500 const [pinConfirm, setPinConfirm] = createSignal("");501 const [pinSaving, setPinSaving] = createSignal(false);502 const [pinError, setPinError] = createSignal<string | null>(null);503504 505 const [showViewWords, setShowViewWords] = createSignal(false);506 const [viewWordsPassword, setViewWordsPassword] = createSignal("");507 const [viewWordsError, setViewWordsError] = createSignal<string | null>(null);508 const [viewedWords, setViewedWords] = createSignal<string[] | null>(null);509 const [viewWordsBusy, setViewWordsBusy] = createSignal(false);510511 async function handleViewRecoveryWords() {512 setViewWordsError(null);513 if (!viewWordsPassword()) {514 setViewWordsError("Enter your master password.");515 return;516 }517 setViewWordsBusy(true);518 try {519 const r = (await sendMessage({520 type: "GET_RECOVERY_WORDS",521 payload: { password: viewWordsPassword() },522 })) as { ok: boolean; data?: string[]; error?: string };523 if (r.ok) {524 setViewedWords(r.data ?? []);525 setViewWordsPassword("");526 return;527 }528 switch (r.error) {529 case "wrong_password":530 setViewWordsError("Incorrect password");531 break;532 case "password_required":533 setViewWordsError("Enter your master password.");534 break;535 case "recovery_not_setup":536 setViewWordsError("Recovery words aren't set up for this vault yet.");537 break;538 default:539 setViewWordsError("Couldn't retrieve recovery words.");540 }541 } finally {542 setViewWordsBusy(false);543 }544 }545 function closeViewWords() {546 setShowViewWords(false);547 setViewWordsPassword("");548 setViewWordsError(null);549 setViewedWords(null);550 }551552 const [duressEnabled, setDuressEnabled] = createSignal(false);553 const [showDuressSetup, setShowDuressSetup] = createSignal(false);554 const [duressPassword, setDuressPassword] = createSignal("");555 const [duressConfirm, setDuressConfirm] = createSignal("");556 const [duressMainPassword, setDuressMainPassword] = createSignal("");557 const [duressSaving, setDuressSaving] = createSignal(false);558 const [duressError, setDuressError] = createSignal<string | null>(null);559 const [duressSuccess, setDuressSuccess] = createSignal<string | null>(null);560561 const [canaryEntries, setCanaryEntries] = createSignal<Array<{id: string; title: string; canary: Canary}>>([]);562563 const [canaryTestingId, setCanaryTestingId] = createSignal<string | null>(null);564 const [canaryTestResult, setCanaryTestResult] = createSignal<{id: string; ok: boolean} | null>(null);565566 const [fbCategory, setFbCategory] = createSignal("Bug report");567 const [fbMessage, setFbMessage] = createSignal("");568 type FeedbackState = 'idle' | 'sending' | 'sent' | 'error';569 const [feedbackState, setFeedbackState] = createSignal<FeedbackState>('idle');570571 createEffect(() => {572 if (props.upgradeHighlight) {573 setActiveCard("pro");574 }575 });576577 onMount(async () => {578 const [licenseRes, passkeyRes, duressRes, settingsRes, keyRes] = await Promise.all([579 sendMessage({ type: "GET_LICENSE" }),580 sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }),581 sendMessage({ type: "IS_DURESS_ENABLED" }),582 sendMessage({ type: "GET_SETTINGS" }),583 sendMessage({ type: "GET_LICENSE_KEY" }),584 ]);585586 587 588 void loadVaultHistory();589590 591 592 void loadPendingVouched();593 void loadPairedDevices();594 void loadSyncState();595 const onBroadcast = (msg: unknown) => {596 const m = msg as { type?: string; payload?: unknown } | null;597 if (m?.type === "VOUCHED_DEVICE_PENDING") {598 void loadPendingVouched();599 }600 if (m?.type === "SYNC_STATE_CHANGED") {601 const payload = (m.payload ?? {}) as SyncOutcomeSummary;602 setSyncSummary(payload);603 setSyncStatus(payload.status ?? "idle");604 }605 };606 chrome.runtime?.onMessage?.addListener(onBroadcast);607 onCleanup(() => {608 try { chrome.runtime?.onMessage?.removeListener(onBroadcast); } catch { }609 });610611 if ((licenseRes as { ok: boolean }).ok) {612 setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null);613 }614615 if ((passkeyRes as { ok: boolean }).ok) {616 setPasskeyState(617 (passkeyRes as { ok: boolean; data?: PasskeyProviderState }).data ?? null618 );619 }620621 if ((duressRes as { ok: boolean }).ok) {622 setDuressEnabled((duressRes as { ok: boolean; data?: boolean }).data ?? false);623 }624625 if ((settingsRes as { ok: boolean }).ok) {626 const sData = (settingsRes as { ok: boolean; data?: { declinedCardFingerprints?: string[]; pinEnabled?: boolean } }).data;627 setDeclinedCardCount(sData?.declinedCardFingerprints?.length ?? 0);628 setPinEnabled(sData?.pinEnabled === true);629 }630631 if ((keyRes as { ok: boolean }).ok) {632 setLicenseKey((keyRes as { ok: boolean; data?: string }).data ?? null);633 }634635 const entriesRes = await sendMessage({ type: "GET_ENTRIES" });636 if ((entriesRes as { ok: boolean }).ok) {637 const entries = (entriesRes as { ok: boolean; data?: Array<{id: string; title: string; canary?: Canary}> }).data ?? [];638 setCanaryEntries(639 entries640 .filter((e) => e.canary?.webhookUrl)641 .map((e) => ({ id: e.id, title: e.title, canary: e.canary! }))642 );643 }644645 });646647 function resetPinFlow() {648 setPinFlow(null);649 setPinCurrentPassword("");650 setPinValue("");651 setPinConfirm("");652 setPinError(null);653 }654655 async function handleSetupPin() {656 setPinError(null);657 if (!pinCurrentPassword()) { setPinError("enter your current password"); return; }658 if (!/^\d{4,12}$/.test(pinValue())) { setPinError("PIN must be 4–12 digits"); return; }659 if (pinValue() !== pinConfirm()) { setPinError("PINs do not match"); return; }660 setPinSaving(true);661 const res = await sendMessage({662 type: "SETUP_PIN",663 payload: { currentPassword: pinCurrentPassword(), pin: pinValue() },664 }) as { ok: boolean; error?: string };665 if (res.ok) {666 setPinEnabled(true);667 resetPinFlow();668 } else {669 setPinError(res.error === "incorrect_current_password" ? "incorrect current password" : "couldn't set up PIN");670 }671 setPinSaving(false);672 }673674 async function handleDisablePin() {675 setPinError(null);676 if (!pinCurrentPassword()) { setPinError("enter your current password"); return; }677 setPinSaving(true);678 const res = await sendMessage({679 type: "DISABLE_PIN",680 payload: { currentPassword: pinCurrentPassword() },681 }) as { ok: boolean; error?: string };682 if (res.ok) {683 setPinEnabled(false);684 resetPinFlow();685 } else {686 setPinError(res.error === "incorrect_current_password" ? "incorrect current password" : "couldn't remove PIN");687 }688 setPinSaving(false);689 }690691 async function handleChangePassword() {692 setChangePwError(null);693 if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; }694 if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; }695 if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; }696 if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; }697 setChangePwSaving(true);698 const res = await sendMessage({699 type: "CHANGE_PASSWORD",700 payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() },701 }) as { ok: boolean; error?: string };702 if (res.ok) {703 setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");704 setShowChangePw(false);705 706 707 708 await sendMessage({ type: "SET_RECOVERY_CONFIRM_PENDING" });709 setShowNewRecoveryWords(true);710 } else {711 setChangePwError(res.error ?? "failed to change password");712 }713 setChangePwSaving(false);714 }715716 const [copied, setCopied] = createSignal(false);717718 async function copyLicenseKey(key: string) {719 await navigator.clipboard.writeText(key);720 setCopied(true);721 setTimeout(() => setCopied(false), 2000);722 }723724 async function handleActivateLicense() {725 const key = licenseKeyInput().trim();726 if (!key) return;727 setActivatingLicense(true);728 setLicenseError(null);729 setLicenseSuccess(null);730 const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } });731 setActivatingLicense(false);732 if ((res as { ok: boolean }).ok) {733 const data = (res as { ok: boolean; data?: LicenseInfo }).data;734 if (data?.valid) {735 setLicenseInfo(data);736 setLicenseKey(key);737 setLicenseSuccess("License activated!");738 setLicenseKeyInput("");739 } else {740 setLicenseError("Invalid license key");741 }742 } else {743 setLicenseError("Activation failed");744 }745 }746747 async function handleTogglePasskeys() {748 const current = passkeyState();749 if (!current || !current.supported) return;750 setPasskeySaving(true);751 const res = await sendMessage({752 type: "SET_PASSKEY_PROVIDER_ENABLED",753 payload: { enabled: !current.enabled },754 });755 setPasskeySaving(false);756 if ((res as { ok: boolean }).ok) {757 setPasskeyState(758 (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null759 );760 }761 }762763 async function handleCheckAllBreaches() {764 setCheckingBreaches(true);765 setBreachResult(null);766 const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });767 setCheckingBreaches(false);768 if ((res as { ok: boolean }).ok) {769 const data = (res as { ok: boolean; data?: Record<string, number> }).data ?? {};770 const breached = Object.values(data).filter((count) => count > 0).length;771 setBreachResult(772 breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean"773 );774 props.onBreachCheckDone();775 }776 }777778779 async function handleSetupDuress() {780 const mainPw = duressMainPassword().trim();781 const duressPw = duressPassword().trim();782 const confirmPw = duressConfirm().trim();783784 if (!mainPw) { setDuressError("enter your main password to confirm"); return; }785 if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; }786 if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; }787 if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; }788789 setDuressSaving(true);790 setDuressError(null);791792 const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };793 if (!verifyRes.ok || !verifyRes.data) {794 setDuressError("incorrect main password");795 setDuressSaving(false);796 return;797 }798799 const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string };800 setDuressSaving(false);801 if (res.ok) {802 setDuressEnabled(true);803 setShowDuressSetup(false);804 setDuressPassword("");805 setDuressConfirm("");806 setDuressMainPassword("");807 setDuressSuccess("Duress password set. If entered, a decoy vault will be shown.");808 setTimeout(() => setDuressSuccess(null), 5000);809 } else {810 setDuressError(811 res.error === "session_expired"812 ? "Vault session expired — lock and unlock Peach, then try again"813 : res.error ?? "failed to set duress password"814 );815 }816 }817818 async function handleClearDeclinedCards() {819 await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } });820 setDeclinedCardCount(0);821 }822823 async function handleCountDuplicates() {824 setDedupRunning(true);825 setDedupDone(null);826 const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number };827 setDedupRunning(false);828 if (res.ok) setDedupCount(res.data ?? 0);829 }830831 async function handleDeduplicate() {832 setDedupRunning(true);833 const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number };834 setDedupRunning(false);835 if (res.ok) {836 setDedupDone(res.data ?? 0);837 setDedupCount(0);838 }839 }840841 async function handleDisableDuress() {842 const mainPw = duressMainPassword().trim();843 if (!mainPw) { setDuressError("enter your main password to confirm"); return; }844845 setDuressSaving(true);846 setDuressError(null);847848 const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };849 if (!verifyRes.ok || !verifyRes.data) {850 setDuressError("incorrect main password");851 setDuressSaving(false);852 return;853 }854855 const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string };856 setDuressSaving(false);857 if (res.ok) {858 setDuressEnabled(false);859 setShowDuressSetup(false);860 setDuressMainPassword("");861 setDuressSuccess("Duress password disabled.");862 setTimeout(() => setDuressSuccess(null), 4000);863 } else {864 setDuressError(res.error ?? "failed to disable duress password");865 }866 }867868 async function handleSubmitFeedback() {869 const message = sanitizeFeedbackMessage(fbMessage());870 if (!message) return;871872 setFeedbackState('sending');873874 try {875 const category = FEEDBACK_CATEGORIES.includes(fbCategory())876 ? fbCategory()877 : FEEDBACK_CATEGORIES[0];878 const res = await fetch('https://api.web3forms.com/submit', {879 method: 'POST',880 headers: { 'Content-Type': 'application/json' },881 body: JSON.stringify({882 access_key: WEB3FORMS_KEY,883 subject: `Peach Feedback - ${category}`,884 message,885 from_name: 'Peach User',886 887 botcheck: '',888 }),889 });890891 if (!res.ok) throw new Error(`HTTP ${res.status}`);892 const data = await res.json() as { success: boolean; message?: string };893 if (!data.success) throw new Error(data.message ?? 'unknown error');894895 setFeedbackState('sent');896 setFbMessage('');897 setFbCategory(FEEDBACK_CATEGORIES[0]);898 } catch (err) {899 console.error('[feedback]', err);900 setFeedbackState('error');901 setTimeout(() => setFeedbackState('idle'), 4000);902 }903 }904905 function renderSecurity() {906 return (907 <div class="peach-settings-body">908 <div class="peach-settings-group">909 <p class="peach-settings-sub-label">LOCKING</p>910 <div class="peach-settings-row">911 <span>Lock on close</span>912 <button913 class={`peach-toggle ${props.lockOnClose ? "is-on" : ""}`}914 onClick={() => void props.onLockOnCloseChange(!props.lockOnClose)}915 aria-label="Toggle lock on close"916 >917 <span class="peach-toggle-knob"></span>918 </button>919 </div>920 <div class="peach-settings-row">921 <span>Auto-lock after</span>922 <select923 class="peach-settings-select"924 value={String(props.autoLockMinutes)}925 onChange={(e) => void props.onAutoLockMinutesChange(Number(e.currentTarget.value))}926 >927 <option value="1">1 minute</option>928 <option value="5">5 minutes</option>929 <option value="15">15 minutes</option>930 <option value="30">30 minutes</option>931 <option value="0">Never</option>932 </select>933 </div>934935 <p class="peach-settings-sub-label">QUICK UNLOCK</p>936 <div class="peach-settings-row peach-settings-row--meta">937 <span>PIN unlock</span>938 <button939 class="peach-btn peach-btn--subtle peach-btn--sm"940 onClick={() => { setPinFlow(pinEnabled() ? "disable" : "setup"); setPinError(null); }}941 >942 {pinEnabled() ? "remove" : "set up"}943 </button>944 </div>945 <Show when={pinFlow() === "setup"}>946 <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">947 <input948 class="peach-settings-input"949 type="password"950 placeholder="current password"951 value={pinCurrentPassword()}952 onInput={(e) => setPinCurrentPassword((e.target as HTMLInputElement).value)}953 autocomplete="current-password"954 />955 <input956 class="peach-settings-input"957 type="password"958 inputmode="numeric"959 maxlength="12"960 placeholder="new 4–12 digit PIN"961 value={pinValue()}962 onInput={(e) => setPinValue((e.target as HTMLInputElement).value.replace(/\D/g, "").slice(0, 12))}963 autocomplete="off"964 />965 <input966 class="peach-settings-input"967 type="password"968 inputmode="numeric"969 maxlength="12"970 placeholder="confirm PIN"971 value={pinConfirm()}972 onInput={(e) => setPinConfirm((e.target as HTMLInputElement).value.replace(/\D/g, "").slice(0, 12))}973 autocomplete="off"974 onKeyDown={(e) => { if (e.key === "Enter") void handleSetupPin(); }}975 />976 <Show when={pinError()}>977 <p class="peach-hint peach-hint-error" style="margin:0">{pinError()}</p>978 </Show>979 <button980 class="peach-btn peach-btn--accent peach-btn--sm"981 style="align-self:flex-start"982 onClick={() => void handleSetupPin()}983 disabled={pinSaving() || !pinCurrentPassword() || !/^\d{4,12}$/.test(pinValue()) || !/^\d{4,12}$/.test(pinConfirm())}984 >985 {pinSaving() ? "saving…" : "save PIN"}986 </button>987 </div>988 </Show>989 <Show when={pinFlow() === "disable"}>990 <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">991 <input992 class="peach-settings-input"993 type="password"994 placeholder="current password"995 value={pinCurrentPassword()}996 onInput={(e) => setPinCurrentPassword((e.target as HTMLInputElement).value)}997 autocomplete="current-password"998 onKeyDown={(e) => { if (e.key === "Enter") void handleDisablePin(); }}999 />1000 <Show when={pinError()}>1001 <p class="peach-hint peach-hint-error" style="margin:0">{pinError()}</p>1002 </Show>1003 <button1004 class="peach-btn peach-btn--subtle peach-btn--sm"1005 style="align-self:flex-start"1006 onClick={() => void handleDisablePin()}1007 disabled={pinSaving() || !pinCurrentPassword()}1008 >1009 {pinSaving() ? "removing…" : "remove PIN"}1010 </button>1011 </div>1012 </Show>10131014 <p class="peach-settings-sub-label">BREACH PROTECTION</p>1015 <div class="peach-settings-row">1016 <span>Breach monitoring</span>1017 <button1018 class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}1019 onClick={() => void props.onBreachMonitoringChange(!props.breachMonitoring)}1020 aria-label="Toggle breach monitoring"1021 >1022 <span class="peach-toggle-knob"></span>1023 </button>1024 </div>1025 <div class="peach-settings-row peach-settings-row--meta">1026 <span>Check all passwords</span>1027 <button1028 class="peach-btn peach-btn--subtle peach-btn--sm"1029 onClick={() => void handleCheckAllBreaches()}1030 disabled={checkingBreaches()}1031 >1032 {checkingBreaches() ? "checking..." : "run"}1033 </button>1034 </div>1035 <Show when={breachResult()}>1036 <div class="peach-settings-row peach-settings-row--meta">1037 <span>Last check</span>1038 <span class="peach-settings-value">{breachResult()}</span>1039 </div>1040 </Show>10411042 <p class="peach-settings-sub-label">MASTER PASSWORD</p>1043 <div class="peach-settings-row peach-settings-row--meta">1044 <span>Master password</span>1045 <button1046 class="peach-btn peach-btn--subtle peach-btn--sm"1047 onClick={() => { setShowChangePw((v) => !v); setChangePwError(null); }}1048 >1049 {showChangePw() ? "cancel" : "change"}1050 </button>1051 </div>1052 <Show when={showChangePw()}>1053 <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1054 <input1055 class="peach-settings-input"1056 type="password"1057 placeholder="current password"1058 value={changePwCurrent()}1059 onInput={(e) => setChangePwCurrent((e.target as HTMLInputElement).value)}1060 autocomplete="current-password"1061 />1062 <input1063 class="peach-settings-input"1064 type="password"1065 placeholder="new password (min 12 chars)"1066 value={changePwNew()}1067 onInput={(e) => setChangePwNew((e.target as HTMLInputElement).value)}1068 autocomplete="new-password"1069 />1070 <input1071 class="peach-settings-input"1072 type="password"1073 placeholder="confirm new password"1074 value={changePwConfirm()}1075 onInput={(e) => setChangePwConfirm((e.target as HTMLInputElement).value)}1076 autocomplete="new-password"1077 onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }}1078 />1079 <Show when={changePwError()}>1080 <p class="peach-hint peach-hint-error" style="margin:0">{changePwError()}</p>1081 </Show>1082 <Show when={changePwSuccess()}>1083 <p class="peach-hint" style="margin:0;color:var(--peach-accent)">password changed ✓</p>1084 </Show>1085 <button1086 class="peach-btn peach-btn--accent peach-btn--sm"1087 style="align-self:flex-start"1088 onClick={() => void handleChangePassword()}1089 disabled={changePwSaving() || !changePwCurrent() || !changePwNew() || !changePwConfirm()}1090 >1091 {changePwSaving() ? "saving…" : "save new password"}1092 </button>1093 </div>1094 </Show>10951096 {}1097 <div class="peach-settings-row peach-settings-row--meta">1098 <span>Recovery words</span>1099 <button1100 class="peach-btn peach-btn--subtle peach-btn--sm"1101 onClick={() => {1102 if (showViewWords()) { closeViewWords(); } else { setShowViewWords(true); }1103 }}1104 >1105 {showViewWords() ? "cancel" : "view"}1106 </button>1107 </div>1108 <Show when={showViewWords()}>1109 <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1110 <Show1111 when={viewedWords()}1112 fallback={1113 <>1114 <p class="peach-hint" style="margin:0;color:var(--peach-t2)">1115 Enter your master password to reveal your recovery words.1116 </p>1117 <input1118 class="peach-settings-input"1119 type="password"1120 placeholder="master password"1121 value={viewWordsPassword()}1122 onInput={(e) => setViewWordsPassword((e.target as HTMLInputElement).value)}1123 autocomplete="current-password"1124 onKeyDown={(e) => { if (e.key === "Enter") void handleViewRecoveryWords(); }}1125 />1126 <Show when={viewWordsError()}>1127 <p class="peach-hint peach-hint-error" style="margin:0">{viewWordsError()}</p>1128 </Show>1129 <button1130 class="peach-btn peach-btn--accent peach-btn--sm"1131 style="align-self:flex-start"1132 onClick={() => void handleViewRecoveryWords()}1133 disabled={viewWordsBusy() || !viewWordsPassword()}1134 >1135 {viewWordsBusy() ? "checking…" : "reveal recovery words"}1136 </button>1137 </>1138 }1139 >1140 <div class="peach-recovery-words-grid">1141 <For each={viewedWords()!}>1142 {(word, i) => (1143 <span class="peach-recovery-word"><b>{i() + 1}</b> {word}</span>1144 )}1145 </For>1146 </div>1147 <button1148 class="peach-btn peach-btn--subtle peach-btn--sm"1149 style="align-self:flex-start"1150 onClick={closeViewWords}1151 >done</button>1152 </Show>1153 </div>1154 </Show>11551156 <p class="peach-settings-sub-label">1157 DURESS PASSWORD1158 <Show when={!isPro()}>1159 <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>1160 </Show>1161 </p>1162 <div class="peach-settings-row peach-settings-row--top">1163 <div class="peach-settings-row-main">1164 <span>1165 Duress password1166 <Show when={!isPro()}>1167 <span class="peach-pro-badge">Pro</span>1168 </Show>1169 </span>1170 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">1171 If entered under coercion, shows a decoy empty vault instead of your real one.1172 </span>1173 </div>1174 <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>1175 {duressEnabled() ? "on" : "off"}1176 </div>1177 </div>11781179 <Show when={duressEnabled() && !showDuressSetup()}>1180 <div class="peach-settings-row peach-settings-row--meta">1181 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1182 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.1183 </span>1184 </div>1185 </Show>11861187 <Show when={duressSuccess()}>1188 <div class="peach-settings-row peach-settings-row--meta">1189 <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>1190 </div>1191 </Show>11921193 <Show when={!showDuressSetup()}>1194 <div class="peach-settings-row peach-settings-row--meta">1195 <button1196 class="peach-btn peach-btn--subtle peach-btn--sm"1197 onClick={() => {1198 1199 1200 1201 1202 1203 if (!isPro() && !duressEnabled()) {1204 props.onProGate?.("duress_vault");1205 setActiveCard("pro");1206 return;1207 }1208 setShowDuressSetup(true);1209 setDuressError(null);1210 setDuressSuccess(null);1211 setDuressPassword("");1212 setDuressConfirm("");1213 setDuressMainPassword("");1214 }}1215 >1216 {duressEnabled() ? "Change / disable" : "Set up"}1217 </button>1218 </div>1219 </Show>12201221 <Show when={showDuressSetup()}>1222 <div class="peach-duress-form">1223 <label class="peach-label">main password (to confirm)</label>1224 <input1225 class="peach-settings-input"1226 type="password"1227 placeholder="your current master password"1228 autocomplete="current-password"1229 value={duressMainPassword()}1230 onInput={(e) => setDuressMainPassword(e.currentTarget.value)}1231 />1232 <Show when={!duressEnabled()}>1233 <label class="peach-label">new duress password</label>1234 <input1235 class="peach-settings-input"1236 type="password"1237 placeholder="duress password (min 8 chars)"1238 autocomplete="new-password"1239 value={duressPassword()}1240 onInput={(e) => setDuressPassword(e.currentTarget.value)}1241 />1242 <label class="peach-label">confirm duress password</label>1243 <input1244 class="peach-settings-input"1245 type="password"1246 placeholder="confirm duress password"1247 autocomplete="new-password"1248 value={duressConfirm()}1249 onInput={(e) => setDuressConfirm(e.currentTarget.value)}1250 />1251 </Show>12521253 <Show when={duressError()}>1254 <p class="peach-hint peach-hint-error">{duressError()}</p>1255 </Show>12561257 <div class="peach-duress-actions">1258 <button1259 class="peach-btn peach-btn--subtle peach-btn--sm"1260 onClick={() => { setShowDuressSetup(false); setDuressError(null); }}1261 >1262 cancel1263 </button>1264 <Show when={duressEnabled()}>1265 <button1266 class="peach-btn peach-btn--ghost peach-btn--sm"1267 style="color:var(--peach-breach)"1268 onClick={() => void handleDisableDuress()}1269 disabled={duressSaving()}1270 >1271 {duressSaving() ? "disabling..." : "disable duress"}1272 </button>1273 </Show>1274 <Show when={!duressEnabled()}>1275 <button1276 class="peach-btn peach-btn--accent peach-btn--sm"1277 onClick={() => void handleSetupDuress()}1278 disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}1279 >1280 {duressSaving() ? "saving..." : "save"}1281 </button>1282 </Show>1283 </div>1284 </div>1285 </Show>12861287 <p class="peach-settings-sub-label">1288 CANARY ENTRIES1289 <Show when={!isPro()}>1290 <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>1291 </Show>1292 </p>1293 <div class="peach-settings-row peach-settings-row--top">1294 <div class="peach-settings-row-main">1295 <span>1296 Canary entries1297 <Show when={!isPro()}>1298 <span class="peach-pro-badge">Pro</span>1299 </Show>1300 </span>1301 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1302 Silently pings a webhook when an entry is accessed. Configure canary on any entry in your vault.1303 </span>1304 </div>1305 <span class="peach-settings-value" style="flex-shrink:0">1306 {canaryEntries().length > 0 ? `${canaryEntries().length} active` : "none"}1307 </span>1308 </div>1309 <Show when={canaryEntries().length > 0}>1310 <For each={canaryEntries()}>1311 {(entry) => (1312 <div class="peach-settings-row peach-settings-row--meta">1313 <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0">{entry.title}</span>1314 <button1315 class="peach-btn peach-btn--subtle peach-btn--sm"1316 onClick={() => void handleTestCanary(entry)}1317 disabled={canaryTestingId() === entry.id}1318 style="flex-shrink:0"1319 >1320 {canaryTestingId() === entry.id1321 ? "sending…"1322 : canaryTestResult()?.id === entry.id1323 ? (canaryTestResult()!.ok ? "sent ✓" : "failed ✗")1324 : "test"}1325 </button>1326 </div>1327 )}1328 </For>1329 </Show>13301331 <p class="peach-settings-sub-label">PASSKEYS</p>1332 <div class="peach-settings-row peach-settings-row--top">1333 <div class="peach-settings-row-main">1334 <span>Intercept passkey requests</span>1335 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">1336 Peach can handle passkey sign-in and creation on supported Chromium browsers.1337 </span>1338 </div>1339 <button1340 class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}1341 onClick={() => void handleTogglePasskeys()}1342 aria-label="Toggle passkey interception"1343 disabled={!passkeyState()?.supported || passkeySaving()}1344 >1345 <span class="peach-toggle-knob"></span>1346 </button>1347 </div>1348 <div class="peach-settings-row peach-settings-row--meta">1349 <span>Status</span>1350 <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>1351 </div>1352 <div class="peach-settings-row peach-settings-row--meta">1353 <span>Browser</span>1354 <span class="peach-settings-value">1355 {passkeyState()?.browserLabel ?? "Unknown"}1356 {passkeyState()?.supported ? " ✓" : " (interception unavailable)"}1357 </span>1358 </div>1359 <div class="peach-info-notice" style="margin-top:8px">1360 Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.1361 </div>1362 <Show when={passkeyState()?.conflict}>1363 <div class="peach-settings-row peach-settings-row--meta">1364 <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>1365 </div>1366 </Show>1367 </div>1368 </div>1369 );1370 }13711372 function renderVault() {1373 return (1374 <div class="peach-settings-body">1375 <div class="peach-settings-group">1376 <p class="peach-settings-sub-label">ENTRIES</p>1377 <div class="peach-settings-row peach-settings-row--meta">1378 <span>Entries</span>1379 <span class="peach-settings-value">{props.entriesCount}</span>1380 </div>1381 <div class="peach-settings-row peach-settings-row--meta">1382 <span>Duplicates</span>1383 <Show when={dedupDone() !== null} fallback={1384 <button1385 class="peach-btn peach-btn--subtle peach-btn--sm"1386 onClick={() => void handleCountDuplicates()}1387 disabled={dedupRunning()}1388 >1389 {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}1390 </button>1391 }>1392 <span class="peach-settings-value" style="color:var(--peach-sync-ok)">1393 {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}1394 </span>1395 </Show>1396 </div>1397 <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>1398 <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">1399 <div class="peach-restore-warning">1400 {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.1401 </div>1402 <div style="display:flex;gap:6px">1403 <button1404 class="peach-btn peach-btn--subtle peach-btn--sm"1405 onClick={() => setDedupCount(null)}1406 >1407 cancel1408 </button>1409 <button1410 class="peach-btn peach-btn--accent peach-btn--sm"1411 onClick={() => void handleDeduplicate()}1412 disabled={dedupRunning()}1413 >1414 {dedupRunning() ? "removing..." : "remove duplicates"}1415 </button>1416 </div>1417 </div>1418 </Show>14191420 <p class="peach-settings-sub-label">MAINTENANCE</p>1421 <div class="peach-settings-row peach-settings-row--meta">1422 <span>Trash auto-delete</span>1423 <select1424 class="peach-settings-select"1425 value={String(props.trashRetentionDays)}1426 onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}1427 >1428 <option value="7">7 days</option>1429 <option value="30">30 days</option>1430 <option value="90">90 days</option>1431 <option value="0">Never</option>1432 </select>1433 </div>1434 <div class="peach-settings-row peach-settings-row--meta">1435 <span>Advanced Vault Health</span>1436 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>1437 open1438 </button>1439 </div>1440 <Show when={declinedCardCount() > 0}>1441 <div class="peach-settings-row peach-settings-row--meta">1442 <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>1443 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>1444 Clear1445 </button>1446 </div>1447 </Show>14481449 <p class="peach-settings-sub-label">DATA</p>1450 <div class="peach-settings-row peach-settings-row--meta">1451 <span>Import passwords</span>1452 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>1453 open1454 </button>1455 </div>1456 <div class="peach-settings-row peach-settings-row--meta">1457 <span>Export backup</span>1458 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onExport}>1459 open1460 </button>1461 </div>1462 <div class="peach-settings-row peach-settings-row--meta">1463 <span>1464 Peach Codex1465 <Show when={!isPro()}>1466 <span class="peach-pro-badge">Pro</span>1467 </Show>1468 </span>1469 <button1470 class="peach-btn peach-btn--subtle peach-btn--sm"1471 onClick={() => props.onOpenCodexGenerate?.()}1472 >1473 open1474 </button>1475 </div>1476 {1477}1478 <div class="peach-settings-row peach-settings-row--meta">1479 <span>Restore from Codex</span>1480 <button1481 class="peach-btn peach-btn--subtle peach-btn--sm"1482 onClick={() => props.onOpenCodexRestore?.()}1483 >1484 open1485 </button>1486 </div>14871488 <Show when={hasReleaseNotes()}>1489 <p class="peach-settings-sub-label">RELEASE NOTES</p>1490 <div class="peach-settings-row peach-settings-row--meta">1491 <span>v{PEACH_VERSION}</span>1492 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => setShowWhatsNew(true)}>1493 view1494 </button>1495 </div>1496 </Show>14971498 <span class="peach-settings-sub-label">VAULT HISTORY</span>1499 <Show when={historyLoading()}>1500 <p class="peach-settings-hint">Loading…</p>1501 </Show>1502 <Show when={!historyLoading() && historySnapshots().length === 0}>1503 <p class="peach-settings-hint">1504 No snapshots yet. Peach saves automatically before every change —1505 saves, syncs, and imports.1506 </p>1507 </Show>1508 <Show when={!historyLoading() && historySnapshots().length > 0}>1509 <p class="peach-settings-hint">1510 Restoring locks your vault. Unlock with the password that1511 encrypted that snapshot to confirm.1512 </p>1513 <div class="peach-history-list">1514 <For each={historySnapshots().slice(0, historyShowAll() ? undefined : 3)}>1515 {(snap) => (1516 <div class="peach-history-row">1517 <div class="peach-history-meta">1518 <span class="peach-history-time">1519 {formatSnapshotTime(snap.savedAt)}1520 </span>1521 <span class="peach-history-sub">1522 {[1523 snap.entryCount !== null1524 ? `up to ${snap.entryCount} ${snap.entryCount === 1 ? "entry" : "entries"}`1525 : null,1526 reasonLabel(snap.reason),1527 ]1528 .filter(Boolean)1529 .join(" · ")}1530 </span>1531 </div>1532 <button1533 class={`peach-btn peach-btn--subtle peach-btn--sm peach-history-btn${1534 historyRestoring() === snap.id ? " is-busy" : ""1535 }`}1536 onClick={() => void handleRestoreVaultSnapshot(snap.id)}1537 disabled={historyRestoring() !== null}1538 >1539 <Show when={historyRestoring() === snap.id} fallback="Restore">1540 …1541 </Show>1542 </button>1543 </div>1544 )}1545 </For>1546 </div>1547 <Show when={historySnapshots().length > 3}>1548 <button1549 class="peach-settings-advanced-toggle"1550 onClick={() => setHistoryShowAll((v) => !v)}1551 aria-expanded={historyShowAll()}1552 >1553 <span class="peach-settings-hint" style="margin:0">1554 {historyShowAll() ? "show less" : `${historySnapshots().length - 3} older`}1555 </span>1556 <span class="peach-settings-advanced-chevron">1557 {historyShowAll() ? "▲" : "▼"}1558 </span>1559 </button>1560 </Show>1561 <p class="peach-history-footer">1562 {historySnapshots().length} of 10 snapshots1563 </p>1564 </Show>15651566 <button1567 class="peach-settings-advanced-toggle"1568 onClick={() => setShowVaultAdvanced((v) => !v)}1569 aria-expanded={showVaultAdvanced()}1570 >1571 <span class="peach-settings-sub-label" style="margin:0">ADVANCED</span>1572 <span class="peach-settings-advanced-chevron">1573 {showVaultAdvanced() ? "▲" : "▼"}1574 </span>1575 </button>15761577 <Show when={showVaultAdvanced()}>1578 <div class="peach-danger-card">1579 <div class="peach-danger-card-head">1580 <span class="peach-danger-card-glyph" aria-hidden="true">⟳</span>1581 <h3 class="peach-danger-card-title">Replace vault from backup</h3>1582 </div>1583 <p class="peach-danger-card-copy">1584 Wipes the current vault and restores from a backup file.1585 Recoverable only if your backup is good.1586 </p>1587 <button1588 class="peach-danger-card-button"1589 onClick={() => props.onOpenFileImport?.()}1590 >1591 Choose backup file1592 </button>1593 </div>15941595 <Show when={props.onOpenPanic}>1596 <div class="peach-danger-card peach-danger-card--hazard">1597 <div class="peach-danger-card-head">1598 <span class="peach-danger-card-glyph" aria-hidden="true">⚠</span>1599 <h3 class="peach-danger-card-title">Destroy vault</h3>1600 </div>1601 <p class="peach-danger-card-copy">1602 Permanently wipes every entry, setting, and license on this1603 device. There is no recovery.1604 </p>1605 <button1606 class="peach-danger-card-button"1607 onClick={() => props.onOpenPanic?.()}1608 >1609 Begin destruction1610 </button>1611 </div>1612 </Show>1613 </Show>1614 </div>1615 </div>1616 );1617 }16181619 function renderSync() {1620 return (1621 <div class="peach-settings-body">1622 <div class="peach-settings-group">1623 <p class="peach-settings-sub-label" style="margin-top:0">SYNC STATUS</p>1624 <div1625 class="peach-settings-row peach-settings-row--meta"1626 style="align-items:center;gap:8px"1627 role="status"1628 aria-label={syncLightLabel()}1629 title={syncLightLabel()}1630 >1631 <span class={`stk-pip stk-pip--sync is-${syncLight()}`} aria-hidden="true" />1632 <span class="peach-settings-value" style="color:var(--peach-t2);line-height:1.5;flex:1;min-width:0;overflow-wrap:anywhere">1633 {syncFlash() ?? lastSyncLine()}1634 </span>1635 </div>1636 <Show when={pairedDevices().length > 0}>1637 <div class="peach-settings-row peach-settings-row--meta">1638 <span class="peach-settings-value" style="color:var(--peach-t2)">1639 {(syncSummary().entriesSent ?? 0) + (syncSummary().entriesReceived ?? 0) > 01640 ? `${syncSummary().entriesSent ?? 0} sent · ${syncSummary().entriesReceived ?? 0} received`1641 : ""}1642 </span>1643 <button1644 class="peach-btn peach-btn--subtle peach-btn--sm"1645 aria-label="Sync this vault now"1646 disabled={syncStatus() === "syncing"}1647 onClick={() => void syncNow()}1648 >{syncStatus() === "syncing" ? "syncing…" : "sync now"}</button>1649 </div>1650 </Show>16511652 <p class="peach-settings-sub-label">ADD A DEVICE</p>1653 <button1654 class="peach-sync-pair-option"1655 onClick={() => props.onOpenSyncPairing?.("code")}1656 >1657 <span class="peach-sync-pair-option-icon" aria-hidden="true">1658 <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">1659 <rect x="6" y="2.5" width="8" height="15" rx="1.6" />1660 <path d="M9 15 L11 15" />1661 </svg>1662 </span>1663 <span class="peach-sync-pair-option-main">1664 <span class="peach-sync-pair-option-title">Show a code</span>1665 <span class="peach-sync-pair-option-sub">Scan it with Peach on your phone, then confirm one 8-digit code</span>1666 </span>1667 <span class="peach-sync-pair-option-chevron" aria-hidden="true">›</span>1668 </button>1669 <Show when={!isPro()}>1670 <p class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2);margin:8px 2px 0">1671 At least one device needs Peach Pro — pair with a Pro device and it carries over to here.1672 </p>1673 </Show>16741675 <Show when={pairedDevices().length > 0}>1676 <p class="peach-settings-sub-label">PAIRED DEVICES</p>1677 <For each={pairedDevices()}>1678 {(d) => (1679 <div class="peach-settings-row peach-settings-row--top">1680 <div class="peach-settings-row-main">1681 <Show1682 when={renameDeviceId() === d.deviceId}1683 fallback={<span>{d.localAlias ?? d.deviceName ?? "Unnamed device"}</span>}1684 >1685 <input1686 class="peach-settings-input"1687 aria-label={`Rename ${d.localAlias ?? d.deviceName ?? "device"}`}1688 maxlength={64}1689 value={renameDraft()}1690 onInput={(event) => setRenameDraft(event.currentTarget.value)}1691 onKeyDown={(event) => {1692 if (event.key === "Enter") void renameDevice(d.deviceId);1693 if (event.key === "Escape") setRenameDeviceId(null);1694 }}1695 />1696 </Show>1697 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1698 {deviceTypeLabel(d.deviceType)} · {syncRelativeTime(d.lastSyncedAt)}{isSyncStale(d) ? " · not seen recently" : ""}1699 </span>1700 </div>1701 <Show1702 when={confirmUnpair() === d.deviceId}1703 fallback={1704 <div style="display:flex;gap:4px;flex-shrink:0">1705 <Show1706 when={renameDeviceId() === d.deviceId}1707 fallback={1708 <button1709 class="peach-btn peach-btn--subtle peach-btn--sm"1710 onClick={() => {1711 setRenameDraft(d.localAlias ?? d.deviceName ?? "");1712 setRenameDeviceId(d.deviceId);1713 }}1714 >rename</button>1715 }1716 >1717 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => setRenameDeviceId(null)}>cancel</button>1718 <button class="peach-btn peach-btn--subtle peach-btn--sm" disabled={!renameDraft().trim()} onClick={() => void renameDevice(d.deviceId)}>save</button>1719 </Show>1720 <button1721 class="peach-btn peach-btn--subtle peach-btn--sm"1722 onClick={() => setConfirmUnpair(d.deviceId)}1723 >remove</button>1724 </div>1725 }1726 >1727 {17281729173017311732}1733 <div class="peach-unpair-confirm">1734 <p class="peach-unpair-confirm-note">1735 Stops this device receiving future changes. It doesn't1736 lock it out of what it already has — if it's lost or1737 stolen, also change your master password and generate1738 new recovery words.1739 </p>1740 <div style="display:flex;gap:6px;justify-content:flex-end">1741 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => setConfirmUnpair(null)}>cancel</button>1742 <button1743 class="peach-btn peach-btn--sm"1744 style="background:var(--peach-breach);border-color:var(--peach-breach)"1745 onClick={() => void unpairDevice(d.deviceId)}1746 >remove</button>1747 </div>1748 </div>1749 </Show>1750 </div>1751 )}1752 </For>1753 </Show>17541755 <Show when={pendingVouched().length > 0}>1756 <p class="peach-settings-sub-label">1757 PENDING DEVICES1758 <span class="peach-pro-badge" style="background:#C8A840;color:#1a1a1a">{pendingVouched().length}</span>1759 </p>1760 <For each={pendingVouched()}>1761 {(dev) => (1762 <div class="peach-settings-row peach-settings-row--top">1763 <div class="peach-settings-row-main">1764 <span>{dev.deviceName}</span>1765 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1766 Key {dev.longTermPublicKeyHex.slice(0, 12).match(/.{4}/g)?.join(" ")} ·1767 requested {new Date(dev.receivedAt).toISOString().slice(0, 16).replace("T", " ")} UTC ·1768 vouched by device {dev.vouchedByDeviceId}. Allowing it can read1769 existing vault contents and all future changes.1770 </span>1771 </div>1772 <div style="display:flex;gap:6px;flex-shrink:0">1773 <button1774 class="peach-btn peach-btn--sm"1775 disabled={vouchedBusy() === dev.deviceId}1776 onClick={() => void confirmVouched(dev.deviceId)}1777 >Allow</button>1778 <button1779 class="peach-btn peach-btn--subtle peach-btn--sm"1780 disabled={vouchedBusy() === dev.deviceId}1781 onClick={() => void rejectVouched(dev.deviceId)}1782 >Reject</button>1783 </div>1784 </div>1785 )}1786 </For>1787 </Show>17881789 <button1790 class="peach-settings-advanced-toggle peach-sync-disclosure-toggle"1791 onClick={() => setShowSyncConnection((value) => !value)}1792 aria-expanded={showSyncConnection()}1793 aria-controls="peach-sync-connection"1794 >1795 <span>1796 <span class="peach-settings-sub-label" style="margin:0">CONNECTION</span>1797 <span class="peach-sync-disclosure-summary">Relay and privacy choices</span>1798 </span>1799 <span class="peach-settings-advanced-chevron">1800 {showSyncConnection() ? "▲" : "▼"}1801 </span>1802 </button>1803 <Show when={showSyncConnection()}>1804 <div id="peach-sync-connection" class="peach-sync-disclosure-content">1805 <SyncModeSelector />1806 </div>1807 </Show>18081809 <button1810 class="peach-settings-advanced-toggle peach-sync-disclosure-toggle"1811 onClick={() => setShowSyncRecovery((value) => !value)}1812 aria-expanded={showSyncRecovery()}1813 aria-controls="peach-sync-recovery"1814 >1815 <span>1816 <span class="peach-settings-sub-label" style="margin:0">RECOVERY & SECURITY</span>1817 <span class="peach-sync-disclosure-summary">For a lost or stolen device</span>1818 </span>1819 <span class="peach-settings-advanced-chevron">1820 {showSyncRecovery() ? "▲" : "▼"}1821 </span>1822 </button>1823 <Show when={showSyncRecovery()}>1824 <div id="peach-sync-recovery" class="peach-sync-disclosure-content">1825 <Show1826 when={rotateConfirmOpen()}1827 fallback={1828 <div class="peach-settings-row peach-settings-row--top">1829 <div class="peach-settings-row-main">1830 <span>Rotate sync identity</span>1831 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1832 Disconnects every device and replaces the keys used for sync.1833 </span>1834 </div>1835 <button1836 class="peach-btn peach-btn--subtle peach-btn--sm"1837 style="color:var(--peach-breach);border-color:color-mix(in srgb,var(--peach-breach) 40%,transparent)"1838 onClick={() => { setRotateError(null); setRotateConfirmOpen(true); }}1839 >rotate</button>1840 </div>1841 }1842 >1843 <div class="peach-info-notice" style="margin-top:8px;border-color:color-mix(in srgb,var(--peach-breach) 40%,transparent)">1844 This disconnects every paired device and changes your 12 recovery words. You'll be shown the new words next.1845 <Show when={rotateError()}>1846 <div style="color:var(--peach-breach);margin-top:6px">{rotateError()}</div>1847 </Show>1848 <div style="display:flex;gap:6px;margin-top:10px">1849 <button1850 class="peach-btn peach-btn--subtle peach-btn--sm"1851 disabled={rotating()}1852 onClick={() => setRotateConfirmOpen(false)}1853 >Cancel</button>1854 <button1855 class="peach-btn peach-btn--sm"1856 style="background:var(--peach-breach);border-color:var(--peach-breach)"1857 disabled={rotating()}1858 onClick={() => void doRotateSyncIdentity()}1859 >{rotating() ? "Rotating…" : "Rotate sync identity"}</button>1860 </div>1861 </div>1862 </Show>1863 </div>1864 </Show>1865 </div>1866 </div>1867 );1868 }18691870 function renderAppearance() {1871 const sizeOptions: Array<{ id: "medium" | "large"; label: string }> = [1872 { id: "medium", label: "Default" },1873 { id: "large", label: "Large" },1874 ];1875 return (1876 <div class="peach-settings-body">1877 <div class="peach-settings-group">1878 <p class="peach-settings-sub-label">THEME</p>1879 <div class="peach-settings-row peach-settings-row--meta">1880 <span>Theme</span>1881 <button1882 class="peach-btn peach-btn--subtle peach-btn--sm"1883 onClick={() =>1884 props.onThemeChange(props.theme === "dark" ? "light" : "dark")1885 }1886 >1887 {props.theme === "dark" ? "Switch to light" : "Switch to dark"}1888 </button>1889 </div>18901891 <p class="peach-settings-sub-label">ACCENT</p>1892 <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">1893 <span>Accent</span>1894 <div class="peach-accent-picker">1895 <For each={ACCENT_COLORS}>1896 {(color) => (1897 <button1898 class="peach-accent-swatch peach-swatch"1899 classList={{ "is-active": props.accentColor === color.name }}1900 style={{ background: color.hex }}1901 onClick={() => void props.onAccentChange(color.name)}1902 aria-label={color.name}1903 title={color.name}1904 />1905 )}1906 </For>1907 </div>1908 </div>19091910 <p class="peach-settings-sub-label">TEXT SIZE</p>1911 <div class="peach-settings-row peach-settings-row--meta">1912 <span>Size</span>1913 <div class="peach-pill-group">1914 <For each={sizeOptions}>1915 {(opt) => (1916 <button1917 class="peach-btn peach-btn--ghost peach-btn--sm"1918 classList={{ "is-active": props.textSize === opt.id }}1919 onClick={() => void props.onTextSizeChange(opt.id)}1920 >1921 {opt.label}1922 </button>1923 )}1924 </For>1925 </div>1926 </div>19271928 <p class="peach-settings-sub-label">MOTION</p>1929 <div class="peach-settings-row peach-settings-row--meta">1930 <span>Reduce motion</span>1931 <button1932 class="peach-btn peach-btn--subtle peach-btn--sm"1933 onClick={() => void props.onReduceMotionChange(!props.reduceMotion)}1934 >1935 {props.reduceMotion ? "On" : "Off"}1936 </button>1937 </div>1938 </div>1939 </div>1940 );1941 }19421943 function renderShortcuts() {1944 const [capturingAction, setCapturingAction] = createSignal<ShortcutAction | null>(null);1945 const [conflictMsg, setConflictMsg] = createSignal<string | null>(null);19461947 function startCapture(action: ShortcutAction) {1948 setConflictMsg(null);1949 setCapturingAction(action);1950 }1951 function cancelCapture() {1952 setCapturingAction(null);1953 setConflictMsg(null);1954 }1955 function commitBinding(action: ShortcutAction, binding: Keybinding) {1956 const conflict = findConflict(props.shortcuts, binding, action);1957 if (conflict) {1958 const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict);1959 setConflictMsg(`Already bound to "${other?.label ?? conflict}"`);1960 return;1961 }1962 const next = { ...props.shortcuts, [action]: binding };1963 setCapturingAction(null);1964 setConflictMsg(null);1965 void props.onShortcutsChange(next);1966 }1967 function resetAction(action: ShortcutAction) {1968 const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] };1969 void props.onShortcutsChange(next);1970 }1971 function resetAll() {1972 void props.onShortcutsChange({ ...DEFAULT_BINDINGS });1973 }19741975 1976 1977 function handleCaptureKey(e: KeyboardEvent) {1978 e.preventDefault();1979 e.stopPropagation();1980 if (e.key === "Escape") { cancelCapture(); return; }1981 const binding = keybindingFromEvent(e);1982 if (!binding) return; 1983 const action = capturingAction();1984 if (action) commitBinding(action, binding);1985 }19861987 return (1988 <div class="peach-settings-body">1989 <div class="peach-settings-group peach-shortcuts-group">1990 <p class="peach-settings-sub-label">ENTRY VIEW</p>1991 <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry")}>1992 {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1993 </For>1994 </div>19951996 <div class="peach-settings-group peach-shortcuts-group">1997 <p class="peach-settings-sub-label">ANYWHERE IN POPUP</p>1998 <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global")}>1999 {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}2000 </For>2001 </div>20022003 <Show when={conflictMsg()}>2004 <div class="peach-shortcuts-conflict">{conflictMsg()}</div>2005 </Show>20062007 <div class="peach-settings-group" style="display:flex;flex-direction:column;gap:8px">2008 <div class="peach-settings-row peach-settings-row--meta">2009 <span>Inline autofill on page</span>2010 <a2011 class="peach-settings-link"2012 href="chrome://extensions/shortcuts"2013 onClick={(e) => {2014 e.preventDefault();2015 chrome.tabs.create({ url: "chrome://extensions/shortcuts" });2016 }}2017 >2018 ⌘⇧P — customize ↗2019 </a>2020 </div>2021 <button2022 class="peach-btn peach-btn--subtle peach-btn--sm"2023 style="align-self:flex-start"2024 onClick={resetAll}2025 >2026 Reset all to defaults2027 </button>2028 </div>2029 </div>2030 );20312032 function renderRow(2033 desc: typeof SHORTCUT_DESCRIPTORS[number],2034 capturing: () => ShortcutAction | null,2035 onStart: (a: ShortcutAction) => void,2036 onCancel: () => void,2037 onReset: (a: ShortcutAction) => void,2038 onKey: (e: KeyboardEvent) => void,2039 ) {2040 const isActive = () => capturing() === desc.action;2041 const binding = () => props.shortcuts[desc.action];2042 const isCustom = () => {2043 const b = binding();2044 const d = DEFAULT_BINDINGS[desc.action];2045 return b.key !== d.key2046 || Boolean(b.ctrl) !== Boolean(d.ctrl)2047 || Boolean(b.meta) !== Boolean(d.meta)2048 || Boolean(b.shift) !== Boolean(d.shift)2049 || Boolean(b.alt) !== Boolean(d.alt);2050 };2051 return (2052 <div class="peach-shortcut-row">2053 <span class="peach-shortcut-row-label">{desc.label}</span>2054 <button2055 class="peach-shortcut-chip"2056 classList={{ "is-capturing": isActive(), "is-custom": isCustom() }}2057 onClick={() => isActive() ? onCancel() : onStart(desc.action)}2058 onKeyDown={(e) => { if (isActive()) onKey(e); }}2059 ref={(el) => {2060 2061 if (el) {2062 queueMicrotask(() => { if (isActive()) el.focus(); });2063 }2064 }}2065 >2066 {isActive() ? "Press a key…" : formatKeybinding(binding())}2067 </button>2068 <Show when={isCustom() && !isActive()}>2069 <button2070 class="peach-shortcut-reset"2071 title="Reset to default"2072 onClick={() => onReset(desc.action)}2073 >2074 ↺2075 </button>2076 </Show>2077 </div>2078 );2079 }2080 }20812082 const PRO_FEATURES: Array<{2083 feature: ProFeature;2084 name: string;2085 desc: string;2086 }> = [2087 { feature: "sync", name: "Peach Sync", desc: "Encrypted · peer-to-peer" },2088 { feature: "codex", name: "Peach Codex", desc: "Restore from paper" },2089 { feature: "passkeys", name: "Passkeys", desc: "Zero-knowledge WebAuthn" },2090 { feature: "advanced_health", name: "Advanced Vault Health", desc: "Reuse · age · exposure" },2091 ];20922093 const PRO_SECURITY_FEATURES: Array<{2094 feature: ProFeature;2095 name: string;2096 desc: string;2097 }> = [2098 { feature: "canary_entry", name: "Canary entries", desc: "Detects vault compromise" },2099 { feature: "duress_vault", name: "Duress vault", desc: "Decoy under coercion" },2100 ];21012102 const [licenseKey, setLicenseKey] = createSignal<string | null>(null);2103 const [showKey, setShowKey] = createSignal(false);21042105 const [deactivating, setDeactivating] = createSignal(false);21062107 async function handleDeactivateLicense() {2108 setDeactivating(true);2109 await sendMessage({ type: "DEACTIVATE_LICENSE" });2110 setLicenseInfo(null);2111 await props.onLicenseChange?.();2112 setDeactivating(false);2113 }21142115 async function handleTestCanary(entry: {id: string; title: string; canary: Canary}) {2116 setCanaryTestingId(entry.id);2117 setCanaryTestResult(null);2118 const res = await sendMessage({2119 type: "FIRE_CANARY_PING",2120 payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger: "test" },2121 }) as { ok: boolean };2122 setCanaryTestResult({ id: entry.id, ok: res.ok });2123 setCanaryTestingId(null);2124 setTimeout(() => setCanaryTestResult((r) => r?.id === entry.id ? null : r), 3000);2125 }21262127 function renderLicense() {2128 async function onActivate() {2129 await handleActivateLicense();2130 if ((licenseInfo()?.valid ?? false) && licenseInfo()?.plan === "pro") {2131 await props.onLicenseChange?.();2132 }2133 }21342135 return (2136 <div class="peach-settings-body">2137 <Show2138 when={isPro()}2139 fallback={2140 <div class="peach-pro-upgrade">2141 <p class="peach-pro-eyebrow">PEACH PRO · ONE-TIME</p>21422143 <h2 class="peach-pro-headline">2144 Every<em>thing,</em><br />every<em>where.</em>2145 </h2>21462147 <p class="peach-pro-subtitle">2148 One key unlocks every Pro feature — for the lifetime of the app.2149 </p>21502151 <div class="peach-pro-grid">2152 <For each={PRO_FEATURES}>2153 {(item, i) => (2154 <div2155 class="peach-pro-grid-item"2156 classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}2157 >2158 <span class="peach-pro-grid-num">{String(i() + 1).padStart(2, "0")}</span>2159 <span class="peach-pro-grid-name">{item.name}</span>2160 <span class="peach-pro-grid-desc">{item.desc}</span>2161 </div>2162 )}2163 </For>2164 </div>21652166 <p class="peach-pro-section-label">ADVANCED SECURITY</p>2167 <div class="peach-pro-grid">2168 <For each={PRO_SECURITY_FEATURES}>2169 {(item, i) => (2170 <div2171 class="peach-pro-grid-item"2172 classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}2173 >2174 <span class="peach-pro-grid-num">{String(PRO_FEATURES.length + i() + 1).padStart(2, "0")}</span>2175 <span class="peach-pro-grid-name">{item.name}</span>2176 <span class="peach-pro-grid-desc">{item.desc}</span>2177 </div>2178 )}2179 </For>2180 </div>21812182 <div class="peach-pro-upgrade-actions">2183 <p class="peach-pro-pricing">2184 $39 <span class="peach-pro-pricing-sub">once · yours forever</span>2185 </p>2186 <a2187 href="https://buy.polar.sh/polar_cl_IXSFIPojx67rcOTksn8LkH5mnU3osPHtYqBIl1eyuIz"2188 target="_blank"2189 rel="noopener noreferrer"2190 class="peach-pro-upgrade-cta"2191 >2192 Get Peach Pro2193 </a>2194 <div class="peach-pro-key-divider"><span>ALREADY HAVE A KEY</span></div>2195 <div class="peach-pro-key-inline">2196 <input2197 class="peach-pro-key-inline-input"2198 type="text"2199 placeholder="PEACH-XXXX-XXXX-XXXX"2200 value={licenseKeyInput()}2201 onInput={(e) => setLicenseKeyInput(e.currentTarget.value)}2202 onKeyDown={(e) => { if (e.key === "Enter") void onActivate(); }}2203 autocomplete="off"2204 spellcheck={false}2205 />2206 <button2207 class="peach-pro-key-apply"2208 onClick={() => void onActivate()}2209 disabled={activatingLicense() || !licenseKeyInput().trim()}2210 >2211 {activatingLicense() ? "…" : "APPLY"}2212 </button>2213 </div>2214 <Show when={licenseError()}>2215 <p class="peach-pro-key-error">{licenseError()}</p>2216 </Show>2217 </div>2218 </div>2219 }2220 >2221 <div class="peach-pro-active">2222 <div class="peach-pro-active-hero">2223 <button2224 class="peach-pro-active-mark-wrap"2225 type="button"2226 aria-label="Celebrate"2227 onClick={handleProCelebrationClick}2228 >2229 <PeachMark class="peach-pro-active-mark" />2230 <Show when={justActivated()}>2231 <div class="peach-pro-sparks" aria-hidden="true">2232 <span class="peach-pro-spark" style="--spark-x:0px;--spark-y:-54px;--spark-delay:0ms">✦</span>2233 <span class="peach-pro-spark" style="--spark-x:38px;--spark-y:-38px;--spark-delay:50ms">✦</span>2234 <span class="peach-pro-spark" style="--spark-x:54px;--spark-y:0px;--spark-delay:110ms">·</span>2235 <span class="peach-pro-spark" style="--spark-x:38px;--spark-y:38px;--spark-delay:30ms">✦</span>2236 <span class="peach-pro-spark" style="--spark-x:0px;--spark-y:54px;--spark-delay:80ms">·</span>2237 <span class="peach-pro-spark" style="--spark-x:-38px;--spark-y:38px;--spark-delay:130ms">✦</span>2238 <span class="peach-pro-spark" style="--spark-x:-54px;--spark-y:0px;--spark-delay:55ms">·</span>2239 <span class="peach-pro-spark" style="--spark-x:-38px;--spark-y:-38px;--spark-delay:95ms">✦</span>2240 </div>2241 </Show>2242 </button>2243 <h2 class="peach-pro-active-title">Peach Pro</h2>2244 <p class="peach-pro-active-since">{formatActivatedSince(licenseInfo()?.issued)}</p>2245 <Show when={justActivated()}>2246 <p class="peach-pro-thankyou-text" aria-live="polite">thank you ✦</p>2247 </Show>2248 </div>22492250 <Show when={licenseKey()}>2251 <div class="peach-settings-license">2252 <span class="peach-settings-label">LICENSE KEY</span>2253 <div class="peach-settings-license-row">2254 <Show2255 when={showKey()}2256 fallback={2257 <span class="peach-settings-license-key peach-settings-license-key--masked" aria-label="License key hidden">2258 ••••••••••••••••••••••••••••2259 </span>2260 }2261 >2262 <span class="peach-settings-license-key">{licenseKey()!}</span>2263 </Show>2264 <div class="peach-settings-license-actions">2265 <button2266 class="peach-settings-license-copy"2267 title="Copy license key"2268 onClick={() => copyLicenseKey(licenseKey()!)}2269 >2270 {copied() ? "✓" : "copy"}2271 </button>2272 <button2273 class="peach-settings-license-toggle"2274 aria-label={showKey() ? "Hide license key" : "Reveal license key"}2275 title={showKey() ? "Hide" : "Reveal"}2276 onClick={() => setShowKey(v => !v)}2277 >2278 <Show2279 when={showKey()}2280 fallback={2281 <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">2282 <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />2283 <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />2284 </svg>2285 }2286 >2287 <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">2288 <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />2289 <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />2290 <line x1="2" y1="2" x2="12" y2="12" />2291 </svg>2292 </Show>2293 </button>2294 </div>2295 </div>2296 </div>2297 </Show>22982299 <div class="peach-pro-active-features">2300 <For each={[...PRO_FEATURES, ...PRO_SECURITY_FEATURES]}>2301 {(item) => (2302 <div class="peach-pro-active-feature">2303 <span class="peach-pro-active-feature-mark">✦</span>2304 <span class="peach-pro-active-feature-name">{item.name}</span>2305 </div>2306 )}2307 </For>2308 </div>23092310 <button2311 class="peach-pro-deactivate"2312 onClick={() => void handleDeactivateLicense()}2313 disabled={deactivating()}2314 >2315 {deactivating() ? "Deactivating…" : "Deactivate"}2316 </button>2317 </div>2318 </Show>2319 </div>2320 );2321 }23222323 function renderFeedback() {2324 return (2325 <div class="peach-settings-body">2326 <div class="peach-settings-group peach-feedback-group">2327 <Show2328 when={feedbackState() === 'sent'}2329 fallback={2330 <>2331 <p class="peach-feedback-intro">2332 Help us improve Peach. Every report goes directly to our inbox.2333 </p>23342335 <p class="peach-settings-sub-label">TYPE</p>2336 <div class="peach-feedback-chips">2337 <For each={FEEDBACK_CATEGORIES}>2338 {(cat) => {2339 const selected = () => fbCategory() === cat;2340 return (2341 <button2342 type="button"2343 class={`peach-feedback-chip ${selected() ? "is-active" : ""}`}2344 aria-pressed={selected()}2345 onClick={() => setFbCategory(cat)}2346 >2347 {cat}2348 </button>2349 );2350 }}2351 </For>2352 </div>23532354 <p class="peach-settings-sub-label">MESSAGE</p>2355 <textarea2356 class="peach-feedback-textarea"2357 placeholder="Describe the issue or idea..."2358 maxLength={MAX_FEEDBACK_MESSAGE_LENGTH}2359 value={fbMessage()}2360 onInput={(e) => setFbMessage(e.currentTarget.value)}2361 />23622363 <p class="peach-feedback-meta">2364 Version {version} · will be included automatically2365 </p>23662367 <button2368 type="button"2369 class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"2370 disabled={!fbMessage().trim() || feedbackState() === 'sending'}2371 onClick={() => void handleSubmitFeedback()}2372 >2373 <Switch>2374 <Match when={feedbackState() === 'idle'}>Send</Match>2375 <Match when={feedbackState() === 'sending'}>Sending...</Match>2376 <Match when={feedbackState() === 'error'}>Failed - try again</Match>2377 </Switch>2378 </button>23792380 <p class="peach-feedback-anon">2381 Sent anonymously · no account required2382 </p>2383 </>2384 }2385 >2386 <div class="peach-feedback-thanks" role="status" aria-live="polite">2387 <p class="peach-feedback-thanks-title">Thank you</p>2388 <p>2389 Your note is in the queue. Someone will process it soon.2390 </p>2391 </div>2392 </Show>2393 </div>2394 </div>2395 );2396 }23972398 const bodies: StackBodies = {2399 protection: renderSecurity(),2400 vault: renderVault(),2401 sync: renderSync(),2402 appearance: renderAppearance(),2403 shortcuts: renderShortcuts(),2404 pro: renderLicense(),2405 feedback: renderFeedback(),2406 };24072408 return (2409 <Show2410 when={showWhatsNew()}2411 fallback={2412 <Show2413 when={showNewRecoveryWords()}2414 fallback={2415 <div2416 class="peach-overlay-view peach-settings-overlay"2417 classList={{ "ov-enter-right": !props.isExiting, "ov-exit-right": !!props.isExiting }}2418 style="display:flex;flex-direction:column"2419 >2420 <StackDeck2421 active={activeCard()}2422 onActiveChange={setActiveCard}2423 onBack={props.onBack}2424 cards={cards()}2425 bodies={bodies}2426 version={version}2427 isFanning={isFanning()}2428 celebratingCardId={justActivated() ? "pro" : null}2429 isPro={isPro()}2430 />2431 </div>2432 }2433 >2434 <RecoveryWordDisplayView onDone={() => setShowNewRecoveryWords(false)} />2435 </Show>2436 }2437 >2438 <div class="peach-overlay-view peach-settings-overlay">2439 <WhatsNewView mode="history" onContinue={() => setShowWhatsNew(false)} />2440 </div>2441 </Show>2442 );2443}