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 { deviceId: string; deviceName: string }247 const [pendingVouched, setPendingVouched] = createSignal<PendingVouched[]>([]);248 const [vouchedBusy, setVouchedBusy] = createSignal<string | null>(null);249 250 const [pairedDevices, setPairedDevices] = createSignal<PairedDevice[]>([]);251 const [confirmUnpair, setConfirmUnpair] = createSignal<string | null>(null);252 const [renameDeviceId, setRenameDeviceId] = createSignal<string | null>(null);253 const [renameDraft, setRenameDraft] = createSignal("");254 const [syncStatus, setSyncStatus] = createSignal<"idle" | "syncing" | "error">("idle");255 const [syncFlash, setSyncFlash] = createSignal<string | null>(null);256 257 258 const [syncSummary, setSyncSummary] = createSignal<SyncOutcomeSummary>({});259 const syncLight = () =>260 syncStatus() === "syncing"261 ? "busy"262 : syncIndicator(syncSummary(), pairedDevices().length);263 const syncLightLabel = () => syncIndicatorLabel(syncLight(), syncSummary());264 const lastSyncLine = () => {265 const s = syncSummary();266 if (!s.outcome) return "No sync attempted yet";267 return `${syncRelativeTime(s.finishedAt)} · ${describeSyncOutcome(s)}`;268 };269 async function loadSyncState() {270 const r = (await sendMessage({ type: "GET_SYNC_STATE" })) as {271 ok: boolean;272 data?: SyncOutcomeSummary;273 };274 if (r.ok && r.data) {275 setSyncSummary(r.data);276 setSyncStatus(r.data.status ?? "idle");277 }278 }279280 const STALE_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000;281 function syncRelativeTime(when: number | undefined): string {282 if (!when) return "Not synced yet";283 const elapsed = Date.now() - when;284 if (elapsed < 60_000) return "Just now";285 const minutes = Math.floor(elapsed / 60_000);286 if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"} ago`;287 const hours = Math.floor(minutes / 60);288 if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;289 const days = Math.floor(hours / 24);290 return days < 7 ? `${days} day${days === 1 ? "" : "s"} ago` : "Not seen recently";291 }292 function isSyncStale(d: PairedDevice): boolean {293 return d.lastSyncedAt !== undefined && Date.now() - d.lastSyncedAt > STALE_THRESHOLD_MS;294 }295 function deviceTypeLabel(t: PairedDevice["deviceType"]): string {296 switch (t) {297 case "android": return "Android";298 case "ios": return "iPhone";299 case "extension": return "Browser";300 default: return "Device";301 }302 }303304 async function loadPairedDevices() {305 if (!isPro()) return;306 const r = (await sendMessage({ type: "LIST_PAIRED_DEVICES" })) as {307 ok: boolean;308 data?: { devices: PairedDevice[] };309 };310 if (r.ok) setPairedDevices(r.data?.devices ?? []);311 }312 async function unpairDevice(deviceId: string) {313 await sendMessage({ type: "UNPAIR_DEVICE", payload: { deviceId } });314 setConfirmUnpair(null);315 await loadPairedDevices();316 }317 async function renameDevice(deviceId: string) {318 const name = renameDraft().trim();319 if (!name) return;320 const result = (await sendMessage({321 type: "RENAME_PAIRED_DEVICE",322 payload: { deviceId, name },323 })) as { ok: boolean };324 if (!result.ok) return;325 setRenameDeviceId(null);326 await loadPairedDevices();327 }328 async function syncNow() {329 setSyncStatus("syncing");330 setSyncFlash(null);331 const res = (await sendMessage({332 type: "TRIGGER_SYNC",333 payload: { reason: "manual", force: true },334 })) as { ok: boolean; error?: string };335 if (!res.ok) {336 setSyncStatus("error");337 setSyncSummary({ status: "error", outcome: "run_failed", detail: res.error });338 return;339 }340 341 342 343 let s: SyncOutcomeSummary = { status: "syncing" };344 for (let i = 0; i < 40 && s.status === "syncing"; i++) {345 await new Promise((r) => setTimeout(r, 250));346 const state = (await sendMessage({ type: "GET_SYNC_STATE" })) as {347 ok: boolean;348 data?: SyncOutcomeSummary;349 };350 s = state.ok ? state.data ?? { status: "idle" } : { status: "error", outcome: "run_failed" };351 }352 setSyncSummary(s);353 setSyncStatus(s.status ?? "idle");354 355 356 setSyncFlash(describeSyncOutcome(s));357 setTimeout(() => setSyncFlash(null), 4000);358 await loadPairedDevices();359 }360 361 const [rotateConfirmOpen, setRotateConfirmOpen] = createSignal(false);362 const [rotating, setRotating] = createSignal(false);363 const [rotateError, setRotateError] = createSignal<string | null>(null);364 const [showSyncConnection, setShowSyncConnection] = createSignal(false);365 const [showSyncRecovery, setShowSyncRecovery] = createSignal(false);366367 async function loadPendingVouched() {368 const r = (await sendMessage({ type: "GET_PENDING_VOUCHED_DEVICES" })) as {369 ok: boolean;370 data?: PendingVouched[];371 };372 if (r.ok) setPendingVouched(r.data ?? []);373 }374 async function confirmVouched(deviceId: string) {375 setVouchedBusy(deviceId);376 try {377 await sendMessage({ type: "CONFIRM_VOUCHED_DEVICE", payload: { deviceId } });378 await loadPendingVouched();379 } finally {380 setVouchedBusy(null);381 }382 }383 async function rejectVouched(deviceId: string) {384 setVouchedBusy(deviceId);385 try {386 await sendMessage({ type: "REJECT_VOUCHED_DEVICE", payload: { deviceId } });387 await loadPendingVouched();388 } finally {389 setVouchedBusy(null);390 }391 }392 async function doRotateSyncIdentity() {393 setRotateError(null);394 setRotating(true);395 try {396 const r = (await sendMessage({ type: "ROTATE_SYNC_IDENTITY" })) as {397 ok: boolean;398 error?: string;399 };400 if (!r.ok) {401 setRotateError(r.error ?? "Rotation failed.");402 return;403 }404 setRotateConfirmOpen(false);405 406 407 setShowNewRecoveryWords(true);408 } finally {409 setRotating(false);410 }411 }412413 const [dedupCount, setDedupCount] = createSignal<number | null>(null);414 const [dedupRunning, setDedupRunning] = createSignal(false);415 const [dedupDone, setDedupDone] = createSignal<number | null>(null);416417 const [declinedCardCount, setDeclinedCardCount] = createSignal(0);418419 420 421 422 const [historySnapshots, setHistorySnapshots] = createSignal<VaultSnapshot[]>([]);423 const [historyLoading, setHistoryLoading] = createSignal(false);424 const [historyRestoring, setHistoryRestoring] = createSignal<string | null>(null);425 const [historyShowAll, setHistoryShowAll] = createSignal(false);426427 async function loadVaultHistory() {428 setHistoryLoading(true);429 try {430 const res = await sendMessage({ type: "GET_VAULT_HISTORY" });431 const list = (res as { ok: boolean; data?: VaultSnapshot[] }).data ?? [];432 setHistorySnapshots(list);433 } finally {434 setHistoryLoading(false);435 }436 }437438 async function handleRestoreVaultSnapshot(id: string) {439 if (historyRestoring()) return;440 setHistoryRestoring(id);441 const res = await sendMessage({442 type: "RESTORE_VAULT_SNAPSHOT",443 payload: { snapshotId: id },444 });445 setHistoryRestoring(null);446 if ((res as { ok: boolean }).ok) {447 448 449 props.onBack();450 }451 }452453 function formatSnapshotTime(savedAt: number): string {454 const diff = Date.now() - savedAt;455 if (diff < 60_000) return "Just now";456 if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;457 const d = new Date(savedAt);458 const isToday = new Date().toDateString() === d.toDateString();459 const isYest =460 new Date(Date.now() - 86_400_000).toDateString() === d.toDateString();461 const t = d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });462 if (isToday) return `Today ${t}`;463 if (isYest) return `Yesterday ${t}`;464 return `${d.toLocaleDateString([], { month: "short", day: "numeric" })} ${t}`;465 }466467 function reasonLabel(reason: VaultSnapshot["reason"]): string {468 return (469 {470 local_save: "saved",471 sync_received: "synced",472 import: "imported",473 restore: "restored",474 } as const475 )[reason] ?? reason;476 }477478 const [showVaultAdvanced, setShowVaultAdvanced] = createSignal(false);479 const [showWhatsNew, setShowWhatsNew] = createSignal(false);480481 const [showChangePw, setShowChangePw] = createSignal(false);482 const [changePwCurrent, setChangePwCurrent] = createSignal("");483 const [changePwNew, setChangePwNew] = createSignal("");484 const [changePwConfirm, setChangePwConfirm] = createSignal("");485 const [changePwSaving, setChangePwSaving] = createSignal(false);486 const [changePwError, setChangePwError] = createSignal<string | null>(null);487 const [changePwSuccess] = createSignal(false);488 const [showNewRecoveryWords, setShowNewRecoveryWords] = createSignal(false);489490 const [pinEnabled, setPinEnabled] = createSignal(false);491 const [pinFlow, setPinFlow] = createSignal<"setup" | "disable" | null>(null);492 const [pinCurrentPassword, setPinCurrentPassword] = createSignal("");493 const [pinValue, setPinValue] = createSignal("");494 const [pinConfirm, setPinConfirm] = createSignal("");495 const [pinSaving, setPinSaving] = createSignal(false);496 const [pinError, setPinError] = createSignal<string | null>(null);497498 499 const [showViewWords, setShowViewWords] = createSignal(false);500 const [viewWordsPassword, setViewWordsPassword] = createSignal("");501 const [viewWordsError, setViewWordsError] = createSignal<string | null>(null);502 const [viewedWords, setViewedWords] = createSignal<string[] | null>(null);503 const [viewWordsBusy, setViewWordsBusy] = createSignal(false);504505 async function handleViewRecoveryWords() {506 setViewWordsError(null);507 if (!viewWordsPassword()) {508 setViewWordsError("Enter your master password.");509 return;510 }511 setViewWordsBusy(true);512 try {513 const r = (await sendMessage({514 type: "GET_RECOVERY_WORDS",515 payload: { password: viewWordsPassword() },516 })) as { ok: boolean; data?: string[]; error?: string };517 if (r.ok) {518 setViewedWords(r.data ?? []);519 setViewWordsPassword("");520 return;521 }522 switch (r.error) {523 case "wrong_password":524 setViewWordsError("Incorrect password");525 break;526 case "password_required":527 setViewWordsError("Enter your master password.");528 break;529 case "recovery_not_setup":530 setViewWordsError("Recovery words aren't set up for this vault yet.");531 break;532 default:533 setViewWordsError("Couldn't retrieve recovery words.");534 }535 } finally {536 setViewWordsBusy(false);537 }538 }539 function closeViewWords() {540 setShowViewWords(false);541 setViewWordsPassword("");542 setViewWordsError(null);543 setViewedWords(null);544 }545546 const [duressEnabled, setDuressEnabled] = createSignal(false);547 const [showDuressSetup, setShowDuressSetup] = createSignal(false);548 const [duressPassword, setDuressPassword] = createSignal("");549 const [duressConfirm, setDuressConfirm] = createSignal("");550 const [duressMainPassword, setDuressMainPassword] = createSignal("");551 const [duressSaving, setDuressSaving] = createSignal(false);552 const [duressError, setDuressError] = createSignal<string | null>(null);553 const [duressSuccess, setDuressSuccess] = createSignal<string | null>(null);554555 const [canaryEntries, setCanaryEntries] = createSignal<Array<{id: string; title: string; canary: Canary}>>([]);556557 const [canaryTestingId, setCanaryTestingId] = createSignal<string | null>(null);558 const [canaryTestResult, setCanaryTestResult] = createSignal<{id: string; ok: boolean} | null>(null);559560 const [fbCategory, setFbCategory] = createSignal("Bug report");561 const [fbMessage, setFbMessage] = createSignal("");562 type FeedbackState = 'idle' | 'sending' | 'sent' | 'error';563 const [feedbackState, setFeedbackState] = createSignal<FeedbackState>('idle');564565 createEffect(() => {566 if (props.upgradeHighlight) {567 setActiveCard("pro");568 }569 });570571 onMount(async () => {572 const [licenseRes, passkeyRes, duressRes, settingsRes, keyRes] = await Promise.all([573 sendMessage({ type: "GET_LICENSE" }),574 sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }),575 sendMessage({ type: "IS_DURESS_ENABLED" }),576 sendMessage({ type: "GET_SETTINGS" }),577 sendMessage({ type: "GET_LICENSE_KEY" }),578 ]);579580 581 582 void loadVaultHistory();583584 585 586 void loadPendingVouched();587 void loadPairedDevices();588 void loadSyncState();589 const onBroadcast = (msg: unknown) => {590 const m = msg as { type?: string; payload?: unknown } | null;591 if (m?.type === "VOUCHED_DEVICE_PENDING") {592 void loadPendingVouched();593 }594 if (m?.type === "SYNC_STATE_CHANGED") {595 const payload = (m.payload ?? {}) as SyncOutcomeSummary;596 setSyncSummary(payload);597 setSyncStatus(payload.status ?? "idle");598 }599 };600 chrome.runtime?.onMessage?.addListener(onBroadcast);601 onCleanup(() => {602 try { chrome.runtime?.onMessage?.removeListener(onBroadcast); } catch { }603 });604605 if ((licenseRes as { ok: boolean }).ok) {606 setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null);607 }608609 if ((passkeyRes as { ok: boolean }).ok) {610 setPasskeyState(611 (passkeyRes as { ok: boolean; data?: PasskeyProviderState }).data ?? null612 );613 }614615 if ((duressRes as { ok: boolean }).ok) {616 setDuressEnabled((duressRes as { ok: boolean; data?: boolean }).data ?? false);617 }618619 if ((settingsRes as { ok: boolean }).ok) {620 const sData = (settingsRes as { ok: boolean; data?: { declinedCardFingerprints?: string[]; pinEnabled?: boolean } }).data;621 setDeclinedCardCount(sData?.declinedCardFingerprints?.length ?? 0);622 setPinEnabled(sData?.pinEnabled === true);623 }624625 if ((keyRes as { ok: boolean }).ok) {626 setLicenseKey((keyRes as { ok: boolean; data?: string }).data ?? null);627 }628629 const entriesRes = await sendMessage({ type: "GET_ENTRIES" });630 if ((entriesRes as { ok: boolean }).ok) {631 const entries = (entriesRes as { ok: boolean; data?: Array<{id: string; title: string; canary?: Canary}> }).data ?? [];632 setCanaryEntries(633 entries634 .filter((e) => e.canary?.webhookUrl)635 .map((e) => ({ id: e.id, title: e.title, canary: e.canary! }))636 );637 }638639 });640641 function resetPinFlow() {642 setPinFlow(null);643 setPinCurrentPassword("");644 setPinValue("");645 setPinConfirm("");646 setPinError(null);647 }648649 async function handleSetupPin() {650 setPinError(null);651 if (!pinCurrentPassword()) { setPinError("enter your current password"); return; }652 if (!/^\d{4,12}$/.test(pinValue())) { setPinError("PIN must be 4–12 digits"); return; }653 if (pinValue() !== pinConfirm()) { setPinError("PINs do not match"); return; }654 setPinSaving(true);655 const res = await sendMessage({656 type: "SETUP_PIN",657 payload: { currentPassword: pinCurrentPassword(), pin: pinValue() },658 }) as { ok: boolean; error?: string };659 if (res.ok) {660 setPinEnabled(true);661 resetPinFlow();662 } else {663 setPinError(res.error === "incorrect_current_password" ? "incorrect current password" : "couldn't set up PIN");664 }665 setPinSaving(false);666 }667668 async function handleDisablePin() {669 setPinError(null);670 if (!pinCurrentPassword()) { setPinError("enter your current password"); return; }671 setPinSaving(true);672 const res = await sendMessage({673 type: "DISABLE_PIN",674 payload: { currentPassword: pinCurrentPassword() },675 }) as { ok: boolean; error?: string };676 if (res.ok) {677 setPinEnabled(false);678 resetPinFlow();679 } else {680 setPinError(res.error === "incorrect_current_password" ? "incorrect current password" : "couldn't remove PIN");681 }682 setPinSaving(false);683 }684685 async function handleChangePassword() {686 setChangePwError(null);687 if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; }688 if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; }689 if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; }690 if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; }691 setChangePwSaving(true);692 const res = await sendMessage({693 type: "CHANGE_PASSWORD",694 payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() },695 }) as { ok: boolean; error?: string };696 if (res.ok) {697 setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");698 setShowChangePw(false);699 700 701 702 await sendMessage({ type: "SET_RECOVERY_CONFIRM_PENDING" });703 setShowNewRecoveryWords(true);704 } else {705 setChangePwError(res.error ?? "failed to change password");706 }707 setChangePwSaving(false);708 }709710 const [copied, setCopied] = createSignal(false);711712 async function copyLicenseKey(key: string) {713 await navigator.clipboard.writeText(key);714 setCopied(true);715 setTimeout(() => setCopied(false), 2000);716 }717718 async function handleActivateLicense() {719 const key = licenseKeyInput().trim();720 if (!key) return;721 setActivatingLicense(true);722 setLicenseError(null);723 setLicenseSuccess(null);724 const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } });725 setActivatingLicense(false);726 if ((res as { ok: boolean }).ok) {727 const data = (res as { ok: boolean; data?: LicenseInfo }).data;728 if (data?.valid) {729 setLicenseInfo(data);730 setLicenseKey(key);731 setLicenseSuccess("License activated!");732 setLicenseKeyInput("");733 } else {734 setLicenseError("Invalid license key");735 }736 } else {737 setLicenseError("Activation failed");738 }739 }740741 async function handleTogglePasskeys() {742 const current = passkeyState();743 if (!current || !current.supported) return;744 setPasskeySaving(true);745 const res = await sendMessage({746 type: "SET_PASSKEY_PROVIDER_ENABLED",747 payload: { enabled: !current.enabled },748 });749 setPasskeySaving(false);750 if ((res as { ok: boolean }).ok) {751 setPasskeyState(752 (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null753 );754 }755 }756757 async function handleCheckAllBreaches() {758 setCheckingBreaches(true);759 setBreachResult(null);760 const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });761 setCheckingBreaches(false);762 if ((res as { ok: boolean }).ok) {763 const data = (res as { ok: boolean; data?: Record<string, number> }).data ?? {};764 const breached = Object.values(data).filter((count) => count > 0).length;765 setBreachResult(766 breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean"767 );768 props.onBreachCheckDone();769 }770 }771772773 async function handleSetupDuress() {774 const mainPw = duressMainPassword().trim();775 const duressPw = duressPassword().trim();776 const confirmPw = duressConfirm().trim();777778 if (!mainPw) { setDuressError("enter your main password to confirm"); return; }779 if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; }780 if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; }781 if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; }782783 setDuressSaving(true);784 setDuressError(null);785786 const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };787 if (!verifyRes.ok || !verifyRes.data) {788 setDuressError("incorrect main password");789 setDuressSaving(false);790 return;791 }792793 const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string };794 setDuressSaving(false);795 if (res.ok) {796 setDuressEnabled(true);797 setShowDuressSetup(false);798 setDuressPassword("");799 setDuressConfirm("");800 setDuressMainPassword("");801 setDuressSuccess("Duress password set. If entered, a decoy vault will be shown.");802 setTimeout(() => setDuressSuccess(null), 5000);803 } else {804 setDuressError(805 res.error === "session_expired"806 ? "Vault session expired — lock and unlock Peach, then try again"807 : res.error ?? "failed to set duress password"808 );809 }810 }811812 async function handleClearDeclinedCards() {813 await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } });814 setDeclinedCardCount(0);815 }816817 async function handleCountDuplicates() {818 setDedupRunning(true);819 setDedupDone(null);820 const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number };821 setDedupRunning(false);822 if (res.ok) setDedupCount(res.data ?? 0);823 }824825 async function handleDeduplicate() {826 setDedupRunning(true);827 const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number };828 setDedupRunning(false);829 if (res.ok) {830 setDedupDone(res.data ?? 0);831 setDedupCount(0);832 }833 }834835 async function handleDisableDuress() {836 const mainPw = duressMainPassword().trim();837 if (!mainPw) { setDuressError("enter your main password to confirm"); return; }838839 setDuressSaving(true);840 setDuressError(null);841842 const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };843 if (!verifyRes.ok || !verifyRes.data) {844 setDuressError("incorrect main password");845 setDuressSaving(false);846 return;847 }848849 const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string };850 setDuressSaving(false);851 if (res.ok) {852 setDuressEnabled(false);853 setShowDuressSetup(false);854 setDuressMainPassword("");855 setDuressSuccess("Duress password disabled.");856 setTimeout(() => setDuressSuccess(null), 4000);857 } else {858 setDuressError(res.error ?? "failed to disable duress password");859 }860 }861862 async function handleSubmitFeedback() {863 const message = sanitizeFeedbackMessage(fbMessage());864 if (!message) return;865866 setFeedbackState('sending');867868 try {869 const category = FEEDBACK_CATEGORIES.includes(fbCategory())870 ? fbCategory()871 : FEEDBACK_CATEGORIES[0];872 const res = await fetch('https://api.web3forms.com/submit', {873 method: 'POST',874 headers: { 'Content-Type': 'application/json' },875 body: JSON.stringify({876 access_key: WEB3FORMS_KEY,877 subject: `Peach Feedback - ${category}`,878 message,879 from_name: 'Peach User',880 881 botcheck: '',882 }),883 });884885 if (!res.ok) throw new Error(`HTTP ${res.status}`);886 const data = await res.json() as { success: boolean; message?: string };887 if (!data.success) throw new Error(data.message ?? 'unknown error');888889 setFeedbackState('sent');890 setFbMessage('');891 setFbCategory(FEEDBACK_CATEGORIES[0]);892 } catch (err) {893 console.error('[feedback]', err);894 setFeedbackState('error');895 setTimeout(() => setFeedbackState('idle'), 4000);896 }897 }898899 function renderSecurity() {900 return (901 <div class="peach-settings-body">902 <div class="peach-settings-group">903 <p class="peach-settings-sub-label">LOCKING</p>904 <div class="peach-settings-row">905 <span>Lock on close</span>906 <button907 class={`peach-toggle ${props.lockOnClose ? "is-on" : ""}`}908 onClick={() => void props.onLockOnCloseChange(!props.lockOnClose)}909 aria-label="Toggle lock on close"910 >911 <span class="peach-toggle-knob"></span>912 </button>913 </div>914 <div class="peach-settings-row">915 <span>Auto-lock after</span>916 <select917 class="peach-settings-select"918 value={String(props.autoLockMinutes)}919 onChange={(e) => void props.onAutoLockMinutesChange(Number(e.currentTarget.value))}920 >921 <option value="1">1 minute</option>922 <option value="5">5 minutes</option>923 <option value="15">15 minutes</option>924 <option value="30">30 minutes</option>925 <option value="0">Never</option>926 </select>927 </div>928929 <p class="peach-settings-sub-label">QUICK UNLOCK</p>930 <div class="peach-settings-row peach-settings-row--meta">931 <span>PIN unlock</span>932 <button933 class="peach-btn peach-btn--subtle peach-btn--sm"934 onClick={() => { setPinFlow(pinEnabled() ? "disable" : "setup"); setPinError(null); }}935 >936 {pinEnabled() ? "remove" : "set up"}937 </button>938 </div>939 <Show when={pinFlow() === "setup"}>940 <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">941 <input942 class="peach-settings-input"943 type="password"944 placeholder="current password"945 value={pinCurrentPassword()}946 onInput={(e) => setPinCurrentPassword((e.target as HTMLInputElement).value)}947 autocomplete="current-password"948 />949 <input950 class="peach-settings-input"951 type="password"952 inputmode="numeric"953 maxlength="12"954 placeholder="new 4–12 digit PIN"955 value={pinValue()}956 onInput={(e) => setPinValue((e.target as HTMLInputElement).value.replace(/\D/g, "").slice(0, 12))}957 autocomplete="off"958 />959 <input960 class="peach-settings-input"961 type="password"962 inputmode="numeric"963 maxlength="12"964 placeholder="confirm PIN"965 value={pinConfirm()}966 onInput={(e) => setPinConfirm((e.target as HTMLInputElement).value.replace(/\D/g, "").slice(0, 12))}967 autocomplete="off"968 onKeyDown={(e) => { if (e.key === "Enter") void handleSetupPin(); }}969 />970 <Show when={pinError()}>971 <p class="peach-hint peach-hint-error" style="margin:0">{pinError()}</p>972 </Show>973 <button974 class="peach-btn peach-btn--accent peach-btn--sm"975 style="align-self:flex-start"976 onClick={() => void handleSetupPin()}977 disabled={pinSaving() || !pinCurrentPassword() || !/^\d{4,12}$/.test(pinValue()) || !/^\d{4,12}$/.test(pinConfirm())}978 >979 {pinSaving() ? "saving…" : "save PIN"}980 </button>981 </div>982 </Show>983 <Show when={pinFlow() === "disable"}>984 <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">985 <input986 class="peach-settings-input"987 type="password"988 placeholder="current password"989 value={pinCurrentPassword()}990 onInput={(e) => setPinCurrentPassword((e.target as HTMLInputElement).value)}991 autocomplete="current-password"992 onKeyDown={(e) => { if (e.key === "Enter") void handleDisablePin(); }}993 />994 <Show when={pinError()}>995 <p class="peach-hint peach-hint-error" style="margin:0">{pinError()}</p>996 </Show>997 <button998 class="peach-btn peach-btn--subtle peach-btn--sm"999 style="align-self:flex-start"1000 onClick={() => void handleDisablePin()}1001 disabled={pinSaving() || !pinCurrentPassword()}1002 >1003 {pinSaving() ? "removing…" : "remove PIN"}1004 </button>1005 </div>1006 </Show>10071008 <p class="peach-settings-sub-label">BREACH PROTECTION</p>1009 <div class="peach-settings-row">1010 <span>Breach monitoring</span>1011 <button1012 class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}1013 onClick={() => void props.onBreachMonitoringChange(!props.breachMonitoring)}1014 aria-label="Toggle breach monitoring"1015 >1016 <span class="peach-toggle-knob"></span>1017 </button>1018 </div>1019 <div class="peach-settings-row peach-settings-row--meta">1020 <span>Check all passwords</span>1021 <button1022 class="peach-btn peach-btn--subtle peach-btn--sm"1023 onClick={() => void handleCheckAllBreaches()}1024 disabled={checkingBreaches()}1025 >1026 {checkingBreaches() ? "checking..." : "run"}1027 </button>1028 </div>1029 <Show when={breachResult()}>1030 <div class="peach-settings-row peach-settings-row--meta">1031 <span>Last check</span>1032 <span class="peach-settings-value">{breachResult()}</span>1033 </div>1034 </Show>10351036 <p class="peach-settings-sub-label">MASTER PASSWORD</p>1037 <div class="peach-settings-row peach-settings-row--meta">1038 <span>Master password</span>1039 <button1040 class="peach-btn peach-btn--subtle peach-btn--sm"1041 onClick={() => { setShowChangePw((v) => !v); setChangePwError(null); }}1042 >1043 {showChangePw() ? "cancel" : "change"}1044 </button>1045 </div>1046 <Show when={showChangePw()}>1047 <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1048 <input1049 class="peach-settings-input"1050 type="password"1051 placeholder="current password"1052 value={changePwCurrent()}1053 onInput={(e) => setChangePwCurrent((e.target as HTMLInputElement).value)}1054 autocomplete="current-password"1055 />1056 <input1057 class="peach-settings-input"1058 type="password"1059 placeholder="new password (min 12 chars)"1060 value={changePwNew()}1061 onInput={(e) => setChangePwNew((e.target as HTMLInputElement).value)}1062 autocomplete="new-password"1063 />1064 <input1065 class="peach-settings-input"1066 type="password"1067 placeholder="confirm new password"1068 value={changePwConfirm()}1069 onInput={(e) => setChangePwConfirm((e.target as HTMLInputElement).value)}1070 autocomplete="new-password"1071 onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }}1072 />1073 <Show when={changePwError()}>1074 <p class="peach-hint peach-hint-error" style="margin:0">{changePwError()}</p>1075 </Show>1076 <Show when={changePwSuccess()}>1077 <p class="peach-hint" style="margin:0;color:var(--peach-accent)">password changed ✓</p>1078 </Show>1079 <button1080 class="peach-btn peach-btn--accent peach-btn--sm"1081 style="align-self:flex-start"1082 onClick={() => void handleChangePassword()}1083 disabled={changePwSaving() || !changePwCurrent() || !changePwNew() || !changePwConfirm()}1084 >1085 {changePwSaving() ? "saving…" : "save new password"}1086 </button>1087 </div>1088 </Show>10891090 {}1091 <div class="peach-settings-row peach-settings-row--meta">1092 <span>Recovery words</span>1093 <button1094 class="peach-btn peach-btn--subtle peach-btn--sm"1095 onClick={() => {1096 if (showViewWords()) { closeViewWords(); } else { setShowViewWords(true); }1097 }}1098 >1099 {showViewWords() ? "cancel" : "view"}1100 </button>1101 </div>1102 <Show when={showViewWords()}>1103 <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1104 <Show1105 when={viewedWords()}1106 fallback={1107 <>1108 <p class="peach-hint" style="margin:0;color:var(--peach-t2)">1109 Enter your master password to reveal your recovery words.1110 </p>1111 <input1112 class="peach-settings-input"1113 type="password"1114 placeholder="master password"1115 value={viewWordsPassword()}1116 onInput={(e) => setViewWordsPassword((e.target as HTMLInputElement).value)}1117 autocomplete="current-password"1118 onKeyDown={(e) => { if (e.key === "Enter") void handleViewRecoveryWords(); }}1119 />1120 <Show when={viewWordsError()}>1121 <p class="peach-hint peach-hint-error" style="margin:0">{viewWordsError()}</p>1122 </Show>1123 <button1124 class="peach-btn peach-btn--accent peach-btn--sm"1125 style="align-self:flex-start"1126 onClick={() => void handleViewRecoveryWords()}1127 disabled={viewWordsBusy() || !viewWordsPassword()}1128 >1129 {viewWordsBusy() ? "checking…" : "reveal recovery words"}1130 </button>1131 </>1132 }1133 >1134 <div class="peach-recovery-words-grid">1135 <For each={viewedWords()!}>1136 {(word, i) => (1137 <span class="peach-recovery-word"><b>{i() + 1}</b> {word}</span>1138 )}1139 </For>1140 </div>1141 <button1142 class="peach-btn peach-btn--subtle peach-btn--sm"1143 style="align-self:flex-start"1144 onClick={closeViewWords}1145 >done</button>1146 </Show>1147 </div>1148 </Show>11491150 <p class="peach-settings-sub-label">1151 DURESS PASSWORD1152 <Show when={!isPro()}>1153 <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>1154 </Show>1155 </p>1156 <div class="peach-settings-row peach-settings-row--top">1157 <div class="peach-settings-row-main">1158 <span>1159 Duress password1160 <Show when={!isPro()}>1161 <span class="peach-pro-badge">Pro</span>1162 </Show>1163 </span>1164 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">1165 If entered under coercion, shows a decoy empty vault instead of your real one.1166 </span>1167 </div>1168 <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>1169 {duressEnabled() ? "on" : "off"}1170 </div>1171 </div>11721173 <Show when={duressEnabled() && !showDuressSetup()}>1174 <div class="peach-settings-row peach-settings-row--meta">1175 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1176 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.1177 </span>1178 </div>1179 </Show>11801181 <Show when={duressSuccess()}>1182 <div class="peach-settings-row peach-settings-row--meta">1183 <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>1184 </div>1185 </Show>11861187 <Show when={!showDuressSetup()}>1188 <div class="peach-settings-row peach-settings-row--meta">1189 <button1190 class="peach-btn peach-btn--subtle peach-btn--sm"1191 onClick={() => {1192 1193 1194 1195 1196 1197 if (!isPro() && !duressEnabled()) {1198 props.onProGate?.("duress_vault");1199 setActiveCard("pro");1200 return;1201 }1202 setShowDuressSetup(true);1203 setDuressError(null);1204 setDuressSuccess(null);1205 setDuressPassword("");1206 setDuressConfirm("");1207 setDuressMainPassword("");1208 }}1209 >1210 {duressEnabled() ? "Change / disable" : "Set up"}1211 </button>1212 </div>1213 </Show>12141215 <Show when={showDuressSetup()}>1216 <div class="peach-duress-form">1217 <label class="peach-label">main password (to confirm)</label>1218 <input1219 class="peach-settings-input"1220 type="password"1221 placeholder="your current master password"1222 autocomplete="current-password"1223 value={duressMainPassword()}1224 onInput={(e) => setDuressMainPassword(e.currentTarget.value)}1225 />1226 <Show when={!duressEnabled()}>1227 <label class="peach-label">new duress password</label>1228 <input1229 class="peach-settings-input"1230 type="password"1231 placeholder="duress password (min 8 chars)"1232 autocomplete="new-password"1233 value={duressPassword()}1234 onInput={(e) => setDuressPassword(e.currentTarget.value)}1235 />1236 <label class="peach-label">confirm duress password</label>1237 <input1238 class="peach-settings-input"1239 type="password"1240 placeholder="confirm duress password"1241 autocomplete="new-password"1242 value={duressConfirm()}1243 onInput={(e) => setDuressConfirm(e.currentTarget.value)}1244 />1245 </Show>12461247 <Show when={duressError()}>1248 <p class="peach-hint peach-hint-error">{duressError()}</p>1249 </Show>12501251 <div class="peach-duress-actions">1252 <button1253 class="peach-btn peach-btn--subtle peach-btn--sm"1254 onClick={() => { setShowDuressSetup(false); setDuressError(null); }}1255 >1256 cancel1257 </button>1258 <Show when={duressEnabled()}>1259 <button1260 class="peach-btn peach-btn--ghost peach-btn--sm"1261 style="color:var(--peach-breach)"1262 onClick={() => void handleDisableDuress()}1263 disabled={duressSaving()}1264 >1265 {duressSaving() ? "disabling..." : "disable duress"}1266 </button>1267 </Show>1268 <Show when={!duressEnabled()}>1269 <button1270 class="peach-btn peach-btn--accent peach-btn--sm"1271 onClick={() => void handleSetupDuress()}1272 disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}1273 >1274 {duressSaving() ? "saving..." : "save"}1275 </button>1276 </Show>1277 </div>1278 </div>1279 </Show>12801281 <p class="peach-settings-sub-label">1282 CANARY ENTRIES1283 <Show when={!isPro()}>1284 <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>1285 </Show>1286 </p>1287 <div class="peach-settings-row peach-settings-row--top">1288 <div class="peach-settings-row-main">1289 <span>1290 Canary entries1291 <Show when={!isPro()}>1292 <span class="peach-pro-badge">Pro</span>1293 </Show>1294 </span>1295 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1296 Silently pings a webhook when an entry is accessed. Configure canary on any entry in your vault.1297 </span>1298 </div>1299 <span class="peach-settings-value" style="flex-shrink:0">1300 {canaryEntries().length > 0 ? `${canaryEntries().length} active` : "none"}1301 </span>1302 </div>1303 <Show when={canaryEntries().length > 0}>1304 <For each={canaryEntries()}>1305 {(entry) => (1306 <div class="peach-settings-row peach-settings-row--meta">1307 <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0">{entry.title}</span>1308 <button1309 class="peach-btn peach-btn--subtle peach-btn--sm"1310 onClick={() => void handleTestCanary(entry)}1311 disabled={canaryTestingId() === entry.id}1312 style="flex-shrink:0"1313 >1314 {canaryTestingId() === entry.id1315 ? "sending…"1316 : canaryTestResult()?.id === entry.id1317 ? (canaryTestResult()!.ok ? "sent ✓" : "failed ✗")1318 : "test"}1319 </button>1320 </div>1321 )}1322 </For>1323 </Show>13241325 <p class="peach-settings-sub-label">PASSKEYS</p>1326 <div class="peach-settings-row peach-settings-row--top">1327 <div class="peach-settings-row-main">1328 <span>Intercept passkey requests</span>1329 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">1330 Peach can handle passkey sign-in and creation on supported Chromium browsers.1331 </span>1332 </div>1333 <button1334 class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}1335 onClick={() => void handleTogglePasskeys()}1336 aria-label="Toggle passkey interception"1337 disabled={!passkeyState()?.supported || passkeySaving()}1338 >1339 <span class="peach-toggle-knob"></span>1340 </button>1341 </div>1342 <div class="peach-settings-row peach-settings-row--meta">1343 <span>Status</span>1344 <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>1345 </div>1346 <div class="peach-settings-row peach-settings-row--meta">1347 <span>Browser</span>1348 <span class="peach-settings-value">1349 {passkeyState()?.browserLabel ?? "Unknown"}1350 {passkeyState()?.supported ? " ✓" : " (interception unavailable)"}1351 </span>1352 </div>1353 <div class="peach-info-notice" style="margin-top:8px">1354 Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.1355 </div>1356 <Show when={passkeyState()?.conflict}>1357 <div class="peach-settings-row peach-settings-row--meta">1358 <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>1359 </div>1360 </Show>1361 </div>1362 </div>1363 );1364 }13651366 function renderVault() {1367 return (1368 <div class="peach-settings-body">1369 <div class="peach-settings-group">1370 <p class="peach-settings-sub-label">ENTRIES</p>1371 <div class="peach-settings-row peach-settings-row--meta">1372 <span>Entries</span>1373 <span class="peach-settings-value">{props.entriesCount}</span>1374 </div>1375 <div class="peach-settings-row peach-settings-row--meta">1376 <span>Duplicates</span>1377 <Show when={dedupDone() !== null} fallback={1378 <button1379 class="peach-btn peach-btn--subtle peach-btn--sm"1380 onClick={() => void handleCountDuplicates()}1381 disabled={dedupRunning()}1382 >1383 {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}1384 </button>1385 }>1386 <span class="peach-settings-value" style="color:var(--peach-sync-ok)">1387 {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}1388 </span>1389 </Show>1390 </div>1391 <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>1392 <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">1393 <div class="peach-restore-warning">1394 {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.1395 </div>1396 <div style="display:flex;gap:6px">1397 <button1398 class="peach-btn peach-btn--subtle peach-btn--sm"1399 onClick={() => setDedupCount(null)}1400 >1401 cancel1402 </button>1403 <button1404 class="peach-btn peach-btn--accent peach-btn--sm"1405 onClick={() => void handleDeduplicate()}1406 disabled={dedupRunning()}1407 >1408 {dedupRunning() ? "removing..." : "remove duplicates"}1409 </button>1410 </div>1411 </div>1412 </Show>14131414 <p class="peach-settings-sub-label">MAINTENANCE</p>1415 <div class="peach-settings-row peach-settings-row--meta">1416 <span>Trash auto-delete</span>1417 <select1418 class="peach-settings-select"1419 value={String(props.trashRetentionDays)}1420 onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}1421 >1422 <option value="7">7 days</option>1423 <option value="30">30 days</option>1424 <option value="90">90 days</option>1425 <option value="0">Never</option>1426 </select>1427 </div>1428 <div class="peach-settings-row peach-settings-row--meta">1429 <span>Advanced Vault Health</span>1430 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>1431 open1432 </button>1433 </div>1434 <Show when={declinedCardCount() > 0}>1435 <div class="peach-settings-row peach-settings-row--meta">1436 <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>1437 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>1438 Clear1439 </button>1440 </div>1441 </Show>14421443 <p class="peach-settings-sub-label">DATA</p>1444 <div class="peach-settings-row peach-settings-row--meta">1445 <span>Import passwords</span>1446 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>1447 open1448 </button>1449 </div>1450 <div class="peach-settings-row peach-settings-row--meta">1451 <span>Export backup</span>1452 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onExport}>1453 open1454 </button>1455 </div>1456 <div class="peach-settings-row peach-settings-row--meta">1457 <span>1458 Peach Codex1459 <Show when={!isPro()}>1460 <span class="peach-pro-badge">Pro</span>1461 </Show>1462 </span>1463 <button1464 class="peach-btn peach-btn--subtle peach-btn--sm"1465 onClick={() => props.onOpenCodexGenerate?.()}1466 >1467 open1468 </button>1469 </div>1470 {1471}1472 <div class="peach-settings-row peach-settings-row--meta">1473 <span>Restore from Codex</span>1474 <button1475 class="peach-btn peach-btn--subtle peach-btn--sm"1476 onClick={() => props.onOpenCodexRestore?.()}1477 >1478 open1479 </button>1480 </div>14811482 <Show when={hasReleaseNotes()}>1483 <p class="peach-settings-sub-label">WHAT'S NEW</p>1484 <div class="peach-settings-row peach-settings-row--meta">1485 <span>v{PEACH_VERSION}</span>1486 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => setShowWhatsNew(true)}>1487 view1488 </button>1489 </div>1490 </Show>14911492 <span class="peach-settings-sub-label">VAULT HISTORY</span>1493 <Show when={historyLoading()}>1494 <p class="peach-settings-hint">Loading…</p>1495 </Show>1496 <Show when={!historyLoading() && historySnapshots().length === 0}>1497 <p class="peach-settings-hint">1498 No snapshots yet. Peach saves automatically before every change —1499 saves, syncs, and imports.1500 </p>1501 </Show>1502 <Show when={!historyLoading() && historySnapshots().length > 0}>1503 <p class="peach-settings-hint">1504 Restoring locks your vault. Unlock with the password that1505 encrypted that snapshot to confirm.1506 </p>1507 <div class="peach-history-list">1508 <For each={historySnapshots().slice(0, historyShowAll() ? undefined : 3)}>1509 {(snap) => (1510 <div class="peach-history-row">1511 <div class="peach-history-meta">1512 <span class="peach-history-time">1513 {formatSnapshotTime(snap.savedAt)}1514 </span>1515 <span class="peach-history-sub">1516 {[1517 snap.entryCount !== null1518 ? `up to ${snap.entryCount} ${snap.entryCount === 1 ? "entry" : "entries"}`1519 : null,1520 reasonLabel(snap.reason),1521 ]1522 .filter(Boolean)1523 .join(" · ")}1524 </span>1525 </div>1526 <button1527 class={`peach-btn peach-btn--subtle peach-btn--sm peach-history-btn${1528 historyRestoring() === snap.id ? " is-busy" : ""1529 }`}1530 onClick={() => void handleRestoreVaultSnapshot(snap.id)}1531 disabled={historyRestoring() !== null}1532 >1533 <Show when={historyRestoring() === snap.id} fallback="Restore">1534 …1535 </Show>1536 </button>1537 </div>1538 )}1539 </For>1540 </div>1541 <Show when={historySnapshots().length > 3}>1542 <button1543 class="peach-settings-advanced-toggle"1544 onClick={() => setHistoryShowAll((v) => !v)}1545 aria-expanded={historyShowAll()}1546 >1547 <span class="peach-settings-hint" style="margin:0">1548 {historyShowAll() ? "show less" : `${historySnapshots().length - 3} older`}1549 </span>1550 <span class="peach-settings-advanced-chevron">1551 {historyShowAll() ? "▲" : "▼"}1552 </span>1553 </button>1554 </Show>1555 <p class="peach-history-footer">1556 {historySnapshots().length} of 10 snapshots1557 </p>1558 </Show>15591560 <button1561 class="peach-settings-advanced-toggle"1562 onClick={() => setShowVaultAdvanced((v) => !v)}1563 aria-expanded={showVaultAdvanced()}1564 >1565 <span class="peach-settings-sub-label" style="margin:0">ADVANCED</span>1566 <span class="peach-settings-advanced-chevron">1567 {showVaultAdvanced() ? "▲" : "▼"}1568 </span>1569 </button>15701571 <Show when={showVaultAdvanced()}>1572 <div class="peach-danger-card">1573 <div class="peach-danger-card-head">1574 <span class="peach-danger-card-glyph" aria-hidden="true">⟳</span>1575 <h3 class="peach-danger-card-title">Replace vault from backup</h3>1576 </div>1577 <p class="peach-danger-card-copy">1578 Wipes the current vault and restores from a backup file.1579 Recoverable only if your backup is good.1580 </p>1581 <button1582 class="peach-danger-card-button"1583 onClick={() => props.onOpenFileImport?.()}1584 >1585 Choose backup file1586 </button>1587 </div>15881589 <Show when={props.onOpenPanic}>1590 <div class="peach-danger-card peach-danger-card--hazard">1591 <div class="peach-danger-card-head">1592 <span class="peach-danger-card-glyph" aria-hidden="true">⚠</span>1593 <h3 class="peach-danger-card-title">Destroy vault</h3>1594 </div>1595 <p class="peach-danger-card-copy">1596 Permanently wipes every entry, setting, and license on this1597 device. There is no recovery.1598 </p>1599 <button1600 class="peach-danger-card-button"1601 onClick={() => props.onOpenPanic?.()}1602 >1603 Begin destruction1604 </button>1605 </div>1606 </Show>1607 </Show>1608 </div>1609 </div>1610 );1611 }16121613 function renderSync() {1614 return (1615 <div class="peach-settings-body">1616 <div class="peach-settings-group">1617 <p class="peach-settings-sub-label" style="margin-top:0">SYNC STATUS</p>1618 <div1619 class="peach-settings-row peach-settings-row--meta"1620 style="align-items:center;gap:8px"1621 role="status"1622 aria-label={syncLightLabel()}1623 title={syncLightLabel()}1624 >1625 <span class={`stk-pip stk-pip--sync is-${syncLight()}`} aria-hidden="true" />1626 <span class="peach-settings-value" style="color:var(--peach-t2);line-height:1.5;flex:1;min-width:0;overflow-wrap:anywhere">1627 {syncFlash() ?? lastSyncLine()}1628 </span>1629 </div>1630 <Show when={pairedDevices().length > 0}>1631 <div class="peach-settings-row peach-settings-row--meta">1632 <span class="peach-settings-value" style="color:var(--peach-t2)">1633 {(syncSummary().entriesSent ?? 0) + (syncSummary().entriesReceived ?? 0) > 01634 ? `${syncSummary().entriesSent ?? 0} sent · ${syncSummary().entriesReceived ?? 0} received`1635 : ""}1636 </span>1637 <button1638 class="peach-btn peach-btn--subtle peach-btn--sm"1639 aria-label="Sync this vault now"1640 disabled={syncStatus() === "syncing"}1641 onClick={() => void syncNow()}1642 >{syncStatus() === "syncing" ? "syncing…" : "sync now"}</button>1643 </div>1644 </Show>16451646 <p class="peach-settings-sub-label">ADD A DEVICE</p>1647 <button1648 class="peach-sync-pair-option"1649 onClick={() => props.onOpenSyncPairing?.("code")}1650 >1651 <span class="peach-sync-pair-option-icon" aria-hidden="true">1652 <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">1653 <rect x="6" y="2.5" width="8" height="15" rx="1.6" />1654 <path d="M9 15 L11 15" />1655 </svg>1656 </span>1657 <span class="peach-sync-pair-option-main">1658 <span class="peach-sync-pair-option-title">Show a code</span>1659 <span class="peach-sync-pair-option-sub">Scan it with Peach on your phone, then confirm one 8-digit code</span>1660 </span>1661 <span class="peach-sync-pair-option-chevron" aria-hidden="true">›</span>1662 </button>1663 <Show when={!isPro()}>1664 <p class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2);margin:8px 2px 0">1665 At least one device needs Peach Pro — pair with a Pro device and it carries over to here.1666 </p>1667 </Show>16681669 <Show when={pairedDevices().length > 0}>1670 <p class="peach-settings-sub-label">PAIRED DEVICES</p>1671 <For each={pairedDevices()}>1672 {(d) => (1673 <div class="peach-settings-row peach-settings-row--top">1674 <div class="peach-settings-row-main">1675 <Show1676 when={renameDeviceId() === d.deviceId}1677 fallback={<span>{d.localAlias ?? d.deviceName ?? "Unnamed device"}</span>}1678 >1679 <input1680 class="peach-settings-input"1681 aria-label={`Rename ${d.localAlias ?? d.deviceName ?? "device"}`}1682 maxlength={64}1683 value={renameDraft()}1684 onInput={(event) => setRenameDraft(event.currentTarget.value)}1685 onKeyDown={(event) => {1686 if (event.key === "Enter") void renameDevice(d.deviceId);1687 if (event.key === "Escape") setRenameDeviceId(null);1688 }}1689 />1690 </Show>1691 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1692 {deviceTypeLabel(d.deviceType)} · {syncRelativeTime(d.lastSyncedAt)}{isSyncStale(d) ? " · not seen recently" : ""}1693 </span>1694 </div>1695 <Show1696 when={confirmUnpair() === d.deviceId}1697 fallback={1698 <div style="display:flex;gap:4px;flex-shrink:0">1699 <Show1700 when={renameDeviceId() === d.deviceId}1701 fallback={1702 <button1703 class="peach-btn peach-btn--subtle peach-btn--sm"1704 onClick={() => {1705 setRenameDraft(d.localAlias ?? d.deviceName ?? "");1706 setRenameDeviceId(d.deviceId);1707 }}1708 >rename</button>1709 }1710 >1711 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => setRenameDeviceId(null)}>cancel</button>1712 <button class="peach-btn peach-btn--subtle peach-btn--sm" disabled={!renameDraft().trim()} onClick={() => void renameDevice(d.deviceId)}>save</button>1713 </Show>1714 <button1715 class="peach-btn peach-btn--subtle peach-btn--sm"1716 onClick={() => setConfirmUnpair(d.deviceId)}1717 >remove</button>1718 </div>1719 }1720 >1721 {17221723172417251726}1727 <div class="peach-unpair-confirm">1728 <p class="peach-unpair-confirm-note">1729 Stops this device receiving future changes. It doesn't1730 lock it out of what it already has — if it's lost or1731 stolen, also change your master password and generate1732 new recovery words.1733 </p>1734 <div style="display:flex;gap:6px;justify-content:flex-end">1735 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => setConfirmUnpair(null)}>cancel</button>1736 <button1737 class="peach-btn peach-btn--sm"1738 style="background:var(--peach-breach);border-color:var(--peach-breach)"1739 onClick={() => void unpairDevice(d.deviceId)}1740 >remove</button>1741 </div>1742 </div>1743 </Show>1744 </div>1745 )}1746 </For>1747 </Show>17481749 <Show when={pendingVouched().length > 0}>1750 <p class="peach-settings-sub-label">1751 PENDING DEVICES1752 <span class="peach-pro-badge" style="background:#C8A840;color:#1a1a1a">{pendingVouched().length}</span>1753 </p>1754 <For each={pendingVouched()}>1755 {(dev) => (1756 <div class="peach-settings-row peach-settings-row--top">1757 <div class="peach-settings-row-main">1758 <span>{dev.deviceName}</span>1759 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1760 Was added by another of your devices. Allow it to sync with this vault?1761 </span>1762 </div>1763 <div style="display:flex;gap:6px;flex-shrink:0">1764 <button1765 class="peach-btn peach-btn--sm"1766 disabled={vouchedBusy() === dev.deviceId}1767 onClick={() => void confirmVouched(dev.deviceId)}1768 >Allow</button>1769 <button1770 class="peach-btn peach-btn--subtle peach-btn--sm"1771 disabled={vouchedBusy() === dev.deviceId}1772 onClick={() => void rejectVouched(dev.deviceId)}1773 >Reject</button>1774 </div>1775 </div>1776 )}1777 </For>1778 </Show>17791780 <button1781 class="peach-settings-advanced-toggle peach-sync-disclosure-toggle"1782 onClick={() => setShowSyncConnection((value) => !value)}1783 aria-expanded={showSyncConnection()}1784 aria-controls="peach-sync-connection"1785 >1786 <span>1787 <span class="peach-settings-sub-label" style="margin:0">CONNECTION</span>1788 <span class="peach-sync-disclosure-summary">Relay and privacy choices</span>1789 </span>1790 <span class="peach-settings-advanced-chevron">1791 {showSyncConnection() ? "▲" : "▼"}1792 </span>1793 </button>1794 <Show when={showSyncConnection()}>1795 <div id="peach-sync-connection" class="peach-sync-disclosure-content">1796 <SyncModeSelector />1797 </div>1798 </Show>17991800 <button1801 class="peach-settings-advanced-toggle peach-sync-disclosure-toggle"1802 onClick={() => setShowSyncRecovery((value) => !value)}1803 aria-expanded={showSyncRecovery()}1804 aria-controls="peach-sync-recovery"1805 >1806 <span>1807 <span class="peach-settings-sub-label" style="margin:0">RECOVERY & SECURITY</span>1808 <span class="peach-sync-disclosure-summary">For a lost or stolen device</span>1809 </span>1810 <span class="peach-settings-advanced-chevron">1811 {showSyncRecovery() ? "▲" : "▼"}1812 </span>1813 </button>1814 <Show when={showSyncRecovery()}>1815 <div id="peach-sync-recovery" class="peach-sync-disclosure-content">1816 <Show1817 when={rotateConfirmOpen()}1818 fallback={1819 <div class="peach-settings-row peach-settings-row--top">1820 <div class="peach-settings-row-main">1821 <span>Rotate sync identity</span>1822 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1823 Disconnects every device and replaces the keys used for sync.1824 </span>1825 </div>1826 <button1827 class="peach-btn peach-btn--subtle peach-btn--sm"1828 style="color:var(--peach-breach);border-color:color-mix(in srgb,var(--peach-breach) 40%,transparent)"1829 onClick={() => { setRotateError(null); setRotateConfirmOpen(true); }}1830 >rotate</button>1831 </div>1832 }1833 >1834 <div class="peach-info-notice" style="margin-top:8px;border-color:color-mix(in srgb,var(--peach-breach) 40%,transparent)">1835 This disconnects every paired device and changes your 12 recovery words. You'll be shown the new words next.1836 <Show when={rotateError()}>1837 <div style="color:var(--peach-breach);margin-top:6px">{rotateError()}</div>1838 </Show>1839 <div style="display:flex;gap:6px;margin-top:10px">1840 <button1841 class="peach-btn peach-btn--subtle peach-btn--sm"1842 disabled={rotating()}1843 onClick={() => setRotateConfirmOpen(false)}1844 >Cancel</button>1845 <button1846 class="peach-btn peach-btn--sm"1847 style="background:var(--peach-breach);border-color:var(--peach-breach)"1848 disabled={rotating()}1849 onClick={() => void doRotateSyncIdentity()}1850 >{rotating() ? "Rotating…" : "Rotate sync identity"}</button>1851 </div>1852 </div>1853 </Show>1854 </div>1855 </Show>1856 </div>1857 </div>1858 );1859 }18601861 function renderAppearance() {1862 const sizeOptions: Array<{ id: "medium" | "large"; label: string }> = [1863 { id: "medium", label: "Default" },1864 { id: "large", label: "Large" },1865 ];1866 return (1867 <div class="peach-settings-body">1868 <div class="peach-settings-group">1869 <p class="peach-settings-sub-label">THEME</p>1870 <div class="peach-settings-row peach-settings-row--meta">1871 <span>Theme</span>1872 <button1873 class="peach-btn peach-btn--subtle peach-btn--sm"1874 onClick={() =>1875 props.onThemeChange(props.theme === "dark" ? "light" : "dark")1876 }1877 >1878 {props.theme === "dark" ? "Switch to light" : "Switch to dark"}1879 </button>1880 </div>18811882 <p class="peach-settings-sub-label">ACCENT</p>1883 <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">1884 <span>Accent</span>1885 <div class="peach-accent-picker">1886 <For each={ACCENT_COLORS}>1887 {(color) => (1888 <button1889 class="peach-accent-swatch peach-swatch"1890 classList={{ "is-active": props.accentColor === color.name }}1891 style={{ background: color.hex }}1892 onClick={() => void props.onAccentChange(color.name)}1893 aria-label={color.name}1894 title={color.name}1895 />1896 )}1897 </For>1898 </div>1899 </div>19001901 <p class="peach-settings-sub-label">TEXT SIZE</p>1902 <div class="peach-settings-row peach-settings-row--meta">1903 <span>Size</span>1904 <div class="peach-pill-group">1905 <For each={sizeOptions}>1906 {(opt) => (1907 <button1908 class="peach-btn peach-btn--ghost peach-btn--sm"1909 classList={{ "is-active": props.textSize === opt.id }}1910 onClick={() => void props.onTextSizeChange(opt.id)}1911 >1912 {opt.label}1913 </button>1914 )}1915 </For>1916 </div>1917 </div>19181919 <p class="peach-settings-sub-label">MOTION</p>1920 <div class="peach-settings-row peach-settings-row--meta">1921 <span>Reduce motion</span>1922 <button1923 class="peach-btn peach-btn--subtle peach-btn--sm"1924 onClick={() => void props.onReduceMotionChange(!props.reduceMotion)}1925 >1926 {props.reduceMotion ? "On" : "Off"}1927 </button>1928 </div>1929 </div>1930 </div>1931 );1932 }19331934 function renderShortcuts() {1935 const [capturingAction, setCapturingAction] = createSignal<ShortcutAction | null>(null);1936 const [conflictMsg, setConflictMsg] = createSignal<string | null>(null);19371938 function startCapture(action: ShortcutAction) {1939 setConflictMsg(null);1940 setCapturingAction(action);1941 }1942 function cancelCapture() {1943 setCapturingAction(null);1944 setConflictMsg(null);1945 }1946 function commitBinding(action: ShortcutAction, binding: Keybinding) {1947 const conflict = findConflict(props.shortcuts, binding, action);1948 if (conflict) {1949 const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict);1950 setConflictMsg(`Already bound to "${other?.label ?? conflict}"`);1951 return;1952 }1953 const next = { ...props.shortcuts, [action]: binding };1954 setCapturingAction(null);1955 setConflictMsg(null);1956 void props.onShortcutsChange(next);1957 }1958 function resetAction(action: ShortcutAction) {1959 const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] };1960 void props.onShortcutsChange(next);1961 }1962 function resetAll() {1963 void props.onShortcutsChange({ ...DEFAULT_BINDINGS });1964 }19651966 1967 1968 function handleCaptureKey(e: KeyboardEvent) {1969 e.preventDefault();1970 e.stopPropagation();1971 if (e.key === "Escape") { cancelCapture(); return; }1972 const binding = keybindingFromEvent(e);1973 if (!binding) return; 1974 const action = capturingAction();1975 if (action) commitBinding(action, binding);1976 }19771978 return (1979 <div class="peach-settings-body">1980 <div class="peach-settings-group peach-shortcuts-group">1981 <p class="peach-settings-sub-label">ENTRY VIEW</p>1982 <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry")}>1983 {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1984 </For>1985 </div>19861987 <div class="peach-settings-group peach-shortcuts-group">1988 <p class="peach-settings-sub-label">ANYWHERE IN POPUP</p>1989 <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global")}>1990 {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1991 </For>1992 </div>19931994 <Show when={conflictMsg()}>1995 <div class="peach-shortcuts-conflict">{conflictMsg()}</div>1996 </Show>19971998 <div class="peach-settings-group" style="display:flex;flex-direction:column;gap:8px">1999 <div class="peach-settings-row peach-settings-row--meta">2000 <span>Inline autofill on page</span>2001 <a2002 class="peach-settings-link"2003 href="chrome://extensions/shortcuts"2004 onClick={(e) => {2005 e.preventDefault();2006 chrome.tabs.create({ url: "chrome://extensions/shortcuts" });2007 }}2008 >2009 ⌘⇧P — customize ↗2010 </a>2011 </div>2012 <button2013 class="peach-btn peach-btn--subtle peach-btn--sm"2014 style="align-self:flex-start"2015 onClick={resetAll}2016 >2017 Reset all to defaults2018 </button>2019 </div>2020 </div>2021 );20222023 function renderRow(2024 desc: typeof SHORTCUT_DESCRIPTORS[number],2025 capturing: () => ShortcutAction | null,2026 onStart: (a: ShortcutAction) => void,2027 onCancel: () => void,2028 onReset: (a: ShortcutAction) => void,2029 onKey: (e: KeyboardEvent) => void,2030 ) {2031 const isActive = () => capturing() === desc.action;2032 const binding = () => props.shortcuts[desc.action];2033 const isCustom = () => {2034 const b = binding();2035 const d = DEFAULT_BINDINGS[desc.action];2036 return b.key !== d.key2037 || Boolean(b.ctrl) !== Boolean(d.ctrl)2038 || Boolean(b.meta) !== Boolean(d.meta)2039 || Boolean(b.shift) !== Boolean(d.shift)2040 || Boolean(b.alt) !== Boolean(d.alt);2041 };2042 return (2043 <div class="peach-shortcut-row">2044 <span class="peach-shortcut-row-label">{desc.label}</span>2045 <button2046 class="peach-shortcut-chip"2047 classList={{ "is-capturing": isActive(), "is-custom": isCustom() }}2048 onClick={() => isActive() ? onCancel() : onStart(desc.action)}2049 onKeyDown={(e) => { if (isActive()) onKey(e); }}2050 ref={(el) => {2051 2052 if (el) {2053 queueMicrotask(() => { if (isActive()) el.focus(); });2054 }2055 }}2056 >2057 {isActive() ? "Press a key…" : formatKeybinding(binding())}2058 </button>2059 <Show when={isCustom() && !isActive()}>2060 <button2061 class="peach-shortcut-reset"2062 title="Reset to default"2063 onClick={() => onReset(desc.action)}2064 >2065 ↺2066 </button>2067 </Show>2068 </div>2069 );2070 }2071 }20722073 const PRO_FEATURES: Array<{2074 feature: ProFeature;2075 name: string;2076 desc: string;2077 }> = [2078 { feature: "sync", name: "Peach Sync", desc: "Encrypted · peer-to-peer" },2079 { feature: "codex", name: "Peach Codex", desc: "Restore from paper" },2080 { feature: "passkeys", name: "Passkeys", desc: "Zero-knowledge WebAuthn" },2081 { feature: "advanced_health", name: "Advanced Vault Health", desc: "Reuse · age · exposure" },2082 ];20832084 const PRO_SECURITY_FEATURES: Array<{2085 feature: ProFeature;2086 name: string;2087 desc: string;2088 }> = [2089 { feature: "canary_entry", name: "Canary entries", desc: "Detects vault compromise" },2090 { feature: "duress_vault", name: "Duress vault", desc: "Decoy under coercion" },2091 ];20922093 const [licenseKey, setLicenseKey] = createSignal<string | null>(null);2094 const [showKey, setShowKey] = createSignal(false);20952096 const [deactivating, setDeactivating] = createSignal(false);20972098 async function handleDeactivateLicense() {2099 setDeactivating(true);2100 await sendMessage({ type: "DEACTIVATE_LICENSE" });2101 setLicenseInfo(null);2102 await props.onLicenseChange?.();2103 setDeactivating(false);2104 }21052106 async function handleTestCanary(entry: {id: string; title: string; canary: Canary}) {2107 setCanaryTestingId(entry.id);2108 setCanaryTestResult(null);2109 const res = await sendMessage({2110 type: "FIRE_CANARY_PING",2111 payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger: "test" },2112 }) as { ok: boolean };2113 setCanaryTestResult({ id: entry.id, ok: res.ok });2114 setCanaryTestingId(null);2115 setTimeout(() => setCanaryTestResult((r) => r?.id === entry.id ? null : r), 3000);2116 }21172118 function renderLicense() {2119 async function onActivate() {2120 await handleActivateLicense();2121 if ((licenseInfo()?.valid ?? false) && licenseInfo()?.plan === "pro") {2122 await props.onLicenseChange?.();2123 }2124 }21252126 return (2127 <div class="peach-settings-body">2128 <Show2129 when={isPro()}2130 fallback={2131 <div class="peach-pro-upgrade">2132 <p class="peach-pro-eyebrow">PEACH PRO · ONE-TIME</p>21332134 <h2 class="peach-pro-headline">2135 Every<em>thing,</em><br />every<em>where.</em>2136 </h2>21372138 <p class="peach-pro-subtitle">2139 One key unlocks every Pro feature — for the lifetime of the app.2140 </p>21412142 <div class="peach-pro-grid">2143 <For each={PRO_FEATURES}>2144 {(item, i) => (2145 <div2146 class="peach-pro-grid-item"2147 classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}2148 >2149 <span class="peach-pro-grid-num">{String(i() + 1).padStart(2, "0")}</span>2150 <span class="peach-pro-grid-name">{item.name}</span>2151 <span class="peach-pro-grid-desc">{item.desc}</span>2152 </div>2153 )}2154 </For>2155 </div>21562157 <p class="peach-pro-section-label">ADVANCED SECURITY</p>2158 <div class="peach-pro-grid">2159 <For each={PRO_SECURITY_FEATURES}>2160 {(item, i) => (2161 <div2162 class="peach-pro-grid-item"2163 classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}2164 >2165 <span class="peach-pro-grid-num">{String(PRO_FEATURES.length + i() + 1).padStart(2, "0")}</span>2166 <span class="peach-pro-grid-name">{item.name}</span>2167 <span class="peach-pro-grid-desc">{item.desc}</span>2168 </div>2169 )}2170 </For>2171 </div>21722173 <div class="peach-pro-upgrade-actions">2174 <p class="peach-pro-pricing">2175 $39 <span class="peach-pro-pricing-sub">once · yours forever</span>2176 </p>2177 <a2178 href="https://buy.polar.sh/polar_cl_IXSFIPojx67rcOTksn8LkH5mnU3osPHtYqBIl1eyuIz"2179 target="_blank"2180 rel="noopener noreferrer"2181 class="peach-pro-upgrade-cta"2182 >2183 Get Peach Pro2184 </a>2185 <div class="peach-pro-key-divider"><span>ALREADY HAVE A KEY</span></div>2186 <div class="peach-pro-key-inline">2187 <input2188 class="peach-pro-key-inline-input"2189 type="text"2190 placeholder="PEACH-XXXX-XXXX-XXXX"2191 value={licenseKeyInput()}2192 onInput={(e) => setLicenseKeyInput(e.currentTarget.value)}2193 onKeyDown={(e) => { if (e.key === "Enter") void onActivate(); }}2194 autocomplete="off"2195 spellcheck={false}2196 />2197 <button2198 class="peach-pro-key-apply"2199 onClick={() => void onActivate()}2200 disabled={activatingLicense() || !licenseKeyInput().trim()}2201 >2202 {activatingLicense() ? "…" : "APPLY"}2203 </button>2204 </div>2205 <Show when={licenseError()}>2206 <p class="peach-pro-key-error">{licenseError()}</p>2207 </Show>2208 </div>2209 </div>2210 }2211 >2212 <div class="peach-pro-active">2213 <div class="peach-pro-active-hero">2214 <button2215 class="peach-pro-active-mark-wrap"2216 type="button"2217 aria-label="Celebrate"2218 onClick={handleProCelebrationClick}2219 >2220 <PeachMark class="peach-pro-active-mark" />2221 <Show when={justActivated()}>2222 <div class="peach-pro-sparks" aria-hidden="true">2223 <span class="peach-pro-spark" style="--spark-x:0px;--spark-y:-54px;--spark-delay:0ms">✦</span>2224 <span class="peach-pro-spark" style="--spark-x:38px;--spark-y:-38px;--spark-delay:50ms">✦</span>2225 <span class="peach-pro-spark" style="--spark-x:54px;--spark-y:0px;--spark-delay:110ms">·</span>2226 <span class="peach-pro-spark" style="--spark-x:38px;--spark-y:38px;--spark-delay:30ms">✦</span>2227 <span class="peach-pro-spark" style="--spark-x:0px;--spark-y:54px;--spark-delay:80ms">·</span>2228 <span class="peach-pro-spark" style="--spark-x:-38px;--spark-y:38px;--spark-delay:130ms">✦</span>2229 <span class="peach-pro-spark" style="--spark-x:-54px;--spark-y:0px;--spark-delay:55ms">·</span>2230 <span class="peach-pro-spark" style="--spark-x:-38px;--spark-y:-38px;--spark-delay:95ms">✦</span>2231 </div>2232 </Show>2233 </button>2234 <h2 class="peach-pro-active-title">Peach Pro</h2>2235 <p class="peach-pro-active-since">{formatActivatedSince(licenseInfo()?.issued)}</p>2236 <Show when={justActivated()}>2237 <p class="peach-pro-thankyou-text" aria-live="polite">thank you ✦</p>2238 </Show>2239 </div>22402241 <Show when={licenseKey()}>2242 <div class="peach-settings-license">2243 <span class="peach-settings-label">LICENSE KEY</span>2244 <div class="peach-settings-license-row">2245 <Show2246 when={showKey()}2247 fallback={2248 <span class="peach-settings-license-key peach-settings-license-key--masked" aria-label="License key hidden">2249 ••••••••••••••••••••••••••••2250 </span>2251 }2252 >2253 <span class="peach-settings-license-key">{licenseKey()!}</span>2254 </Show>2255 <div class="peach-settings-license-actions">2256 <button2257 class="peach-settings-license-copy"2258 title="Copy license key"2259 onClick={() => copyLicenseKey(licenseKey()!)}2260 >2261 {copied() ? "✓" : "copy"}2262 </button>2263 <button2264 class="peach-settings-license-toggle"2265 aria-label={showKey() ? "Hide license key" : "Reveal license key"}2266 title={showKey() ? "Hide" : "Reveal"}2267 onClick={() => setShowKey(v => !v)}2268 >2269 <Show2270 when={showKey()}2271 fallback={2272 <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">2273 <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />2274 <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />2275 </svg>2276 }2277 >2278 <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">2279 <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />2280 <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />2281 <line x1="2" y1="2" x2="12" y2="12" />2282 </svg>2283 </Show>2284 </button>2285 </div>2286 </div>2287 </div>2288 </Show>22892290 <div class="peach-pro-active-features">2291 <For each={[...PRO_FEATURES, ...PRO_SECURITY_FEATURES]}>2292 {(item) => (2293 <div class="peach-pro-active-feature">2294 <span class="peach-pro-active-feature-mark">✦</span>2295 <span class="peach-pro-active-feature-name">{item.name}</span>2296 </div>2297 )}2298 </For>2299 </div>23002301 <button2302 class="peach-pro-deactivate"2303 onClick={() => void handleDeactivateLicense()}2304 disabled={deactivating()}2305 >2306 {deactivating() ? "Deactivating…" : "Deactivate"}2307 </button>2308 </div>2309 </Show>2310 </div>2311 );2312 }23132314 function renderFeedback() {2315 return (2316 <div class="peach-settings-body">2317 <div class="peach-settings-group peach-feedback-group">2318 <Show2319 when={feedbackState() === 'sent'}2320 fallback={2321 <>2322 <p class="peach-feedback-intro">2323 Help us improve Peach. Every report goes directly to our inbox.2324 </p>23252326 <p class="peach-settings-sub-label">TYPE</p>2327 <div class="peach-feedback-chips">2328 <For each={FEEDBACK_CATEGORIES}>2329 {(cat) => {2330 const selected = () => fbCategory() === cat;2331 return (2332 <button2333 type="button"2334 class={`peach-feedback-chip ${selected() ? "is-active" : ""}`}2335 aria-pressed={selected()}2336 onClick={() => setFbCategory(cat)}2337 >2338 {cat}2339 </button>2340 );2341 }}2342 </For>2343 </div>23442345 <p class="peach-settings-sub-label">MESSAGE</p>2346 <textarea2347 class="peach-feedback-textarea"2348 placeholder="Describe the issue or idea..."2349 maxLength={MAX_FEEDBACK_MESSAGE_LENGTH}2350 value={fbMessage()}2351 onInput={(e) => setFbMessage(e.currentTarget.value)}2352 />23532354 <p class="peach-feedback-meta">2355 Version {version} · will be included automatically2356 </p>23572358 <button2359 type="button"2360 class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"2361 disabled={!fbMessage().trim() || feedbackState() === 'sending'}2362 onClick={() => void handleSubmitFeedback()}2363 >2364 <Switch>2365 <Match when={feedbackState() === 'idle'}>Send</Match>2366 <Match when={feedbackState() === 'sending'}>Sending...</Match>2367 <Match when={feedbackState() === 'error'}>Failed - try again</Match>2368 </Switch>2369 </button>23702371 <p class="peach-feedback-anon">2372 Sent anonymously · no account required2373 </p>2374 </>2375 }2376 >2377 <div class="peach-feedback-thanks" role="status" aria-live="polite">2378 <p class="peach-feedback-thanks-title">Thank you</p>2379 <p>2380 Your note is in the queue. Someone will process it soon.2381 </p>2382 </div>2383 </Show>2384 </div>2385 </div>2386 );2387 }23882389 const bodies: StackBodies = {2390 protection: renderSecurity(),2391 vault: renderVault(),2392 sync: renderSync(),2393 appearance: renderAppearance(),2394 shortcuts: renderShortcuts(),2395 pro: renderLicense(),2396 feedback: renderFeedback(),2397 };23982399 return (2400 <Show2401 when={showWhatsNew()}2402 fallback={2403 <Show2404 when={showNewRecoveryWords()}2405 fallback={2406 <div2407 class="peach-overlay-view peach-settings-overlay"2408 classList={{ "ov-enter-right": !props.isExiting, "ov-exit-right": !!props.isExiting }}2409 style="display:flex;flex-direction:column"2410 >2411 <StackDeck2412 active={activeCard()}2413 onActiveChange={setActiveCard}2414 onBack={props.onBack}2415 cards={cards()}2416 bodies={bodies}2417 version={version}2418 isFanning={isFanning()}2419 celebratingCardId={justActivated() ? "pro" : null}2420 isPro={isPro()}2421 />2422 </div>2423 }2424 >2425 <RecoveryWordDisplayView onDone={() => setShowNewRecoveryWords(false)} />2426 </Show>2427 }2428 >2429 <div class="peach-overlay-view peach-settings-overlay">2430 <WhatsNewView onContinue={() => setShowWhatsNew(false)} />2431 </div>2432 </Show>2433 );2434}