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 type { Canary } from "../../shared/types";9import { ACCENT_COLORS } from "../../shared/types";10import type {11 AccentColorName,12 PasskeyProviderState,13 ProFeature,14 VaultSnapshot,15} from "../../shared/types";16import type { PairedDevice } from "../../shared/sync-types";17import {18 describeSyncOutcome,19 syncIndicator,20 syncIndicatorLabel,21 type SyncOutcomeSummary,22} from "../../shared/sync-outcome";23import {24 DEFAULT_BINDINGS,25 findConflict,26 formatKeybinding,27 keybindingFromEvent,28 SHORTCUT_DESCRIPTORS,29 type Keybinding,30 type ShortcutAction,31} from "../../shared/shortcuts";32import { isNativePlatform, nativePlugin } from "../../shared/native";33import { WEB3FORMS_KEY } from "../../shared/config";3435interface LicenseInfo {36 valid: boolean;37 plan: "free" | "pro";38 issued?: string;39}4041interface SettingsProps {42 entriesCount: number;43 breachMonitoring: boolean;44 lockOnClose: boolean;45 autoLockMinutes: number;46 trashRetentionDays: number;47 theme: "dark" | "light";48 accentColor: AccentColorName;49 textSize: "small" | "medium" | "large";50 reduceMotion: boolean;51 isPro?: boolean;52 initialSection?: string | null;53 upgradeHighlight?: ProFeature | null;54 onBreachMonitoringChange: (enabled: boolean) => void | Promise<void>;55 onLockOnCloseChange: (enabled: boolean) => void | Promise<void>;56 onAutoLockMinutesChange: (minutes: number) => void | Promise<void>;57 onTrashRetentionChange: (days: number) => void | Promise<void>;58 onThemeChange: (theme: "dark" | "light") => void;59 onAccentChange: (name: AccentColorName) => void | Promise<void>;60 onTextSizeChange: (size: "small" | "medium" | "large") => void | Promise<void>;61 onReduceMotionChange: (enabled: boolean) => void | Promise<void>;62 shortcuts: Record<ShortcutAction, Keybinding>;63 onShortcutsChange: (active: Record<ShortcutAction, Keybinding>) => void | Promise<void>;64 onLicenseChange?: () => void | Promise<void>;65 isExiting?: boolean;66 onBack: () => void;67 onImport: () => void;68 onExport: () => void;69 onBreachCheckDone: () => void;70 onOpenHealth?: () => void;71 onOpenSyncPairing?: (method?: "code") => void;72 onOpenFileImport?: () => void;73 onOpenPanic?: () => void;74 onOpenCodexGenerate?: () => void;75 onOpenCodexRestore?: () => void;76 777879 onProGate?: (feature: ProFeature) => void;80 onCardChange?: (id: CardId) => void;81}8283const FEEDBACK_CATEGORIES = ["Bug report", "Feature request", "Question", "Other"];84const MAX_FEEDBACK_MESSAGE_LENGTH = 4000;85const PRO_CELEBRATION_CARD_STEP_MS = 150;86const PRO_CELEBRATION_THANK_YOU_MS = 3200;8788function sanitizeFeedbackMessage(value: string): string {89 return value90 .trim()91 .slice(0, MAX_FEEDBACK_MESSAGE_LENGTH)92 93 .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "")94 .replace(/&/g, "&")95 .replace(/</g, "<")96 .replace(/>/g, ">")97 .replace(/"/g, """)98 .replace(/'/g, "'");99}100101function formatActivatedSince(issued: string | undefined): string {102 if (!issued) return "Active";103 try {104 const d = new Date(issued);105 if (Number.isNaN(d.getTime())) return "Active";106 return `Active since ${d.toLocaleDateString(undefined, { month: "long", year: "numeric" })}`;107 } catch {108 return "Active";109 }110}111112function initialSectionToCard(value: string | null | undefined): CardId | null {113 if (!value) return null;114 if (value === "security") return "protection";115 if (value === "license") return "pro";116 if (117 value === "protection" ||118 value === "vault" ||119 value === "sync" ||120 value === "appearance" ||121 value === "shortcuts" ||122 value === "pro" ||123 value === "feedback"124 ) {125 return value;126 }127 return null;128}129130export function SettingsView(props: SettingsProps) {131 const manifest = chrome.runtime.getManifest();132 const version = manifest.version;133 const initial = props.initialSection;134 const [activeCard, setActiveCard] = createSignal<CardId>(135 props.upgradeHighlight ? "pro" : initialSectionToCard(initial) ?? "pro"136 );137138 createEffect(() => { props.onCardChange?.(activeCard()); });139140 const [justActivated, setJustActivated] = createSignal(false);141 const [isFanning, setIsFanning] = createSignal(false);142143 function triggerCelebration() {144 if (isFanning()) return;145 setJustActivated(false);146 setIsFanning(true);147 setActiveCard("pro");148 const fanOrder: CardId[] = ["protection", "vault", "sync", "appearance", "shortcuts", "feedback"];149 fanOrder.forEach((id, i) => {150 window.setTimeout(() => setActiveCard(id), (i + 1) * PRO_CELEBRATION_CARD_STEP_MS);151 });152 window.setTimeout(() => {153 setActiveCard("pro");154 setIsFanning(false);155 setJustActivated(true);156 window.setTimeout(() => setJustActivated(false), PRO_CELEBRATION_THANK_YOU_MS);157 }, (fanOrder.length + 1) * PRO_CELEBRATION_CARD_STEP_MS);158 }159160 function handleProCelebrationClick(e: MouseEvent) {161 e.stopPropagation();162 triggerCelebration();163 }164165 const [checkingBreaches, setCheckingBreaches] = createSignal(false);166 const [breachResult, setBreachResult] = createSignal<string | null>(null);167 const [licenseInfo, setLicenseInfo] = createSignal<LicenseInfo | null>(null);168 const [licenseKeyInput, setLicenseKeyInput] = createSignal("");169 const [licenseError, setLicenseError] = createSignal<string | null>(null);170 const [, setLicenseSuccess] = createSignal<string | null>(null);171 const [activatingLicense, setActivatingLicense] = createSignal(false);172173 const isPro = () => props.isPro ?? false;174 const cards = createMemo<StackCardSpec[]>(() => [175 {176 id: "pro",177 num: "I · FOREVER",178 title: "Peach Pro",179 lede: isPro() ? "active · paid once, yours forever" : "$39 once · yours forever",180 bgVar: "--stack-pro-bg",181 fgVar: "--stack-pro-fg",182 icon: "spark",183 onIconClick: isPro() ? triggerCelebration : undefined,184 },185 {186 id: "protection",187 num: "II · PROTECT",188 title: "Protection",189 lede: `${props.breachMonitoring ? "breaches watched" : "breach watch off"} · ${props.lockOnClose ? "locks on close" : "manual lock"}`,190 bgVar: "--stack-protect-bg",191 fgVar: "--stack-protect-fg",192 icon: "shield",193 },194 {195 id: "vault",196 num: "III · KEEP",197 title: "Vault",198 lede: `${props.entriesCount} ${props.entriesCount === 1 ? "entry" : "entries"} · backups, history, cleanup`,199 bgVar: "--stack-vault-bg",200 fgVar: "--stack-vault-fg",201 icon: "lock",202 },203 {204 id: "sync",205 num: "IV · ECHO",206 title: "Sync",207 lede: isPro() ? "your devices, one vault" : "Pro · encrypted device sync",208 bgVar: "--stack-sync-bg",209 fgVar: "--stack-sync-fg",210 icon: "sync",211 },212 {213 id: "appearance",214 num: "V · DRESS",215 title: "Appearance",216 lede: `${props.theme} · ${props.accentColor} · ${props.textSize}`,217 bgVar: "--stack-appear-bg",218 fgVar: "--stack-appear-fg",219 icon: "sun",220 },221 {222 id: "shortcuts",223 num: "VI · KEYS",224 title: "Shortcuts",225 lede: `${SHORTCUT_DESCRIPTORS.length} bindings · what your fingers know`,226 bgVar: "--stack-shortcut-bg",227 fgVar: "--stack-shortcut-fg",228 icon: "keys",229 },230 {231 id: "feedback",232 num: "VII · ECHO",233 title: "Feedback",234 lede: `version ${version} · tell us what would make it better`,235 bgVar: "--stack-feedback-bg",236 fgVar: "--stack-feedback-fg",237 icon: "bubble",238 },239 ]);240 const [biometricSupported, setBiometricSupported] = createSignal(false);241 const [biometricStored, setBiometricStored] = createSignal(false);242 const [biometricBusy, setBiometricBusy] = createSignal(false);243 const [biometricError, setBiometricError] = createSignal<string | null>(null);244 const [passkeyState, setPasskeyState] = createSignal<PasskeyProviderState | null>(null);245 const [passkeySaving, setPasskeySaving] = createSignal(false);246247 248 249 interface PendingVouched { deviceId: string; deviceName: string }250 const [pendingVouched, setPendingVouched] = createSignal<PendingVouched[]>([]);251 const [vouchedBusy, setVouchedBusy] = createSignal<string | null>(null);252 253 const [pairedDevices, setPairedDevices] = createSignal<PairedDevice[]>([]);254 const [confirmUnpair, setConfirmUnpair] = createSignal<string | null>(null);255 const [renameDeviceId, setRenameDeviceId] = createSignal<string | null>(null);256 const [renameDraft, setRenameDraft] = createSignal("");257 const [syncStatus, setSyncStatus] = createSignal<"idle" | "syncing" | "error">("idle");258 const [syncFlash, setSyncFlash] = createSignal<string | null>(null);259 260 261 const [syncSummary, setSyncSummary] = createSignal<SyncOutcomeSummary>({});262 const syncLight = () =>263 syncStatus() === "syncing"264 ? "busy"265 : syncIndicator(syncSummary(), pairedDevices().length);266 const syncLightLabel = () => syncIndicatorLabel(syncLight(), syncSummary());267 const lastSyncLine = () => {268 const s = syncSummary();269 if (!s.outcome) return "No sync attempted yet";270 return `${syncRelativeTime(s.finishedAt)} · ${describeSyncOutcome(s)}`;271 };272 async function loadSyncState() {273 const r = (await sendMessage({ type: "GET_SYNC_STATE" })) as {274 ok: boolean;275 data?: SyncOutcomeSummary;276 };277 if (r.ok && r.data) {278 setSyncSummary(r.data);279 setSyncStatus(r.data.status ?? "idle");280 }281 }282283 const STALE_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000;284 function syncRelativeTime(when: number | undefined): string {285 if (!when) return "Not synced yet";286 const elapsed = Date.now() - when;287 if (elapsed < 60_000) return "Just now";288 const minutes = Math.floor(elapsed / 60_000);289 if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"} ago`;290 const hours = Math.floor(minutes / 60);291 if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;292 const days = Math.floor(hours / 24);293 return days < 7 ? `${days} day${days === 1 ? "" : "s"} ago` : "Not seen recently";294 }295 function isSyncStale(d: PairedDevice): boolean {296 return d.lastSyncedAt !== undefined && Date.now() - d.lastSyncedAt > STALE_THRESHOLD_MS;297 }298 function deviceTypeLabel(t: PairedDevice["deviceType"]): string {299 switch (t) {300 case "android": return "Android";301 case "ios": return "iPhone";302 case "extension": return "Browser";303 default: return "Device";304 }305 }306307 async function loadPairedDevices() {308 if (!isPro()) return;309 const r = (await sendMessage({ type: "LIST_PAIRED_DEVICES" })) as {310 ok: boolean;311 data?: { devices: PairedDevice[] };312 };313 if (r.ok) setPairedDevices(r.data?.devices ?? []);314 }315 async function unpairDevice(deviceId: string) {316 await sendMessage({ type: "UNPAIR_DEVICE", payload: { deviceId } });317 setConfirmUnpair(null);318 await loadPairedDevices();319 }320 async function renameDevice(deviceId: string) {321 const name = renameDraft().trim();322 if (!name) return;323 const result = (await sendMessage({324 type: "RENAME_PAIRED_DEVICE",325 payload: { deviceId, name },326 })) as { ok: boolean };327 if (!result.ok) return;328 setRenameDeviceId(null);329 await loadPairedDevices();330 }331 async function syncNow() {332 setSyncStatus("syncing");333 setSyncFlash(null);334 const res = (await sendMessage({335 type: "TRIGGER_SYNC",336 payload: { reason: "manual", force: true },337 })) as { ok: boolean; error?: string };338 if (!res.ok) {339 setSyncStatus("error");340 setSyncSummary({ status: "error", outcome: "run_failed", detail: res.error });341 return;342 }343 344 345 346 let s: SyncOutcomeSummary = { status: "syncing" };347 for (let i = 0; i < 40 && s.status === "syncing"; i++) {348 await new Promise((r) => setTimeout(r, 250));349 const state = (await sendMessage({ type: "GET_SYNC_STATE" })) as {350 ok: boolean;351 data?: SyncOutcomeSummary;352 };353 s = state.ok ? state.data ?? { status: "idle" } : { status: "error", outcome: "run_failed" };354 }355 setSyncSummary(s);356 setSyncStatus(s.status ?? "idle");357 358 359 setSyncFlash(describeSyncOutcome(s));360 setTimeout(() => setSyncFlash(null), 4000);361 await loadPairedDevices();362 }363 364 const [rotateConfirmOpen, setRotateConfirmOpen] = createSignal(false);365 const [rotating, setRotating] = createSignal(false);366 const [rotateError, setRotateError] = createSignal<string | null>(null);367 const [showSyncConnection, setShowSyncConnection] = createSignal(false);368 const [showSyncRecovery, setShowSyncRecovery] = createSignal(false);369370 async function loadPendingVouched() {371 const r = (await sendMessage({ type: "GET_PENDING_VOUCHED_DEVICES" })) as {372 ok: boolean;373 data?: PendingVouched[];374 };375 if (r.ok) setPendingVouched(r.data ?? []);376 }377 async function confirmVouched(deviceId: string) {378 setVouchedBusy(deviceId);379 try {380 await sendMessage({ type: "CONFIRM_VOUCHED_DEVICE", payload: { deviceId } });381 await loadPendingVouched();382 } finally {383 setVouchedBusy(null);384 }385 }386 async function rejectVouched(deviceId: string) {387 setVouchedBusy(deviceId);388 try {389 await sendMessage({ type: "REJECT_VOUCHED_DEVICE", payload: { deviceId } });390 await loadPendingVouched();391 } finally {392 setVouchedBusy(null);393 }394 }395 async function doRotateSyncIdentity() {396 setRotateError(null);397 setRotating(true);398 try {399 const r = (await sendMessage({ type: "ROTATE_SYNC_IDENTITY" })) as {400 ok: boolean;401 error?: string;402 };403 if (!r.ok) {404 setRotateError(r.error ?? "Rotation failed.");405 return;406 }407 setRotateConfirmOpen(false);408 409 410 setShowNewRecoveryWords(true);411 } finally {412 setRotating(false);413 }414 }415416 const [dedupCount, setDedupCount] = createSignal<number | null>(null);417 const [dedupRunning, setDedupRunning] = createSignal(false);418 const [dedupDone, setDedupDone] = createSignal<number | null>(null);419420 const [declinedCardCount, setDeclinedCardCount] = createSignal(0);421422 423 424 425 const [historySnapshots, setHistorySnapshots] = createSignal<VaultSnapshot[]>([]);426 const [historyLoading, setHistoryLoading] = createSignal(false);427 const [historyRestoring, setHistoryRestoring] = createSignal<string | null>(null);428 const [historyShowAll, setHistoryShowAll] = createSignal(false);429430 async function loadVaultHistory() {431 setHistoryLoading(true);432 try {433 const res = await sendMessage({ type: "GET_VAULT_HISTORY" });434 const list = (res as { ok: boolean; data?: VaultSnapshot[] }).data ?? [];435 setHistorySnapshots(list);436 } finally {437 setHistoryLoading(false);438 }439 }440441 async function handleRestoreVaultSnapshot(id: string) {442 if (historyRestoring()) return;443 setHistoryRestoring(id);444 const res = await sendMessage({445 type: "RESTORE_VAULT_SNAPSHOT",446 payload: { snapshotId: id },447 });448 setHistoryRestoring(null);449 if ((res as { ok: boolean }).ok) {450 451 452 props.onBack();453 }454 }455456 function formatSnapshotTime(savedAt: number): string {457 const diff = Date.now() - savedAt;458 if (diff < 60_000) return "Just now";459 if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;460 const d = new Date(savedAt);461 const isToday = new Date().toDateString() === d.toDateString();462 const isYest =463 new Date(Date.now() - 86_400_000).toDateString() === d.toDateString();464 const t = d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });465 if (isToday) return `Today ${t}`;466 if (isYest) return `Yesterday ${t}`;467 return `${d.toLocaleDateString([], { month: "short", day: "numeric" })} ${t}`;468 }469470 function reasonLabel(reason: VaultSnapshot["reason"]): string {471 return (472 {473 local_save: "saved",474 sync_received: "synced",475 import: "imported",476 restore: "restored",477 } as const478 )[reason] ?? reason;479 }480481 const [showVaultAdvanced, setShowVaultAdvanced] = createSignal(false);482483 const [showChangePw, setShowChangePw] = createSignal(false);484 const [changePwCurrent, setChangePwCurrent] = createSignal("");485 const [changePwNew, setChangePwNew] = createSignal("");486 const [changePwConfirm, setChangePwConfirm] = createSignal("");487 const [changePwSaving, setChangePwSaving] = createSignal(false);488 const [changePwError, setChangePwError] = createSignal<string | null>(null);489 const [changePwSuccess] = createSignal(false);490 const [showNewRecoveryWords, setShowNewRecoveryWords] = createSignal(false);491492 const [pinEnabled, setPinEnabled] = createSignal(false);493 const [pinFlow, setPinFlow] = createSignal<"setup" | "disable" | null>(null);494 const [pinCurrentPassword, setPinCurrentPassword] = createSignal("");495 const [pinValue, setPinValue] = createSignal("");496 const [pinConfirm, setPinConfirm] = createSignal("");497 const [pinSaving, setPinSaving] = createSignal(false);498 const [pinError, setPinError] = createSignal<string | null>(null);499500 501 const [showViewWords, setShowViewWords] = createSignal(false);502 const [viewWordsPassword, setViewWordsPassword] = createSignal("");503 const [viewWordsError, setViewWordsError] = createSignal<string | null>(null);504 const [viewedWords, setViewedWords] = createSignal<string[] | null>(null);505 const [viewWordsBusy, setViewWordsBusy] = createSignal(false);506507 async function handleViewRecoveryWords() {508 setViewWordsError(null);509 if (!viewWordsPassword()) {510 setViewWordsError("Enter your master password.");511 return;512 }513 setViewWordsBusy(true);514 try {515 const r = (await sendMessage({516 type: "GET_RECOVERY_WORDS",517 payload: { password: viewWordsPassword() },518 })) as { ok: boolean; data?: string[]; error?: string };519 if (r.ok) {520 setViewedWords(r.data ?? []);521 setViewWordsPassword("");522 return;523 }524 switch (r.error) {525 case "wrong_password":526 setViewWordsError("Incorrect password");527 break;528 case "password_required":529 setViewWordsError("Enter your master password.");530 break;531 case "recovery_not_setup":532 setViewWordsError("Recovery words aren't set up for this vault yet.");533 break;534 default:535 setViewWordsError("Couldn't retrieve recovery words.");536 }537 } finally {538 setViewWordsBusy(false);539 }540 }541 function closeViewWords() {542 setShowViewWords(false);543 setViewWordsPassword("");544 setViewWordsError(null);545 setViewedWords(null);546 }547548 const [duressEnabled, setDuressEnabled] = createSignal(false);549 const [showDuressSetup, setShowDuressSetup] = createSignal(false);550 const [duressPassword, setDuressPassword] = createSignal("");551 const [duressConfirm, setDuressConfirm] = createSignal("");552 const [duressMainPassword, setDuressMainPassword] = createSignal("");553 const [duressSaving, setDuressSaving] = createSignal(false);554 const [duressError, setDuressError] = createSignal<string | null>(null);555 const [duressSuccess, setDuressSuccess] = createSignal<string | null>(null);556557 const [canaryEntries, setCanaryEntries] = createSignal<Array<{id: string; title: string; canary: Canary}>>([]);558559 const [canaryTestingId, setCanaryTestingId] = createSignal<string | null>(null);560 const [canaryTestResult, setCanaryTestResult] = createSignal<{id: string; ok: boolean} | null>(null);561562 const [fbCategory, setFbCategory] = createSignal("Bug report");563 const [fbMessage, setFbMessage] = createSignal("");564 type FeedbackState = 'idle' | 'sending' | 'sent' | 'error';565 const [feedbackState, setFeedbackState] = createSignal<FeedbackState>('idle');566567 createEffect(() => {568 if (props.upgradeHighlight) {569 setActiveCard("pro");570 }571 });572573 onMount(async () => {574 const [licenseRes, passkeyRes, duressRes, settingsRes, keyRes] = await Promise.all([575 sendMessage({ type: "GET_LICENSE" }),576 sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }),577 sendMessage({ type: "IS_DURESS_ENABLED" }),578 sendMessage({ type: "GET_SETTINGS" }),579 sendMessage({ type: "GET_LICENSE_KEY" }),580 ]);581582 583 584 void loadVaultHistory();585586 587 588 void loadPendingVouched();589 void loadPairedDevices();590 void loadSyncState();591 const onBroadcast = (msg: unknown) => {592 const m = msg as { type?: string; payload?: unknown } | null;593 if (m?.type === "VOUCHED_DEVICE_PENDING") {594 void loadPendingVouched();595 }596 if (m?.type === "SYNC_STATE_CHANGED") {597 const payload = (m.payload ?? {}) as SyncOutcomeSummary;598 setSyncSummary(payload);599 setSyncStatus(payload.status ?? "idle");600 }601 };602 chrome.runtime?.onMessage?.addListener(onBroadcast);603 onCleanup(() => {604 try { chrome.runtime?.onMessage?.removeListener(onBroadcast); } catch { }605 });606607 if ((licenseRes as { ok: boolean }).ok) {608 setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null);609 }610611 if ((passkeyRes as { ok: boolean }).ok) {612 setPasskeyState(613 (passkeyRes as { ok: boolean; data?: PasskeyProviderState }).data ?? null614 );615 }616617 if ((duressRes as { ok: boolean }).ok) {618 setDuressEnabled((duressRes as { ok: boolean; data?: boolean }).data ?? false);619 }620621 if ((settingsRes as { ok: boolean }).ok) {622 const sData = (settingsRes as { ok: boolean; data?: { declinedCardFingerprints?: string[]; pinEnabled?: boolean } }).data;623 setDeclinedCardCount(sData?.declinedCardFingerprints?.length ?? 0);624 setPinEnabled(sData?.pinEnabled === true);625 }626627 if ((keyRes as { ok: boolean }).ok) {628 setLicenseKey((keyRes as { ok: boolean; data?: string }).data ?? null);629 }630631 const entriesRes = await sendMessage({ type: "GET_ENTRIES" });632 if ((entriesRes as { ok: boolean }).ok) {633 const entries = (entriesRes as { ok: boolean; data?: Array<{id: string; title: string; canary?: Canary}> }).data ?? [];634 setCanaryEntries(635 entries636 .filter((e) => e.canary?.webhookUrl)637 .map((e) => ({ id: e.id, title: e.title, canary: e.canary! }))638 );639 }640641642 if (isNativePlatform()) {643 const biometric = nativePlugin<{ isAvailable: () => Promise<{ available: boolean }> }>("BiometricPlugin");644 const keystore = nativePlugin<{ hasMasterKey: () => Promise<{ exists: boolean }> }>("KeystorePlugin");645 try {646 if (biometric) {647 const a = await biometric.isAvailable();648 setBiometricSupported(a.available);649 }650 if (keystore) {651 const k = await keystore.hasMasterKey();652 setBiometricStored(k.exists);653 }654 } catch { }655 }656 });657658 async function handleEnableBiometrics() {659 setBiometricBusy(true);660 setBiometricError(null);661 try {662 const keyRes = (await sendMessage({ type: "GET_DERIVED_KEY" })) as {663 ok: boolean;664 data?: { masterKeyHex: string };665 error?: string;666 };667 if (!keyRes.ok || !keyRes.data?.masterKeyHex) {668 setBiometricError(keyRes.error === "vault_locked"669 ? "unlock the vault first"670 : "couldn't read derived key");671 return;672 }673 const keystore = nativePlugin<{ storeMasterKey: (p: { masterKeyHex: string }) => Promise<void> }>("KeystorePlugin");674 if (!keystore) {675 setBiometricError("biometric plugin unavailable");676 return;677 }678 await keystore.storeMasterKey({ masterKeyHex: keyRes.data.masterKeyHex });679 await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: true } });680 setBiometricStored(true);681 } catch (e) {682 setBiometricError(e instanceof Error ? e.message : "failed to enable biometrics");683 } finally {684 setBiometricBusy(false);685 }686 }687688 async function handleDisableBiometrics() {689 setBiometricBusy(true);690 setBiometricError(null);691 try {692 const keystore = nativePlugin<{ clearMasterKey: () => Promise<void> }>("KeystorePlugin");693 if (keystore) await keystore.clearMasterKey();694 await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: false } });695 setBiometricStored(false);696 } catch (e) {697 setBiometricError(e instanceof Error ? e.message : "failed to disable biometrics");698 } finally {699 setBiometricBusy(false);700 }701 }702703 function resetPinFlow() {704 setPinFlow(null);705 setPinCurrentPassword("");706 setPinValue("");707 setPinConfirm("");708 setPinError(null);709 }710711 async function handleSetupPin() {712 setPinError(null);713 if (!pinCurrentPassword()) { setPinError("enter your current password"); return; }714 if (!/^\d{4,12}$/.test(pinValue())) { setPinError("PIN must be 4–12 digits"); return; }715 if (pinValue() !== pinConfirm()) { setPinError("PINs do not match"); return; }716 setPinSaving(true);717 const res = await sendMessage({718 type: "SETUP_PIN",719 payload: { currentPassword: pinCurrentPassword(), pin: pinValue() },720 }) as { ok: boolean; error?: string };721 if (res.ok) {722 setPinEnabled(true);723 resetPinFlow();724 } else {725 setPinError(res.error === "incorrect_current_password" ? "incorrect current password" : "couldn't set up PIN");726 }727 setPinSaving(false);728 }729730 async function handleDisablePin() {731 setPinError(null);732 if (!pinCurrentPassword()) { setPinError("enter your current password"); return; }733 setPinSaving(true);734 const res = await sendMessage({735 type: "DISABLE_PIN",736 payload: { currentPassword: pinCurrentPassword() },737 }) as { ok: boolean; error?: string };738 if (res.ok) {739 setPinEnabled(false);740 resetPinFlow();741 } else {742 setPinError(res.error === "incorrect_current_password" ? "incorrect current password" : "couldn't remove PIN");743 }744 setPinSaving(false);745 }746747 async function handleChangePassword() {748 setChangePwError(null);749 if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; }750 if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; }751 if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; }752 if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; }753 setChangePwSaving(true);754 const res = await sendMessage({755 type: "CHANGE_PASSWORD",756 payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() },757 }) as { ok: boolean; error?: string };758 if (res.ok) {759 setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");760 setShowChangePw(false);761 762 763 764 765 await chrome.storage?.local?.set?.({ peach_recovery_pending: "1" });766 setShowNewRecoveryWords(true);767 } else {768 setChangePwError(res.error ?? "failed to change password");769 }770 setChangePwSaving(false);771 }772773 const [copied, setCopied] = createSignal(false);774775 async function copyLicenseKey(key: string) {776 await navigator.clipboard.writeText(key);777 setCopied(true);778 setTimeout(() => setCopied(false), 2000);779 }780781 async function handleActivateLicense() {782 const key = licenseKeyInput().trim();783 if (!key) return;784 setActivatingLicense(true);785 setLicenseError(null);786 setLicenseSuccess(null);787 const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } });788 setActivatingLicense(false);789 if ((res as { ok: boolean }).ok) {790 const data = (res as { ok: boolean; data?: LicenseInfo }).data;791 if (data?.valid) {792 setLicenseInfo(data);793 setLicenseKey(key);794 setLicenseSuccess("License activated!");795 setLicenseKeyInput("");796 } else {797 setLicenseError("Invalid license key");798 }799 } else {800 setLicenseError("Activation failed");801 }802 }803804 async function handleTogglePasskeys() {805 const current = passkeyState();806 if (!current || !current.supported) return;807 setPasskeySaving(true);808 const res = await sendMessage({809 type: "SET_PASSKEY_PROVIDER_ENABLED",810 payload: { enabled: !current.enabled },811 });812 setPasskeySaving(false);813 if ((res as { ok: boolean }).ok) {814 setPasskeyState(815 (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null816 );817 }818 }819820 async function handleCheckAllBreaches() {821 setCheckingBreaches(true);822 setBreachResult(null);823 const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });824 setCheckingBreaches(false);825 if ((res as { ok: boolean }).ok) {826 const data = (res as { ok: boolean; data?: Record<string, number> }).data ?? {};827 const breached = Object.values(data).filter((count) => count > 0).length;828 setBreachResult(829 breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean"830 );831 props.onBreachCheckDone();832 }833 }834835836 async function handleSetupDuress() {837 const mainPw = duressMainPassword().trim();838 const duressPw = duressPassword().trim();839 const confirmPw = duressConfirm().trim();840841 if (!mainPw) { setDuressError("enter your main password to confirm"); return; }842 if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; }843 if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; }844 if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; }845846 setDuressSaving(true);847 setDuressError(null);848849 const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };850 if (!verifyRes.ok || !verifyRes.data) {851 setDuressError("incorrect main password");852 setDuressSaving(false);853 return;854 }855856 const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string };857 setDuressSaving(false);858 if (res.ok) {859 setDuressEnabled(true);860 setShowDuressSetup(false);861 setDuressPassword("");862 setDuressConfirm("");863 setDuressMainPassword("");864 setDuressSuccess("Duress password set. If entered, a decoy vault will be shown.");865 setTimeout(() => setDuressSuccess(null), 5000);866 } else {867 setDuressError(868 res.error === "session_expired"869 ? "Vault session expired — lock and unlock Peach, then try again"870 : res.error ?? "failed to set duress password"871 );872 }873 }874875 async function handleClearDeclinedCards() {876 await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } });877 setDeclinedCardCount(0);878 }879880 async function handleCountDuplicates() {881 setDedupRunning(true);882 setDedupDone(null);883 const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number };884 setDedupRunning(false);885 if (res.ok) setDedupCount(res.data ?? 0);886 }887888 async function handleDeduplicate() {889 setDedupRunning(true);890 const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number };891 setDedupRunning(false);892 if (res.ok) {893 setDedupDone(res.data ?? 0);894 setDedupCount(0);895 }896 }897898 async function handleDisableDuress() {899 const mainPw = duressMainPassword().trim();900 if (!mainPw) { setDuressError("enter your main password to confirm"); return; }901902 setDuressSaving(true);903 setDuressError(null);904905 const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };906 if (!verifyRes.ok || !verifyRes.data) {907 setDuressError("incorrect main password");908 setDuressSaving(false);909 return;910 }911912 const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string };913 setDuressSaving(false);914 if (res.ok) {915 setDuressEnabled(false);916 setShowDuressSetup(false);917 setDuressMainPassword("");918 setDuressSuccess("Duress password disabled.");919 setTimeout(() => setDuressSuccess(null), 4000);920 } else {921 setDuressError(res.error ?? "failed to disable duress password");922 }923 }924925 async function handleSubmitFeedback() {926 const message = sanitizeFeedbackMessage(fbMessage());927 if (!message) return;928929 setFeedbackState('sending');930931 try {932 const category = FEEDBACK_CATEGORIES.includes(fbCategory())933 ? fbCategory()934 : FEEDBACK_CATEGORIES[0];935 const res = await fetch('https://api.web3forms.com/submit', {936 method: 'POST',937 headers: { 'Content-Type': 'application/json' },938 body: JSON.stringify({939 access_key: WEB3FORMS_KEY,940 subject: `Peach Feedback - ${category}`,941 message,942 from_name: 'Peach User',943 944 botcheck: '',945 }),946 });947948 if (!res.ok) throw new Error(`HTTP ${res.status}`);949 const data = await res.json() as { success: boolean; message?: string };950 if (!data.success) throw new Error(data.message ?? 'unknown error');951952 setFeedbackState('sent');953 setFbMessage('');954 setFbCategory(FEEDBACK_CATEGORIES[0]);955 } catch (err) {956 console.error('[feedback]', err);957 setFeedbackState('error');958 setTimeout(() => setFeedbackState('idle'), 4000);959 }960 }961962 function renderSecurity() {963 return (964 <div class="peach-settings-body">965 <div class="peach-settings-group">966 <p class="peach-settings-sub-label">LOCKING</p>967 <div class="peach-settings-row">968 <span>Lock on close</span>969 <button970 class={`peach-toggle ${props.lockOnClose ? "is-on" : ""}`}971 onClick={() => void props.onLockOnCloseChange(!props.lockOnClose)}972 aria-label="Toggle lock on close"973 >974 <span class="peach-toggle-knob"></span>975 </button>976 </div>977 <div class="peach-settings-row">978 <span>Auto-lock after</span>979 <select980 class="peach-settings-select"981 value={String(props.autoLockMinutes)}982 onChange={(e) => void props.onAutoLockMinutesChange(Number(e.currentTarget.value))}983 >984 <option value="1">1 minute</option>985 <option value="5">5 minutes</option>986 <option value="15">15 minutes</option>987 <option value="30">30 minutes</option>988 <option value="0">Never</option>989 </select>990 </div>991992 <p class="peach-settings-sub-label">QUICK UNLOCK</p>993 <div class="peach-settings-row peach-settings-row--meta">994 <span>PIN unlock</span>995 <button996 class="peach-btn peach-btn--subtle peach-btn--sm"997 onClick={() => { setPinFlow(pinEnabled() ? "disable" : "setup"); setPinError(null); }}998 >999 {pinEnabled() ? "remove" : "set up"}1000 </button>1001 </div>1002 <Show when={pinFlow() === "setup"}>1003 <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1004 <input1005 class="peach-settings-input"1006 type="password"1007 placeholder="current password"1008 value={pinCurrentPassword()}1009 onInput={(e) => setPinCurrentPassword((e.target as HTMLInputElement).value)}1010 autocomplete="current-password"1011 />1012 <input1013 class="peach-settings-input"1014 type="password"1015 inputmode="numeric"1016 maxlength="12"1017 placeholder="new 4–12 digit PIN"1018 value={pinValue()}1019 onInput={(e) => setPinValue((e.target as HTMLInputElement).value.replace(/\D/g, "").slice(0, 12))}1020 autocomplete="off"1021 />1022 <input1023 class="peach-settings-input"1024 type="password"1025 inputmode="numeric"1026 maxlength="12"1027 placeholder="confirm PIN"1028 value={pinConfirm()}1029 onInput={(e) => setPinConfirm((e.target as HTMLInputElement).value.replace(/\D/g, "").slice(0, 12))}1030 autocomplete="off"1031 onKeyDown={(e) => { if (e.key === "Enter") void handleSetupPin(); }}1032 />1033 <Show when={pinError()}>1034 <p class="peach-hint peach-hint-error" style="margin:0">{pinError()}</p>1035 </Show>1036 <button1037 class="peach-btn peach-btn--accent peach-btn--sm"1038 style="align-self:flex-start"1039 onClick={() => void handleSetupPin()}1040 disabled={pinSaving() || !pinCurrentPassword() || !/^\d{4,12}$/.test(pinValue()) || !/^\d{4,12}$/.test(pinConfirm())}1041 >1042 {pinSaving() ? "saving…" : "save PIN"}1043 </button>1044 </div>1045 </Show>1046 <Show when={pinFlow() === "disable"}>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={pinCurrentPassword()}1053 onInput={(e) => setPinCurrentPassword((e.target as HTMLInputElement).value)}1054 autocomplete="current-password"1055 onKeyDown={(e) => { if (e.key === "Enter") void handleDisablePin(); }}1056 />1057 <Show when={pinError()}>1058 <p class="peach-hint peach-hint-error" style="margin:0">{pinError()}</p>1059 </Show>1060 <button1061 class="peach-btn peach-btn--subtle peach-btn--sm"1062 style="align-self:flex-start"1063 onClick={() => void handleDisablePin()}1064 disabled={pinSaving() || !pinCurrentPassword()}1065 >1066 {pinSaving() ? "removing…" : "remove PIN"}1067 </button>1068 </div>1069 </Show>10701071 <Show when={isNativePlatform() && biometricSupported()}>1072 <p class="peach-settings-sub-label">BIOMETRICS</p>1073 <div class="peach-settings-row">1074 <span>Unlock with biometrics</span>1075 <button1076 class={`peach-toggle ${biometricStored() ? "is-on" : ""}`}1077 onClick={() => void (biometricStored() ? handleDisableBiometrics() : handleEnableBiometrics())}1078 disabled={biometricBusy()}1079 aria-label="Toggle biometric unlock"1080 >1081 <span class="peach-toggle-knob"></span>1082 </button>1083 </div>1084 <Show when={biometricError()}>1085 <div class="peach-settings-row peach-settings-row--meta">1086 <span class="peach-hint peach-hint-error">{biometricError()}</span>1087 </div>1088 </Show>1089 </Show>10901091 <p class="peach-settings-sub-label">BREACH PROTECTION</p>1092 <div class="peach-settings-row">1093 <span>Breach monitoring</span>1094 <button1095 class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}1096 onClick={() => void props.onBreachMonitoringChange(!props.breachMonitoring)}1097 aria-label="Toggle breach monitoring"1098 >1099 <span class="peach-toggle-knob"></span>1100 </button>1101 </div>1102 <div class="peach-settings-row peach-settings-row--meta">1103 <span>Check all passwords</span>1104 <button1105 class="peach-btn peach-btn--subtle peach-btn--sm"1106 onClick={() => void handleCheckAllBreaches()}1107 disabled={checkingBreaches()}1108 >1109 {checkingBreaches() ? "checking..." : "run"}1110 </button>1111 </div>1112 <Show when={breachResult()}>1113 <div class="peach-settings-row peach-settings-row--meta">1114 <span>Last check</span>1115 <span class="peach-settings-value">{breachResult()}</span>1116 </div>1117 </Show>11181119 <p class="peach-settings-sub-label">MASTER PASSWORD</p>1120 <div class="peach-settings-row peach-settings-row--meta">1121 <span>Master password</span>1122 <button1123 class="peach-btn peach-btn--subtle peach-btn--sm"1124 onClick={() => { setShowChangePw((v) => !v); setChangePwError(null); }}1125 >1126 {showChangePw() ? "cancel" : "change"}1127 </button>1128 </div>1129 <Show when={showChangePw()}>1130 <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1131 <input1132 class="peach-settings-input"1133 type="password"1134 placeholder="current password"1135 value={changePwCurrent()}1136 onInput={(e) => setChangePwCurrent((e.target as HTMLInputElement).value)}1137 autocomplete="current-password"1138 />1139 <input1140 class="peach-settings-input"1141 type="password"1142 placeholder="new password (min 12 chars)"1143 value={changePwNew()}1144 onInput={(e) => setChangePwNew((e.target as HTMLInputElement).value)}1145 autocomplete="new-password"1146 />1147 <input1148 class="peach-settings-input"1149 type="password"1150 placeholder="confirm new password"1151 value={changePwConfirm()}1152 onInput={(e) => setChangePwConfirm((e.target as HTMLInputElement).value)}1153 autocomplete="new-password"1154 onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }}1155 />1156 <Show when={changePwError()}>1157 <p class="peach-hint peach-hint-error" style="margin:0">{changePwError()}</p>1158 </Show>1159 <Show when={changePwSuccess()}>1160 <p class="peach-hint" style="margin:0;color:var(--peach-accent)">password changed ✓</p>1161 </Show>1162 <button1163 class="peach-btn peach-btn--accent peach-btn--sm"1164 style="align-self:flex-start"1165 onClick={() => void handleChangePassword()}1166 disabled={changePwSaving() || !changePwCurrent() || !changePwNew() || !changePwConfirm()}1167 >1168 {changePwSaving() ? "saving…" : "save new password"}1169 </button>1170 </div>1171 </Show>11721173 {}1174 <div class="peach-settings-row peach-settings-row--meta">1175 <span>Recovery words</span>1176 <button1177 class="peach-btn peach-btn--subtle peach-btn--sm"1178 onClick={() => {1179 if (showViewWords()) { closeViewWords(); } else { setShowViewWords(true); }1180 }}1181 >1182 {showViewWords() ? "cancel" : "view"}1183 </button>1184 </div>1185 <Show when={showViewWords()}>1186 <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1187 <Show1188 when={viewedWords()}1189 fallback={1190 <>1191 <p class="peach-hint" style="margin:0;color:var(--peach-t2)">1192 Enter your master password to reveal your recovery words.1193 </p>1194 <input1195 class="peach-settings-input"1196 type="password"1197 placeholder="master password"1198 value={viewWordsPassword()}1199 onInput={(e) => setViewWordsPassword((e.target as HTMLInputElement).value)}1200 autocomplete="current-password"1201 onKeyDown={(e) => { if (e.key === "Enter") void handleViewRecoveryWords(); }}1202 />1203 <Show when={viewWordsError()}>1204 <p class="peach-hint peach-hint-error" style="margin:0">{viewWordsError()}</p>1205 </Show>1206 <button1207 class="peach-btn peach-btn--accent peach-btn--sm"1208 style="align-self:flex-start"1209 onClick={() => void handleViewRecoveryWords()}1210 disabled={viewWordsBusy() || !viewWordsPassword()}1211 >1212 {viewWordsBusy() ? "checking…" : "reveal recovery words"}1213 </button>1214 </>1215 }1216 >1217 <div class="peach-recovery-words-grid">1218 <For each={viewedWords()!}>1219 {(word, i) => (1220 <span class="peach-recovery-word"><b>{i() + 1}</b> {word}</span>1221 )}1222 </For>1223 </div>1224 <button1225 class="peach-btn peach-btn--subtle peach-btn--sm"1226 style="align-self:flex-start"1227 onClick={closeViewWords}1228 >done</button>1229 </Show>1230 </div>1231 </Show>12321233 <p class="peach-settings-sub-label">1234 DURESS PASSWORD1235 <Show when={!isPro()}>1236 <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>1237 </Show>1238 </p>1239 <div class="peach-settings-row peach-settings-row--top">1240 <div class="peach-settings-row-main">1241 <span>1242 Duress password1243 <Show when={!isPro()}>1244 <span class="peach-pro-badge">Pro</span>1245 </Show>1246 </span>1247 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">1248 If entered under coercion, shows a decoy empty vault instead of your real one.1249 </span>1250 </div>1251 <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>1252 {duressEnabled() ? "on" : "off"}1253 </div>1254 </div>12551256 <Show when={duressEnabled() && !showDuressSetup()}>1257 <div class="peach-settings-row peach-settings-row--meta">1258 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1259 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.1260 </span>1261 </div>1262 </Show>12631264 <Show when={duressSuccess()}>1265 <div class="peach-settings-row peach-settings-row--meta">1266 <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>1267 </div>1268 </Show>12691270 <Show when={!showDuressSetup()}>1271 <div class="peach-settings-row peach-settings-row--meta">1272 <button1273 class="peach-btn peach-btn--subtle peach-btn--sm"1274 onClick={() => {1275 1276 1277 1278 1279 1280 if (!isPro() && !duressEnabled()) {1281 props.onProGate?.("duress_vault");1282 setActiveCard("pro");1283 return;1284 }1285 setShowDuressSetup(true);1286 setDuressError(null);1287 setDuressSuccess(null);1288 setDuressPassword("");1289 setDuressConfirm("");1290 setDuressMainPassword("");1291 }}1292 >1293 {duressEnabled() ? "Change / disable" : "Set up"}1294 </button>1295 </div>1296 </Show>12971298 <Show when={showDuressSetup()}>1299 <div class="peach-duress-form">1300 <label class="peach-label">main password (to confirm)</label>1301 <input1302 class="peach-settings-input"1303 type="password"1304 placeholder="your current master password"1305 autocomplete="current-password"1306 value={duressMainPassword()}1307 onInput={(e) => setDuressMainPassword(e.currentTarget.value)}1308 />1309 <Show when={!duressEnabled()}>1310 <label class="peach-label">new duress password</label>1311 <input1312 class="peach-settings-input"1313 type="password"1314 placeholder="duress password (min 8 chars)"1315 autocomplete="new-password"1316 value={duressPassword()}1317 onInput={(e) => setDuressPassword(e.currentTarget.value)}1318 />1319 <label class="peach-label">confirm duress password</label>1320 <input1321 class="peach-settings-input"1322 type="password"1323 placeholder="confirm duress password"1324 autocomplete="new-password"1325 value={duressConfirm()}1326 onInput={(e) => setDuressConfirm(e.currentTarget.value)}1327 />1328 </Show>13291330 <Show when={duressError()}>1331 <p class="peach-hint peach-hint-error">{duressError()}</p>1332 </Show>13331334 <div class="peach-duress-actions">1335 <button1336 class="peach-btn peach-btn--subtle peach-btn--sm"1337 onClick={() => { setShowDuressSetup(false); setDuressError(null); }}1338 >1339 cancel1340 </button>1341 <Show when={duressEnabled()}>1342 <button1343 class="peach-btn peach-btn--ghost peach-btn--sm"1344 style="color:var(--peach-breach)"1345 onClick={() => void handleDisableDuress()}1346 disabled={duressSaving()}1347 >1348 {duressSaving() ? "disabling..." : "disable duress"}1349 </button>1350 </Show>1351 <Show when={!duressEnabled()}>1352 <button1353 class="peach-btn peach-btn--accent peach-btn--sm"1354 onClick={() => void handleSetupDuress()}1355 disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}1356 >1357 {duressSaving() ? "saving..." : "save"}1358 </button>1359 </Show>1360 </div>1361 </div>1362 </Show>13631364 <p class="peach-settings-sub-label">1365 CANARY ENTRIES1366 <Show when={!isPro()}>1367 <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>1368 </Show>1369 </p>1370 <div class="peach-settings-row peach-settings-row--top">1371 <div class="peach-settings-row-main">1372 <span>1373 Canary entries1374 <Show when={!isPro()}>1375 <span class="peach-pro-badge">Pro</span>1376 </Show>1377 </span>1378 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1379 Silently pings a webhook when an entry is accessed. Configure canary on any entry in your vault.1380 </span>1381 </div>1382 <span class="peach-settings-value" style="flex-shrink:0">1383 {canaryEntries().length > 0 ? `${canaryEntries().length} active` : "none"}1384 </span>1385 </div>1386 <Show when={canaryEntries().length > 0}>1387 <For each={canaryEntries()}>1388 {(entry) => (1389 <div class="peach-settings-row peach-settings-row--meta">1390 <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0">{entry.title}</span>1391 <button1392 class="peach-btn peach-btn--subtle peach-btn--sm"1393 onClick={() => void handleTestCanary(entry)}1394 disabled={canaryTestingId() === entry.id}1395 style="flex-shrink:0"1396 >1397 {canaryTestingId() === entry.id1398 ? "sending…"1399 : canaryTestResult()?.id === entry.id1400 ? (canaryTestResult()!.ok ? "sent ✓" : "failed ✗")1401 : "test"}1402 </button>1403 </div>1404 )}1405 </For>1406 </Show>14071408 <p class="peach-settings-sub-label">PASSKEYS</p>1409 <div class="peach-settings-row peach-settings-row--top">1410 <div class="peach-settings-row-main">1411 <span>Intercept passkey requests</span>1412 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">1413 Peach can handle passkey sign-in and creation on supported Chromium browsers.1414 </span>1415 </div>1416 <button1417 class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}1418 onClick={() => void handleTogglePasskeys()}1419 aria-label="Toggle passkey interception"1420 disabled={!passkeyState()?.supported || passkeySaving()}1421 >1422 <span class="peach-toggle-knob"></span>1423 </button>1424 </div>1425 <div class="peach-settings-row peach-settings-row--meta">1426 <span>Status</span>1427 <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>1428 </div>1429 <div class="peach-settings-row peach-settings-row--meta">1430 <span>Browser</span>1431 <span class="peach-settings-value">1432 {passkeyState()?.browserLabel ?? "Unknown"}1433 {passkeyState()?.supported ? " ✓" : " (interception unavailable)"}1434 </span>1435 </div>1436 <div class="peach-info-notice" style="margin-top:8px">1437 Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.1438 </div>1439 <Show when={passkeyState()?.conflict}>1440 <div class="peach-settings-row peach-settings-row--meta">1441 <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>1442 </div>1443 </Show>1444 </div>1445 </div>1446 );1447 }14481449 function renderVault() {1450 return (1451 <div class="peach-settings-body">1452 <div class="peach-settings-group">1453 <p class="peach-settings-sub-label">ENTRIES</p>1454 <div class="peach-settings-row peach-settings-row--meta">1455 <span>Entries</span>1456 <span class="peach-settings-value">{props.entriesCount}</span>1457 </div>1458 <div class="peach-settings-row peach-settings-row--meta">1459 <span>Duplicates</span>1460 <Show when={dedupDone() !== null} fallback={1461 <button1462 class="peach-btn peach-btn--subtle peach-btn--sm"1463 onClick={() => void handleCountDuplicates()}1464 disabled={dedupRunning()}1465 >1466 {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}1467 </button>1468 }>1469 <span class="peach-settings-value" style="color:var(--peach-sync-ok)">1470 {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}1471 </span>1472 </Show>1473 </div>1474 <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>1475 <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">1476 <div class="peach-restore-warning">1477 {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.1478 </div>1479 <div style="display:flex;gap:6px">1480 <button1481 class="peach-btn peach-btn--subtle peach-btn--sm"1482 onClick={() => setDedupCount(null)}1483 >1484 cancel1485 </button>1486 <button1487 class="peach-btn peach-btn--accent peach-btn--sm"1488 onClick={() => void handleDeduplicate()}1489 disabled={dedupRunning()}1490 >1491 {dedupRunning() ? "removing..." : "remove duplicates"}1492 </button>1493 </div>1494 </div>1495 </Show>14961497 <p class="peach-settings-sub-label">MAINTENANCE</p>1498 <div class="peach-settings-row peach-settings-row--meta">1499 <span>Trash auto-delete</span>1500 <select1501 class="peach-settings-select"1502 value={String(props.trashRetentionDays)}1503 onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}1504 >1505 <option value="7">7 days</option>1506 <option value="30">30 days</option>1507 <option value="90">90 days</option>1508 <option value="0">Never</option>1509 </select>1510 </div>1511 <div class="peach-settings-row peach-settings-row--meta">1512 <span>Advanced Vault Health</span>1513 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>1514 open1515 </button>1516 </div>1517 <Show when={declinedCardCount() > 0}>1518 <div class="peach-settings-row peach-settings-row--meta">1519 <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>1520 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>1521 Clear1522 </button>1523 </div>1524 </Show>15251526 <p class="peach-settings-sub-label">DATA</p>1527 <div class="peach-settings-row peach-settings-row--meta">1528 <span>Import passwords</span>1529 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>1530 open1531 </button>1532 </div>1533 <div class="peach-settings-row peach-settings-row--meta">1534 <span>Export backup</span>1535 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onExport}>1536 open1537 </button>1538 </div>1539 <div class="peach-settings-row peach-settings-row--meta">1540 <span>1541 Peach Codex1542 <Show when={!isPro()}>1543 <span class="peach-pro-badge">Pro</span>1544 </Show>1545 </span>1546 <button1547 class="peach-btn peach-btn--subtle peach-btn--sm"1548 onClick={() => props.onOpenCodexGenerate?.()}1549 >1550 open1551 </button>1552 </div>1553 {1554}1555 <div class="peach-settings-row peach-settings-row--meta">1556 <span>Restore from Codex</span>1557 <button1558 class="peach-btn peach-btn--subtle peach-btn--sm"1559 onClick={() => props.onOpenCodexRestore?.()}1560 >1561 open1562 </button>1563 </div>15641565 <span class="peach-settings-sub-label">VAULT HISTORY</span>1566 <Show when={historyLoading()}>1567 <p class="peach-settings-hint">Loading…</p>1568 </Show>1569 <Show when={!historyLoading() && historySnapshots().length === 0}>1570 <p class="peach-settings-hint">1571 No snapshots yet. Peach saves automatically before every change —1572 saves, syncs, and imports.1573 </p>1574 </Show>1575 <Show when={!historyLoading() && historySnapshots().length > 0}>1576 <p class="peach-settings-hint">1577 Restoring locks your vault. Unlock with the password that1578 encrypted that snapshot to confirm.1579 </p>1580 <div class="peach-history-list">1581 <For each={historySnapshots().slice(0, historyShowAll() ? undefined : 3)}>1582 {(snap) => (1583 <div class="peach-history-row">1584 <div class="peach-history-meta">1585 <span class="peach-history-time">1586 {formatSnapshotTime(snap.savedAt)}1587 </span>1588 <span class="peach-history-sub">1589 {[1590 snap.entryCount !== null1591 ? `up to ${snap.entryCount} ${snap.entryCount === 1 ? "entry" : "entries"}`1592 : null,1593 reasonLabel(snap.reason),1594 ]1595 .filter(Boolean)1596 .join(" · ")}1597 </span>1598 </div>1599 <button1600 class={`peach-btn peach-btn--subtle peach-btn--sm peach-history-btn${1601 historyRestoring() === snap.id ? " is-busy" : ""1602 }`}1603 onClick={() => void handleRestoreVaultSnapshot(snap.id)}1604 disabled={historyRestoring() !== null}1605 >1606 <Show when={historyRestoring() === snap.id} fallback="Restore">1607 …1608 </Show>1609 </button>1610 </div>1611 )}1612 </For>1613 </div>1614 <Show when={historySnapshots().length > 3}>1615 <button1616 class="peach-settings-advanced-toggle"1617 onClick={() => setHistoryShowAll((v) => !v)}1618 aria-expanded={historyShowAll()}1619 >1620 <span class="peach-settings-hint" style="margin:0">1621 {historyShowAll() ? "show less" : `${historySnapshots().length - 3} older`}1622 </span>1623 <span class="peach-settings-advanced-chevron">1624 {historyShowAll() ? "▲" : "▼"}1625 </span>1626 </button>1627 </Show>1628 <p class="peach-history-footer">1629 {historySnapshots().length} of 10 snapshots1630 </p>1631 </Show>16321633 <button1634 class="peach-settings-advanced-toggle"1635 onClick={() => setShowVaultAdvanced((v) => !v)}1636 aria-expanded={showVaultAdvanced()}1637 >1638 <span class="peach-settings-sub-label" style="margin:0">ADVANCED</span>1639 <span class="peach-settings-advanced-chevron">1640 {showVaultAdvanced() ? "▲" : "▼"}1641 </span>1642 </button>16431644 <Show when={showVaultAdvanced()}>1645 <div class="peach-danger-card">1646 <div class="peach-danger-card-head">1647 <span class="peach-danger-card-glyph" aria-hidden="true">⟳</span>1648 <h3 class="peach-danger-card-title">Replace vault from backup</h3>1649 </div>1650 <p class="peach-danger-card-copy">1651 Wipes the current vault and restores from a backup file.1652 Recoverable only if your backup is good.1653 </p>1654 <button1655 class="peach-danger-card-button"1656 onClick={() => props.onOpenFileImport?.()}1657 >1658 Choose backup file1659 </button>1660 </div>16611662 <Show when={props.onOpenPanic}>1663 <div class="peach-danger-card peach-danger-card--hazard">1664 <div class="peach-danger-card-head">1665 <span class="peach-danger-card-glyph" aria-hidden="true">⚠</span>1666 <h3 class="peach-danger-card-title">Destroy vault</h3>1667 </div>1668 <p class="peach-danger-card-copy">1669 Permanently wipes every entry, setting, and license on this1670 device. There is no recovery.1671 </p>1672 <button1673 class="peach-danger-card-button"1674 onClick={() => props.onOpenPanic?.()}1675 >1676 Begin destruction1677 </button>1678 </div>1679 </Show>1680 </Show>1681 </div>1682 </div>1683 );1684 }16851686 function renderSync() {1687 return (1688 <div class="peach-settings-body">1689 <div class="peach-settings-group">1690 <p class="peach-settings-sub-label" style="margin-top:0">SYNC STATUS</p>1691 <div1692 class="peach-settings-row peach-settings-row--meta"1693 style="align-items:center;gap:8px"1694 role="status"1695 aria-label={syncLightLabel()}1696 title={syncLightLabel()}1697 >1698 <span class={`stk-pip stk-pip--sync is-${syncLight()}`} aria-hidden="true" />1699 <span class="peach-settings-value" style="color:var(--peach-t2);line-height:1.5;flex:1;min-width:0;overflow-wrap:anywhere">1700 {syncFlash() ?? lastSyncLine()}1701 </span>1702 </div>1703 <Show when={pairedDevices().length > 0}>1704 <div class="peach-settings-row peach-settings-row--meta">1705 <span class="peach-settings-value" style="color:var(--peach-t2)">1706 {(syncSummary().entriesSent ?? 0) + (syncSummary().entriesReceived ?? 0) > 01707 ? `${syncSummary().entriesSent ?? 0} sent · ${syncSummary().entriesReceived ?? 0} received`1708 : ""}1709 </span>1710 <button1711 class="peach-btn peach-btn--subtle peach-btn--sm"1712 aria-label="Sync this vault now"1713 disabled={syncStatus() === "syncing"}1714 onClick={() => void syncNow()}1715 >{syncStatus() === "syncing" ? "syncing…" : "sync now"}</button>1716 </div>1717 </Show>17181719 <p class="peach-settings-sub-label">ADD A DEVICE</p>1720 <button1721 class="peach-sync-pair-option"1722 onClick={() => props.onOpenSyncPairing?.("code")}1723 >1724 <span class="peach-sync-pair-option-icon" aria-hidden="true">1725 <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">1726 <rect x="6" y="2.5" width="8" height="15" rx="1.6" />1727 <path d="M9 15 L11 15" />1728 </svg>1729 </span>1730 <span class="peach-sync-pair-option-main">1731 <span class="peach-sync-pair-option-title">Show a code</span>1732 <span class="peach-sync-pair-option-sub">Scan it with Peach on your phone, then confirm one 8-digit code</span>1733 </span>1734 <span class="peach-sync-pair-option-chevron" aria-hidden="true">›</span>1735 </button>1736 <Show when={!isPro()}>1737 <p class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2);margin:8px 2px 0">1738 At least one device needs Peach Pro — pair with a Pro device and it carries over to here.1739 </p>1740 </Show>17411742 <Show when={pairedDevices().length > 0}>1743 <p class="peach-settings-sub-label">PAIRED DEVICES</p>1744 <For each={pairedDevices()}>1745 {(d) => (1746 <div class="peach-settings-row peach-settings-row--top">1747 <div class="peach-settings-row-main">1748 <Show1749 when={renameDeviceId() === d.deviceId}1750 fallback={<span>{d.localAlias ?? d.deviceName ?? "Unnamed device"}</span>}1751 >1752 <input1753 class="peach-settings-input"1754 aria-label={`Rename ${d.localAlias ?? d.deviceName ?? "device"}`}1755 maxlength={64}1756 value={renameDraft()}1757 onInput={(event) => setRenameDraft(event.currentTarget.value)}1758 onKeyDown={(event) => {1759 if (event.key === "Enter") void renameDevice(d.deviceId);1760 if (event.key === "Escape") setRenameDeviceId(null);1761 }}1762 />1763 </Show>1764 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1765 {deviceTypeLabel(d.deviceType)} · {syncRelativeTime(d.lastSyncedAt)}{isSyncStale(d) ? " · not seen recently" : ""}1766 </span>1767 </div>1768 <Show1769 when={confirmUnpair() === d.deviceId}1770 fallback={1771 <div style="display:flex;gap:4px;flex-shrink:0">1772 <Show1773 when={renameDeviceId() === d.deviceId}1774 fallback={1775 <button1776 class="peach-btn peach-btn--subtle peach-btn--sm"1777 onClick={() => {1778 setRenameDraft(d.localAlias ?? d.deviceName ?? "");1779 setRenameDeviceId(d.deviceId);1780 }}1781 >rename</button>1782 }1783 >1784 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => setRenameDeviceId(null)}>cancel</button>1785 <button class="peach-btn peach-btn--subtle peach-btn--sm" disabled={!renameDraft().trim()} onClick={() => void renameDevice(d.deviceId)}>save</button>1786 </Show>1787 <button1788 class="peach-btn peach-btn--subtle peach-btn--sm"1789 onClick={() => setConfirmUnpair(d.deviceId)}1790 >remove</button>1791 </div>1792 }1793 >1794 {17951796179717981799}1800 <div class="peach-unpair-confirm">1801 <p class="peach-unpair-confirm-note">1802 Stops this device receiving future changes. It doesn't1803 lock it out of what it already has — if it's lost or1804 stolen, also change your master password and generate1805 new recovery words.1806 </p>1807 <div style="display:flex;gap:6px;justify-content:flex-end">1808 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => setConfirmUnpair(null)}>cancel</button>1809 <button1810 class="peach-btn peach-btn--sm"1811 style="background:var(--peach-breach);border-color:var(--peach-breach)"1812 onClick={() => void unpairDevice(d.deviceId)}1813 >remove</button>1814 </div>1815 </div>1816 </Show>1817 </div>1818 )}1819 </For>1820 </Show>18211822 <Show when={pendingVouched().length > 0}>1823 <p class="peach-settings-sub-label">1824 PENDING DEVICES1825 <span class="peach-pro-badge" style="background:#C8A840;color:#1a1a1a">{pendingVouched().length}</span>1826 </p>1827 <For each={pendingVouched()}>1828 {(dev) => (1829 <div class="peach-settings-row peach-settings-row--top">1830 <div class="peach-settings-row-main">1831 <span>{dev.deviceName}</span>1832 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1833 Was added by another of your devices. Allow it to sync with this vault?1834 </span>1835 </div>1836 <div style="display:flex;gap:6px;flex-shrink:0">1837 <button1838 class="peach-btn peach-btn--sm"1839 disabled={vouchedBusy() === dev.deviceId}1840 onClick={() => void confirmVouched(dev.deviceId)}1841 >Allow</button>1842 <button1843 class="peach-btn peach-btn--subtle peach-btn--sm"1844 disabled={vouchedBusy() === dev.deviceId}1845 onClick={() => void rejectVouched(dev.deviceId)}1846 >Reject</button>1847 </div>1848 </div>1849 )}1850 </For>1851 </Show>18521853 <button1854 class="peach-settings-advanced-toggle peach-sync-disclosure-toggle"1855 onClick={() => setShowSyncConnection((value) => !value)}1856 aria-expanded={showSyncConnection()}1857 aria-controls="peach-sync-connection"1858 >1859 <span>1860 <span class="peach-settings-sub-label" style="margin:0">CONNECTION</span>1861 <span class="peach-sync-disclosure-summary">Relay and privacy choices</span>1862 </span>1863 <span class="peach-settings-advanced-chevron">1864 {showSyncConnection() ? "▲" : "▼"}1865 </span>1866 </button>1867 <Show when={showSyncConnection()}>1868 <div id="peach-sync-connection" class="peach-sync-disclosure-content">1869 <SyncModeSelector />1870 </div>1871 </Show>18721873 <button1874 class="peach-settings-advanced-toggle peach-sync-disclosure-toggle"1875 onClick={() => setShowSyncRecovery((value) => !value)}1876 aria-expanded={showSyncRecovery()}1877 aria-controls="peach-sync-recovery"1878 >1879 <span>1880 <span class="peach-settings-sub-label" style="margin:0">RECOVERY & SECURITY</span>1881 <span class="peach-sync-disclosure-summary">For a lost or stolen device</span>1882 </span>1883 <span class="peach-settings-advanced-chevron">1884 {showSyncRecovery() ? "▲" : "▼"}1885 </span>1886 </button>1887 <Show when={showSyncRecovery()}>1888 <div id="peach-sync-recovery" class="peach-sync-disclosure-content">1889 <Show1890 when={rotateConfirmOpen()}1891 fallback={1892 <div class="peach-settings-row peach-settings-row--top">1893 <div class="peach-settings-row-main">1894 <span>Rotate sync identity</span>1895 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1896 Disconnects every device and replaces the keys used for sync.1897 </span>1898 </div>1899 <button1900 class="peach-btn peach-btn--subtle peach-btn--sm"1901 style="color:var(--peach-breach);border-color:color-mix(in srgb,var(--peach-breach) 40%,transparent)"1902 onClick={() => { setRotateError(null); setRotateConfirmOpen(true); }}1903 >rotate</button>1904 </div>1905 }1906 >1907 <div class="peach-info-notice" style="margin-top:8px;border-color:color-mix(in srgb,var(--peach-breach) 40%,transparent)">1908 This disconnects every paired device and changes your 12 recovery words. You'll be shown the new words next.1909 <Show when={rotateError()}>1910 <div style="color:var(--peach-breach);margin-top:6px">{rotateError()}</div>1911 </Show>1912 <div style="display:flex;gap:6px;margin-top:10px">1913 <button1914 class="peach-btn peach-btn--subtle peach-btn--sm"1915 disabled={rotating()}1916 onClick={() => setRotateConfirmOpen(false)}1917 >Cancel</button>1918 <button1919 class="peach-btn peach-btn--sm"1920 style="background:var(--peach-breach);border-color:var(--peach-breach)"1921 disabled={rotating()}1922 onClick={() => void doRotateSyncIdentity()}1923 >{rotating() ? "Rotating…" : "Rotate sync identity"}</button>1924 </div>1925 </div>1926 </Show>1927 </div>1928 </Show>1929 </div>1930 </div>1931 );1932 }19331934 function renderAppearance() {1935 const sizeOptions: Array<{ id: "medium" | "large"; label: string }> = [1936 { id: "medium", label: "Default" },1937 { id: "large", label: "Large" },1938 ];1939 return (1940 <div class="peach-settings-body">1941 <div class="peach-settings-group">1942 <p class="peach-settings-sub-label">THEME</p>1943 <div class="peach-settings-row peach-settings-row--meta">1944 <span>Theme</span>1945 <button1946 class="peach-btn peach-btn--subtle peach-btn--sm"1947 onClick={() =>1948 props.onThemeChange(props.theme === "dark" ? "light" : "dark")1949 }1950 >1951 {props.theme === "dark" ? "Switch to light" : "Switch to dark"}1952 </button>1953 </div>19541955 <p class="peach-settings-sub-label">ACCENT</p>1956 <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">1957 <span>Accent</span>1958 <div class="peach-accent-picker">1959 <For each={ACCENT_COLORS}>1960 {(color) => (1961 <button1962 class="peach-accent-swatch peach-swatch"1963 classList={{ "is-active": props.accentColor === color.name }}1964 style={{ background: color.hex }}1965 onClick={() => void props.onAccentChange(color.name)}1966 aria-label={color.name}1967 title={color.name}1968 />1969 )}1970 </For>1971 </div>1972 </div>19731974 <p class="peach-settings-sub-label">TEXT SIZE</p>1975 <div class="peach-settings-row peach-settings-row--meta">1976 <span>Size</span>1977 <div class="peach-pill-group">1978 <For each={sizeOptions}>1979 {(opt) => (1980 <button1981 class="peach-btn peach-btn--ghost peach-btn--sm"1982 classList={{ "is-active": props.textSize === opt.id }}1983 onClick={() => void props.onTextSizeChange(opt.id)}1984 >1985 {opt.label}1986 </button>1987 )}1988 </For>1989 </div>1990 </div>19911992 <p class="peach-settings-sub-label">MOTION</p>1993 <div class="peach-settings-row peach-settings-row--meta">1994 <span>Reduce motion</span>1995 <button1996 class="peach-btn peach-btn--subtle peach-btn--sm"1997 onClick={() => void props.onReduceMotionChange(!props.reduceMotion)}1998 >1999 {props.reduceMotion ? "On" : "Off"}2000 </button>2001 </div>2002 </div>2003 </div>2004 );2005 }20062007 function renderShortcuts() {2008 const [capturingAction, setCapturingAction] = createSignal<ShortcutAction | null>(null);2009 const [conflictMsg, setConflictMsg] = createSignal<string | null>(null);20102011 function startCapture(action: ShortcutAction) {2012 setConflictMsg(null);2013 setCapturingAction(action);2014 }2015 function cancelCapture() {2016 setCapturingAction(null);2017 setConflictMsg(null);2018 }2019 function commitBinding(action: ShortcutAction, binding: Keybinding) {2020 const conflict = findConflict(props.shortcuts, binding, action);2021 if (conflict) {2022 const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict);2023 setConflictMsg(`Already bound to "${other?.label ?? conflict}"`);2024 return;2025 }2026 const next = { ...props.shortcuts, [action]: binding };2027 setCapturingAction(null);2028 setConflictMsg(null);2029 void props.onShortcutsChange(next);2030 }2031 function resetAction(action: ShortcutAction) {2032 const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] };2033 void props.onShortcutsChange(next);2034 }2035 function resetAll() {2036 void props.onShortcutsChange({ ...DEFAULT_BINDINGS });2037 }20382039 2040 2041 function handleCaptureKey(e: KeyboardEvent) {2042 e.preventDefault();2043 e.stopPropagation();2044 if (e.key === "Escape") { cancelCapture(); return; }2045 const binding = keybindingFromEvent(e);2046 if (!binding) return; 2047 const action = capturingAction();2048 if (action) commitBinding(action, binding);2049 }20502051 return (2052 <div class="peach-settings-body">2053 <div class="peach-settings-group peach-shortcuts-group">2054 <p class="peach-settings-sub-label">ENTRY VIEW</p>2055 <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry")}>2056 {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}2057 </For>2058 </div>20592060 <div class="peach-settings-group peach-shortcuts-group">2061 <p class="peach-settings-sub-label">ANYWHERE IN POPUP</p>2062 <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global")}>2063 {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}2064 </For>2065 </div>20662067 <Show when={conflictMsg()}>2068 <div class="peach-shortcuts-conflict">{conflictMsg()}</div>2069 </Show>20702071 <div class="peach-settings-group" style="display:flex;flex-direction:column;gap:8px">2072 <div class="peach-settings-row peach-settings-row--meta">2073 <span>Inline autofill on page</span>2074 <a2075 class="peach-settings-link"2076 href="chrome://extensions/shortcuts"2077 onClick={(e) => {2078 e.preventDefault();2079 chrome.tabs.create({ url: "chrome://extensions/shortcuts" });2080 }}2081 >2082 ⌘⇧P — customize ↗2083 </a>2084 </div>2085 <button2086 class="peach-btn peach-btn--subtle peach-btn--sm"2087 style="align-self:flex-start"2088 onClick={resetAll}2089 >2090 Reset all to defaults2091 </button>2092 </div>2093 </div>2094 );20952096 function renderRow(2097 desc: typeof SHORTCUT_DESCRIPTORS[number],2098 capturing: () => ShortcutAction | null,2099 onStart: (a: ShortcutAction) => void,2100 onCancel: () => void,2101 onReset: (a: ShortcutAction) => void,2102 onKey: (e: KeyboardEvent) => void,2103 ) {2104 const isActive = () => capturing() === desc.action;2105 const binding = () => props.shortcuts[desc.action];2106 const isCustom = () => {2107 const b = binding();2108 const d = DEFAULT_BINDINGS[desc.action];2109 return b.key !== d.key2110 || Boolean(b.ctrl) !== Boolean(d.ctrl)2111 || Boolean(b.meta) !== Boolean(d.meta)2112 || Boolean(b.shift) !== Boolean(d.shift)2113 || Boolean(b.alt) !== Boolean(d.alt);2114 };2115 return (2116 <div class="peach-shortcut-row">2117 <span class="peach-shortcut-row-label">{desc.label}</span>2118 <button2119 class="peach-shortcut-chip"2120 classList={{ "is-capturing": isActive(), "is-custom": isCustom() }}2121 onClick={() => isActive() ? onCancel() : onStart(desc.action)}2122 onKeyDown={(e) => { if (isActive()) onKey(e); }}2123 ref={(el) => {2124 2125 if (el) {2126 queueMicrotask(() => { if (isActive()) el.focus(); });2127 }2128 }}2129 >2130 {isActive() ? "Press a key…" : formatKeybinding(binding())}2131 </button>2132 <Show when={isCustom() && !isActive()}>2133 <button2134 class="peach-shortcut-reset"2135 title="Reset to default"2136 onClick={() => onReset(desc.action)}2137 >2138 ↺2139 </button>2140 </Show>2141 </div>2142 );2143 }2144 }21452146 const PRO_FEATURES: Array<{2147 feature: ProFeature;2148 name: string;2149 desc: string;2150 }> = [2151 { feature: "sync", name: "Peach Sync", desc: "Encrypted · peer-to-peer" },2152 { feature: "codex", name: "Peach Codex", desc: "Restore from paper" },2153 { feature: "passkeys", name: "Passkeys", desc: "Zero-knowledge WebAuthn" },2154 { feature: "advanced_health", name: "Advanced Vault Health", desc: "Reuse · age · exposure" },2155 ];21562157 const PRO_SECURITY_FEATURES: Array<{2158 feature: ProFeature;2159 name: string;2160 desc: string;2161 }> = [2162 { feature: "canary_entry", name: "Canary entries", desc: "Detects vault compromise" },2163 { feature: "duress_vault", name: "Duress vault", desc: "Decoy under coercion" },2164 ];21652166 const [licenseKey, setLicenseKey] = createSignal<string | null>(null);2167 const [showKey, setShowKey] = createSignal(false);21682169 const [deactivating, setDeactivating] = createSignal(false);21702171 async function handleDeactivateLicense() {2172 setDeactivating(true);2173 await sendMessage({ type: "DEACTIVATE_LICENSE" });2174 setLicenseInfo(null);2175 await props.onLicenseChange?.();2176 setDeactivating(false);2177 }21782179 async function handleTestCanary(entry: {id: string; title: string; canary: Canary}) {2180 setCanaryTestingId(entry.id);2181 setCanaryTestResult(null);2182 const res = await sendMessage({2183 type: "FIRE_CANARY_PING",2184 payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger: "test" },2185 }) as { ok: boolean };2186 setCanaryTestResult({ id: entry.id, ok: res.ok });2187 setCanaryTestingId(null);2188 setTimeout(() => setCanaryTestResult((r) => r?.id === entry.id ? null : r), 3000);2189 }21902191 function renderLicense() {2192 async function onActivate() {2193 await handleActivateLicense();2194 if ((licenseInfo()?.valid ?? false) && licenseInfo()?.plan === "pro") {2195 await props.onLicenseChange?.();2196 }2197 }21982199 return (2200 <div class="peach-settings-body">2201 <Show2202 when={isPro()}2203 fallback={2204 <div class="peach-pro-upgrade">2205 <p class="peach-pro-eyebrow">PEACH PRO · ONE-TIME</p>22062207 <h2 class="peach-pro-headline">2208 Every<em>thing,</em><br />every<em>where.</em>2209 </h2>22102211 <p class="peach-pro-subtitle">2212 One key unlocks every Pro feature — for the lifetime of the app.2213 </p>22142215 <div class="peach-pro-grid">2216 <For each={PRO_FEATURES}>2217 {(item, i) => (2218 <div2219 class="peach-pro-grid-item"2220 classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}2221 >2222 <span class="peach-pro-grid-num">{String(i() + 1).padStart(2, "0")}</span>2223 <span class="peach-pro-grid-name">{item.name}</span>2224 <span class="peach-pro-grid-desc">{item.desc}</span>2225 </div>2226 )}2227 </For>2228 </div>22292230 <p class="peach-pro-section-label">ADVANCED SECURITY</p>2231 <div class="peach-pro-grid">2232 <For each={PRO_SECURITY_FEATURES}>2233 {(item, i) => (2234 <div2235 class="peach-pro-grid-item"2236 classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}2237 >2238 <span class="peach-pro-grid-num">{String(PRO_FEATURES.length + i() + 1).padStart(2, "0")}</span>2239 <span class="peach-pro-grid-name">{item.name}</span>2240 <span class="peach-pro-grid-desc">{item.desc}</span>2241 </div>2242 )}2243 </For>2244 </div>22452246 <div class="peach-pro-upgrade-actions">2247 <p class="peach-pro-pricing">2248 $39 <span class="peach-pro-pricing-sub">once · yours forever</span>2249 </p>2250 <a2251 href="https://buy.polar.sh/polar_cl_IXSFIPojx67rcOTksn8LkH5mnU3osPHtYqBIl1eyuIz"2252 target="_blank"2253 rel="noopener noreferrer"2254 class="peach-pro-upgrade-cta"2255 >2256 Get Peach Pro2257 </a>2258 <div class="peach-pro-key-divider"><span>ALREADY HAVE A KEY</span></div>2259 <div class="peach-pro-key-inline">2260 <input2261 class="peach-pro-key-inline-input"2262 type="text"2263 placeholder="PEACH-XXXX-XXXX-XXXX"2264 value={licenseKeyInput()}2265 onInput={(e) => setLicenseKeyInput(e.currentTarget.value)}2266 onKeyDown={(e) => { if (e.key === "Enter") void onActivate(); }}2267 autocomplete="off"2268 spellcheck={false}2269 />2270 <button2271 class="peach-pro-key-apply"2272 onClick={() => void onActivate()}2273 disabled={activatingLicense() || !licenseKeyInput().trim()}2274 >2275 {activatingLicense() ? "…" : "APPLY"}2276 </button>2277 </div>2278 <Show when={licenseError()}>2279 <p class="peach-pro-key-error">{licenseError()}</p>2280 </Show>2281 </div>2282 </div>2283 }2284 >2285 <div class="peach-pro-active">2286 <div class="peach-pro-active-hero">2287 <button2288 class="peach-pro-active-mark-wrap"2289 type="button"2290 aria-label="Celebrate"2291 onClick={handleProCelebrationClick}2292 >2293 <PeachMark class="peach-pro-active-mark" />2294 <Show when={justActivated()}>2295 <div class="peach-pro-sparks" aria-hidden="true">2296 <span class="peach-pro-spark" style="--spark-x:0px;--spark-y:-54px;--spark-delay:0ms">✦</span>2297 <span class="peach-pro-spark" style="--spark-x:38px;--spark-y:-38px;--spark-delay:50ms">✦</span>2298 <span class="peach-pro-spark" style="--spark-x:54px;--spark-y:0px;--spark-delay:110ms">·</span>2299 <span class="peach-pro-spark" style="--spark-x:38px;--spark-y:38px;--spark-delay:30ms">✦</span>2300 <span class="peach-pro-spark" style="--spark-x:0px;--spark-y:54px;--spark-delay:80ms">·</span>2301 <span class="peach-pro-spark" style="--spark-x:-38px;--spark-y:38px;--spark-delay:130ms">✦</span>2302 <span class="peach-pro-spark" style="--spark-x:-54px;--spark-y:0px;--spark-delay:55ms">·</span>2303 <span class="peach-pro-spark" style="--spark-x:-38px;--spark-y:-38px;--spark-delay:95ms">✦</span>2304 </div>2305 </Show>2306 </button>2307 <h2 class="peach-pro-active-title">Peach Pro</h2>2308 <p class="peach-pro-active-since">{formatActivatedSince(licenseInfo()?.issued)}</p>2309 <Show when={justActivated()}>2310 <p class="peach-pro-thankyou-text" aria-live="polite">thank you ✦</p>2311 </Show>2312 </div>23132314 <Show when={licenseKey()}>2315 <div class="peach-settings-license">2316 <span class="peach-settings-label">LICENSE KEY</span>2317 <div class="peach-settings-license-row">2318 <Show2319 when={showKey()}2320 fallback={2321 <span class="peach-settings-license-key peach-settings-license-key--masked" aria-label="License key hidden">2322 ••••••••••••••••••••••••••••2323 </span>2324 }2325 >2326 <span class="peach-settings-license-key">{licenseKey()!}</span>2327 </Show>2328 <div class="peach-settings-license-actions">2329 <button2330 class="peach-settings-license-copy"2331 title="Copy license key"2332 onClick={() => copyLicenseKey(licenseKey()!)}2333 >2334 {copied() ? "✓" : "copy"}2335 </button>2336 <button2337 class="peach-settings-license-toggle"2338 aria-label={showKey() ? "Hide license key" : "Reveal license key"}2339 title={showKey() ? "Hide" : "Reveal"}2340 onClick={() => setShowKey(v => !v)}2341 >2342 <Show2343 when={showKey()}2344 fallback={2345 <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">2346 <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />2347 <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />2348 </svg>2349 }2350 >2351 <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">2352 <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />2353 <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />2354 <line x1="2" y1="2" x2="12" y2="12" />2355 </svg>2356 </Show>2357 </button>2358 </div>2359 </div>2360 </div>2361 </Show>23622363 <div class="peach-pro-active-features">2364 <For each={[...PRO_FEATURES, ...PRO_SECURITY_FEATURES]}>2365 {(item) => (2366 <div class="peach-pro-active-feature">2367 <span class="peach-pro-active-feature-mark">✦</span>2368 <span class="peach-pro-active-feature-name">{item.name}</span>2369 </div>2370 )}2371 </For>2372 </div>23732374 <button2375 class="peach-pro-deactivate"2376 onClick={() => void handleDeactivateLicense()}2377 disabled={deactivating()}2378 >2379 {deactivating() ? "Deactivating…" : "Deactivate"}2380 </button>2381 </div>2382 </Show>2383 </div>2384 );2385 }23862387 function renderFeedback() {2388 return (2389 <div class="peach-settings-body">2390 <div class="peach-settings-group peach-feedback-group">2391 <Show2392 when={feedbackState() === 'sent'}2393 fallback={2394 <>2395 <p class="peach-feedback-intro">2396 Help us improve Peach. Every report goes directly to our inbox.2397 </p>23982399 <p class="peach-settings-sub-label">TYPE</p>2400 <div class="peach-feedback-chips">2401 <For each={FEEDBACK_CATEGORIES}>2402 {(cat) => {2403 const selected = () => fbCategory() === cat;2404 return (2405 <button2406 type="button"2407 class={`peach-feedback-chip ${selected() ? "is-active" : ""}`}2408 aria-pressed={selected()}2409 onClick={() => setFbCategory(cat)}2410 >2411 {cat}2412 </button>2413 );2414 }}2415 </For>2416 </div>24172418 <p class="peach-settings-sub-label">MESSAGE</p>2419 <textarea2420 class="peach-feedback-textarea"2421 placeholder="Describe the issue or idea..."2422 maxLength={MAX_FEEDBACK_MESSAGE_LENGTH}2423 value={fbMessage()}2424 onInput={(e) => setFbMessage(e.currentTarget.value)}2425 />24262427 <p class="peach-feedback-meta">2428 Version {version} · will be included automatically2429 </p>24302431 <button2432 type="button"2433 class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"2434 disabled={!fbMessage().trim() || feedbackState() === 'sending'}2435 onClick={() => void handleSubmitFeedback()}2436 >2437 <Switch>2438 <Match when={feedbackState() === 'idle'}>Send</Match>2439 <Match when={feedbackState() === 'sending'}>Sending...</Match>2440 <Match when={feedbackState() === 'error'}>Failed - try again</Match>2441 </Switch>2442 </button>24432444 <p class="peach-feedback-anon">2445 Sent anonymously · no account required2446 </p>2447 </>2448 }2449 >2450 <div class="peach-feedback-thanks" role="status" aria-live="polite">2451 <p class="peach-feedback-thanks-title">Thank you</p>2452 <p>2453 Your note is in the queue. Someone will process it soon.2454 </p>2455 </div>2456 </Show>2457 </div>2458 </div>2459 );2460 }24612462 const bodies: StackBodies = {2463 protection: renderSecurity(),2464 vault: renderVault(),2465 sync: renderSync(),2466 appearance: renderAppearance(),2467 shortcuts: renderShortcuts(),2468 pro: renderLicense(),2469 feedback: renderFeedback(),2470 };24712472 return (2473 <Show2474 when={showNewRecoveryWords()}2475 fallback={2476 <div2477 class="peach-overlay-view peach-settings-overlay"2478 classList={{ "ov-enter-right": !props.isExiting, "ov-exit-right": !!props.isExiting }}2479 style="display:flex;flex-direction:column"2480 >2481 <StackDeck2482 active={activeCard()}2483 onActiveChange={setActiveCard}2484 onBack={props.onBack}2485 cards={cards()}2486 bodies={bodies}2487 version={version}2488 isFanning={isFanning()}2489 celebratingCardId={justActivated() ? "pro" : null}2490 isPro={isPro()}2491 />2492 </div>2493 }2494 >2495 <RecoveryWordDisplayView onDone={() => setShowNewRecoveryWords(false)} />2496 </Show>2497 );2498}