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 type { SyncGeneration } from "../../background/sync-generation";26import {27 DEFAULT_BINDINGS,28 findConflict,29 formatKeybinding,30 keybindingFromEvent,31 SHORTCUT_DESCRIPTORS,32 type Keybinding,33 type ShortcutAction,34} from "../../shared/shortcuts";35import { WEB3FORMS_KEY } from "../../shared/config";3637interface LicenseInfo {38 valid: boolean;39 plan: "free" | "pro";40 issued?: string;41}4243interface SettingsProps {44 entriesCount: number;45 breachMonitoring: boolean;46 lockOnClose: boolean;47 autoLockMinutes: number;48 trashRetentionDays: number;49 theme: "dark" | "light";50 accentColor: AccentColorName;51 textSize: "small" | "medium" | "large";52 reduceMotion: boolean;53 isPro?: boolean;54 initialSection?: string | null;55 upgradeHighlight?: ProFeature | null;56 onBreachMonitoringChange: (enabled: boolean) => void | Promise<void>;57 onLockOnCloseChange: (enabled: boolean) => void | Promise<void>;58 onAutoLockMinutesChange: (minutes: number) => void | Promise<void>;59 onTrashRetentionChange: (days: number) => void | Promise<void>;60 onThemeChange: (theme: "dark" | "light") => void;61 onAccentChange: (name: AccentColorName) => void | Promise<void>;62 onTextSizeChange: (size: "small" | "medium" | "large") => void | Promise<void>;63 onReduceMotionChange: (enabled: boolean) => void | Promise<void>;64 shortcuts: Record<ShortcutAction, Keybinding>;65 onShortcutsChange: (active: Record<ShortcutAction, Keybinding>) => void | Promise<void>;66 onLicenseChange?: () => void | Promise<void>;67 isExiting?: boolean;68 onBack: () => void;69 onImport: () => void;70 onExport: () => void;71 onBreachCheckDone: () => void;72 onOpenHealth?: () => void;73 onOpenSyncPairing?: (method?: "code") => void;74 onOpenFileImport?: () => void;75 onOpenPanic?: () => void;76 onOpenCodexGenerate?: () => void;77 onOpenCodexRestore?: () => void;78 798081 onProGate?: (feature: ProFeature) => void;82 onCardChange?: (id: CardId) => void;83}8485const FEEDBACK_CATEGORIES = ["Bug report", "Feature request", "Question", "Other"];86const MAX_FEEDBACK_MESSAGE_LENGTH = 4000;87const PRO_CELEBRATION_CARD_STEP_MS = 150;88const PRO_CELEBRATION_THANK_YOU_MS = 3200;8990function sanitizeFeedbackMessage(value: string): string {91 return value92 .trim()93 .slice(0, MAX_FEEDBACK_MESSAGE_LENGTH)94 95 .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "")96 .replace(/&/g, "&")97 .replace(/</g, "<")98 .replace(/>/g, ">")99 .replace(/"/g, """)100 .replace(/'/g, "'");101}102103function formatActivatedSince(issued: string | undefined): string {104 if (!issued) return "Active";105 try {106 const d = new Date(issued);107 if (Number.isNaN(d.getTime())) return "Active";108 return `Active since ${d.toLocaleDateString(undefined, { month: "long", year: "numeric" })}`;109 } catch {110 return "Active";111 }112}113114function initialSectionToCard(value: string | null | undefined): CardId | null {115 if (!value) return null;116 if (value === "security") return "protection";117 if (value === "license") return "pro";118 if (119 value === "protection" ||120 value === "vault" ||121 value === "sync" ||122 value === "appearance" ||123 value === "shortcuts" ||124 value === "pro" ||125 value === "feedback"126 ) {127 return value;128 }129 return null;130}131132133134135136const SECURE_RESET_DISCLOSURE =137 "Peach will archive this device's old Sync trust and start the new Sync generation. " +138 "Every other device must be updated first, then re-paired with this one — they will " +139 "not continue on their own. Your vault and your recovery words are not changed.";140141function secureResetHeadline(state: string | undefined): string {142 switch (state) {143 case "LEGACY_ACTIVE":144 return "Legacy Sync generation";145 case "RESET_REQUIRED":146 return "Reset required";147 case "RESET_CONFIRMED":148 return "Reset not completed";149 case "V2_ANCHOR":150 case "REPAIRING":151 return "Not re-paired";152 case "V2_ACTIVE":153 return "New Sync generation";154 case "UNKNOWN":155 return "Sync generation unreadable";156 default:157 return "Sync generation";158 }159}160161function secureResetDetail(state: string | undefined): string {162 switch (state) {163 case "LEGACY_ACTIVE":164 return "This device still holds the old Sync trust. Resetting retires it for good: update Peach on every other device, then re-pair each one.";165 case "RESET_REQUIRED":166 return "Sync is stopped until the reset completes. Update Peach on every other device, then reset this one and re-pair each device.";167 case "RESET_CONFIRMED":168 return "The reset did not finish. Nothing was lost — the old trust is archived. Try again.";169 case "V2_ANCHOR":170 case "REPAIRING":171 return "This device anchors the new Sync generation and is not re-paired yet. Pair each device again; until then it keeps its own vault and cannot sync.";172 case "V2_ACTIVE":173 return "This device is on the new Sync generation. Each device is re-paired individually.";174 case "UNKNOWN":175 return "This device's Sync generation can't be read, so sync is stopped. Reinstall or contact support.";176 default:177 return "Sync state is unavailable.";178 }179}180181export function SettingsView(props: SettingsProps) {182 const manifest = chrome.runtime.getManifest();183 const version = manifest.version;184 const initial = props.initialSection;185 const [activeCard, setActiveCard] = createSignal<CardId>(186 props.upgradeHighlight ? "pro" : initialSectionToCard(initial) ?? "pro"187 );188189 createEffect(() => { props.onCardChange?.(activeCard()); });190191 const [justActivated, setJustActivated] = createSignal(false);192 const [isFanning, setIsFanning] = createSignal(false);193194 function triggerCelebration() {195 if (isFanning()) return;196 setJustActivated(false);197 setIsFanning(true);198 setActiveCard("pro");199 const fanOrder: CardId[] = ["protection", "vault", "sync", "appearance", "shortcuts", "feedback"];200 fanOrder.forEach((id, i) => {201 window.setTimeout(() => setActiveCard(id), (i + 1) * PRO_CELEBRATION_CARD_STEP_MS);202 });203 window.setTimeout(() => {204 setActiveCard("pro");205 setIsFanning(false);206 setJustActivated(true);207 window.setTimeout(() => setJustActivated(false), PRO_CELEBRATION_THANK_YOU_MS);208 }, (fanOrder.length + 1) * PRO_CELEBRATION_CARD_STEP_MS);209 }210211 function handleProCelebrationClick(e: MouseEvent) {212 e.stopPropagation();213 triggerCelebration();214 }215216 const [checkingBreaches, setCheckingBreaches] = createSignal(false);217 const [breachResult, setBreachResult] = createSignal<string | null>(null);218 const [licenseInfo, setLicenseInfo] = createSignal<LicenseInfo | null>(null);219 const [licenseKeyInput, setLicenseKeyInput] = createSignal("");220 const [licenseError, setLicenseError] = createSignal<string | null>(null);221 const [, setLicenseSuccess] = createSignal<string | null>(null);222 const [activatingLicense, setActivatingLicense] = createSignal(false);223224 const isPro = () => props.isPro ?? false;225 const cards = createMemo<StackCardSpec[]>(() => [226 {227 id: "pro",228 num: "I · FOREVER",229 title: "Peach Pro",230 lede: isPro() ? "active · paid once, yours forever" : "$39 once · yours forever",231 bgVar: "--stack-pro-bg",232 fgVar: "--stack-pro-fg",233 icon: "spark",234 onIconClick: isPro() ? triggerCelebration : undefined,235 },236 {237 id: "protection",238 num: "II · PROTECT",239 title: "Protection",240 lede: `${props.breachMonitoring ? "breaches watched" : "breach watch off"} · ${props.lockOnClose ? "locks on close" : "manual lock"}`,241 bgVar: "--stack-protect-bg",242 fgVar: "--stack-protect-fg",243 icon: "shield",244 },245 {246 id: "vault",247 num: "III · KEEP",248 title: "Vault",249 lede: `${props.entriesCount} ${props.entriesCount === 1 ? "entry" : "entries"} · backups, history, cleanup`,250 bgVar: "--stack-vault-bg",251 fgVar: "--stack-vault-fg",252 icon: "lock",253 },254 {255 id: "sync",256 num: "IV · ECHO",257 title: "Sync",258 lede: isPro() ? "your devices, one vault" : "Pro · encrypted device sync",259 bgVar: "--stack-sync-bg",260 fgVar: "--stack-sync-fg",261 icon: "sync",262 },263 {264 id: "appearance",265 num: "V · DRESS",266 title: "Appearance",267 lede: `${props.theme} · ${props.accentColor} · ${props.textSize}`,268 bgVar: "--stack-appear-bg",269 fgVar: "--stack-appear-fg",270 icon: "sun",271 },272 {273 id: "shortcuts",274 num: "VI · KEYS",275 title: "Shortcuts",276 lede: `${SHORTCUT_DESCRIPTORS.length} bindings · what your fingers know`,277 bgVar: "--stack-shortcut-bg",278 fgVar: "--stack-shortcut-fg",279 icon: "keys",280 },281 {282 id: "feedback",283 num: "VII · ECHO",284 title: "Feedback",285 lede: `version ${version} · tell us what would make it better`,286 bgVar: "--stack-feedback-bg",287 fgVar: "--stack-feedback-fg",288 icon: "bubble",289 },290 ]);291 const [passkeyState, setPasskeyState] = createSignal<PasskeyProviderState | null>(null);292 const [passkeySaving, setPasskeySaving] = createSignal(false);293294 295 296 interface PendingVouched {297 deviceId: string;298 deviceName: string;299 longTermPublicKeyHex: string;300 receivedAt: number;301 vouchedByDeviceId: string;302 }303 const [pendingVouched, setPendingVouched] = createSignal<PendingVouched[]>([]);304 const [vouchedBusy, setVouchedBusy] = createSignal<string | null>(null);305 306 const [pairedDevices, setPairedDevices] = createSignal<PairedDevice[]>([]);307 const [confirmUnpair, setConfirmUnpair] = createSignal<string | null>(null);308 const [renameDeviceId, setRenameDeviceId] = createSignal<string | null>(null);309 const [renameDraft, setRenameDraft] = createSignal("");310 const [syncStatus, setSyncStatus] = createSignal<"idle" | "syncing" | "error">("idle");311 const [syncFlash, setSyncFlash] = createSignal<string | null>(null);312 313 314 const [syncSummary, setSyncSummary] = createSignal<SyncOutcomeSummary>({});315 316 317 const [generation, setGeneration] = createSignal<SyncGeneration | null>(null);318 const [resetOpen, setResetOpen] = createSignal(false);319 const [resetPassword, setResetPassword] = createSignal("");320 const [resetError, setResetError] = createSignal<string | null>(null);321 const [resetting, setResetting] = createSignal(false);322 const syncLight = () =>323 syncStatus() === "syncing"324 ? "busy"325 : syncIndicator(syncSummary(), pairedDevices().length);326 const syncLightLabel = () => syncIndicatorLabel(syncLight(), syncSummary());327 const lastSyncLine = () => {328 const s = syncSummary();329 if (!s.outcome) return "No sync attempted yet";330 return `${syncRelativeTime(s.finishedAt)} · ${describeSyncOutcome(s)}`;331 };332 async function loadSyncState() {333 const r = (await sendMessage({ type: "GET_SYNC_STATE" })) as {334 ok: boolean;335 data?: SyncOutcomeSummary;336 };337 if (r.ok && r.data) {338 setSyncSummary(r.data);339 setSyncStatus(r.data.status ?? "idle");340 }341 }342343 async function loadGeneration() {344 const r = (await sendMessage({ type: "GET_SYNC_GENERATION" })) as {345 ok: boolean;346 data?: SyncGeneration | null;347 };348 if (r.ok) setGeneration(r.data ?? null);349 }350351 async function runSecureReset() {352 setResetting(true);353 const r = (await sendMessage({354 type: "SECURE_RESET",355 payload: { password: resetPassword() },356 })) as { ok: boolean; data?: SyncGeneration | null; error?: string };357 setResetting(false);358 setResetPassword("");359 if (!r.ok) {360 setResetError(r.error ?? "The reset did not complete — nothing was lost, try again");361 await loadGeneration();362 return;363 }364 setResetError(null);365 setResetOpen(false);366 if (r.data) setGeneration(r.data);367 }368369 const STALE_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000;370 function syncRelativeTime(when: number | undefined): string {371 if (!when) return "Not synced yet";372 const elapsed = Date.now() - when;373 if (elapsed < 60_000) return "Just now";374 const minutes = Math.floor(elapsed / 60_000);375 if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"} ago`;376 const hours = Math.floor(minutes / 60);377 if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;378 const days = Math.floor(hours / 24);379 return days < 7 ? `${days} day${days === 1 ? "" : "s"} ago` : "Not seen recently";380 }381 function isSyncStale(d: PairedDevice): boolean {382 return d.lastSyncedAt !== undefined && Date.now() - d.lastSyncedAt > STALE_THRESHOLD_MS;383 }384 function deviceTypeLabel(t: PairedDevice["deviceType"]): string {385 switch (t) {386 case "android": return "Android";387 case "ios": return "iPhone";388 case "extension": return "Browser";389 default: return "Device";390 }391 }392393 async function loadPairedDevices() {394 if (!isPro()) return;395 const r = (await sendMessage({ type: "LIST_PAIRED_DEVICES" })) as {396 ok: boolean;397 data?: { devices: PairedDevice[] };398 };399 if (r.ok) setPairedDevices(r.data?.devices ?? []);400 }401 async function unpairDevice(deviceId: string) {402 await sendMessage({ type: "UNPAIR_DEVICE", payload: { deviceId } });403 setConfirmUnpair(null);404 await loadPairedDevices();405 }406 async function renameDevice(deviceId: string) {407 const name = renameDraft().trim();408 if (!name) return;409 const result = (await sendMessage({410 type: "RENAME_PAIRED_DEVICE",411 payload: { deviceId, name },412 })) as { ok: boolean };413 if (!result.ok) return;414 setRenameDeviceId(null);415 await loadPairedDevices();416 }417 async function syncNow() {418 setSyncStatus("syncing");419 setSyncFlash(null);420 const res = (await sendMessage({421 type: "TRIGGER_SYNC",422 payload: { reason: "manual", force: true },423 })) as { ok: boolean; error?: string };424 if (!res.ok) {425 setSyncStatus("error");426 setSyncSummary({ status: "error", outcome: "run_failed", detail: res.error });427 return;428 }429 430 431 432 let s: SyncOutcomeSummary = { status: "syncing" };433 for (let i = 0; i < 40 && s.status === "syncing"; i++) {434 await new Promise((r) => setTimeout(r, 250));435 const state = (await sendMessage({ type: "GET_SYNC_STATE" })) as {436 ok: boolean;437 data?: SyncOutcomeSummary;438 };439 s = state.ok ? state.data ?? { status: "idle" } : { status: "error", outcome: "run_failed" };440 }441 setSyncSummary(s);442 setSyncStatus(s.status ?? "idle");443 444 445 setSyncFlash(describeSyncOutcome(s));446 setTimeout(() => setSyncFlash(null), 4000);447 await loadPairedDevices();448 }449 450 const [rotateConfirmOpen, setRotateConfirmOpen] = createSignal(false);451 const [rotating, setRotating] = createSignal(false);452 const [rotateError, setRotateError] = createSignal<string | null>(null);453 const [showSyncConnection, setShowSyncConnection] = createSignal(false);454 const [showSyncRecovery, setShowSyncRecovery] = createSignal(false);455456 async function loadPendingVouched() {457 const r = (await sendMessage({ type: "GET_PENDING_VOUCHED_DEVICES" })) as {458 ok: boolean;459 data?: PendingVouched[];460 };461 if (r.ok) setPendingVouched(r.data ?? []);462 }463 async function confirmVouched(deviceId: string) {464 setVouchedBusy(deviceId);465 try {466 await sendMessage({ type: "CONFIRM_VOUCHED_DEVICE", payload: { deviceId } });467 await loadPendingVouched();468 } finally {469 setVouchedBusy(null);470 }471 }472 async function rejectVouched(deviceId: string) {473 setVouchedBusy(deviceId);474 try {475 await sendMessage({ type: "REJECT_VOUCHED_DEVICE", payload: { deviceId } });476 await loadPendingVouched();477 } finally {478 setVouchedBusy(null);479 }480 }481 async function doRotateSyncIdentity() {482 setRotateError(null);483 setRotating(true);484 try {485 const r = (await sendMessage({ type: "ROTATE_SYNC_IDENTITY" })) as {486 ok: boolean;487 error?: string;488 };489 if (!r.ok) {490 setRotateError(r.error ?? "Rotation failed.");491 return;492 }493 setRotateConfirmOpen(false);494 495 496 setShowNewRecoveryWords(true);497 } finally {498 setRotating(false);499 }500 }501502 const [dedupCount, setDedupCount] = createSignal<number | null>(null);503 const [dedupRunning, setDedupRunning] = createSignal(false);504 const [dedupDone, setDedupDone] = createSignal<number | null>(null);505506 const [declinedCardCount, setDeclinedCardCount] = createSignal(0);507508 509 510 511 const [historySnapshots, setHistorySnapshots] = createSignal<VaultSnapshot[]>([]);512 const [historyLoading, setHistoryLoading] = createSignal(false);513 const [historyRestoring, setHistoryRestoring] = createSignal<string | null>(null);514 const [historyShowAll, setHistoryShowAll] = createSignal(false);515516 async function loadVaultHistory() {517 setHistoryLoading(true);518 try {519 const res = await sendMessage({ type: "GET_VAULT_HISTORY" });520 const list = (res as { ok: boolean; data?: VaultSnapshot[] }).data ?? [];521 setHistorySnapshots(list);522 } finally {523 setHistoryLoading(false);524 }525 }526527 async function handleRestoreVaultSnapshot(id: string) {528 if (historyRestoring()) return;529 setHistoryRestoring(id);530 const res = await sendMessage({531 type: "RESTORE_VAULT_SNAPSHOT",532 payload: { snapshotId: id },533 });534 setHistoryRestoring(null);535 if ((res as { ok: boolean }).ok) {536 537 538 props.onBack();539 }540 }541542 function formatSnapshotTime(savedAt: number): string {543 const diff = Date.now() - savedAt;544 if (diff < 60_000) return "Just now";545 if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;546 const d = new Date(savedAt);547 const isToday = new Date().toDateString() === d.toDateString();548 const isYest =549 new Date(Date.now() - 86_400_000).toDateString() === d.toDateString();550 const t = d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });551 if (isToday) return `Today ${t}`;552 if (isYest) return `Yesterday ${t}`;553 return `${d.toLocaleDateString([], { month: "short", day: "numeric" })} ${t}`;554 }555556 function reasonLabel(reason: VaultSnapshot["reason"]): string {557 return (558 {559 local_save: "saved",560 sync_received: "synced",561 import: "imported",562 restore: "restored",563 } as const564 )[reason] ?? reason;565 }566567 const [showVaultAdvanced, setShowVaultAdvanced] = createSignal(false);568 const [showWhatsNew, setShowWhatsNew] = createSignal(false);569570 const [showChangePw, setShowChangePw] = createSignal(false);571 const [changePwCurrent, setChangePwCurrent] = createSignal("");572 const [changePwNew, setChangePwNew] = createSignal("");573 const [changePwConfirm, setChangePwConfirm] = createSignal("");574 const [changePwSaving, setChangePwSaving] = createSignal(false);575 const [changePwError, setChangePwError] = createSignal<string | null>(null);576 const [changePwSuccess] = createSignal(false);577 const [showNewRecoveryWords, setShowNewRecoveryWords] = createSignal(false);578579 const [pinEnabled, setPinEnabled] = createSignal(false);580 const [pinFlow, setPinFlow] = createSignal<"setup" | "disable" | null>(null);581 const [pinCurrentPassword, setPinCurrentPassword] = createSignal("");582 const [pinValue, setPinValue] = createSignal("");583 const [pinConfirm, setPinConfirm] = createSignal("");584 const [pinSaving, setPinSaving] = createSignal(false);585 const [pinError, setPinError] = createSignal<string | null>(null);586587 588 const [showViewWords, setShowViewWords] = createSignal(false);589 const [viewWordsPassword, setViewWordsPassword] = createSignal("");590 const [viewWordsError, setViewWordsError] = createSignal<string | null>(null);591 const [viewedWords, setViewedWords] = createSignal<string[] | null>(null);592 const [viewWordsBusy, setViewWordsBusy] = createSignal(false);593594 async function handleViewRecoveryWords() {595 setViewWordsError(null);596 if (!viewWordsPassword()) {597 setViewWordsError("Enter your master password.");598 return;599 }600 setViewWordsBusy(true);601 try {602 const r = (await sendMessage({603 type: "GET_RECOVERY_WORDS",604 payload: { password: viewWordsPassword() },605 })) as { ok: boolean; data?: string[]; error?: string };606 if (r.ok) {607 setViewedWords(r.data ?? []);608 setViewWordsPassword("");609 return;610 }611 switch (r.error) {612 case "wrong_password":613 setViewWordsError("Incorrect password");614 break;615 case "password_required":616 setViewWordsError("Enter your master password.");617 break;618 case "recovery_not_setup":619 setViewWordsError("Recovery words aren't set up for this vault yet.");620 break;621 default:622 setViewWordsError("Couldn't retrieve recovery words.");623 }624 } finally {625 setViewWordsBusy(false);626 }627 }628 function closeViewWords() {629 setShowViewWords(false);630 setViewWordsPassword("");631 setViewWordsError(null);632 setViewedWords(null);633 }634635 const [duressEnabled, setDuressEnabled] = createSignal(false);636 const [showDuressSetup, setShowDuressSetup] = createSignal(false);637 const [duressPassword, setDuressPassword] = createSignal("");638 const [duressConfirm, setDuressConfirm] = createSignal("");639 const [duressMainPassword, setDuressMainPassword] = createSignal("");640 const [duressSaving, setDuressSaving] = createSignal(false);641 const [duressError, setDuressError] = createSignal<string | null>(null);642 const [duressSuccess, setDuressSuccess] = createSignal<string | null>(null);643644 const [canaryEntries, setCanaryEntries] = createSignal<Array<{id: string; title: string; canary: Canary}>>([]);645646 const [canaryTestingId, setCanaryTestingId] = createSignal<string | null>(null);647 const [canaryTestResult, setCanaryTestResult] = createSignal<{id: string; ok: boolean} | null>(null);648649 const [fbCategory, setFbCategory] = createSignal("Bug report");650 const [fbMessage, setFbMessage] = createSignal("");651 type FeedbackState = 'idle' | 'sending' | 'sent' | 'error';652 const [feedbackState, setFeedbackState] = createSignal<FeedbackState>('idle');653654 createEffect(() => {655 if (props.upgradeHighlight) {656 setActiveCard("pro");657 }658 });659660 onMount(async () => {661 const [licenseRes, passkeyRes, duressRes, settingsRes, keyRes] = await Promise.all([662 sendMessage({ type: "GET_LICENSE" }),663 sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }),664 sendMessage({ type: "IS_DURESS_ENABLED" }),665 sendMessage({ type: "GET_SETTINGS" }),666 sendMessage({ type: "GET_LICENSE_KEY" }),667 ]);668669 670 671 void loadVaultHistory();672673 674 675 void loadPendingVouched();676 void loadPairedDevices();677 void loadSyncState();678 void loadGeneration();679 const onBroadcast = (msg: unknown) => {680 const m = msg as { type?: string; payload?: unknown } | null;681 if (m?.type === "VOUCHED_DEVICE_PENDING") {682 void loadPendingVouched();683 }684 if (m?.type === "SYNC_STATE_CHANGED") {685 const payload = (m.payload ?? {}) as SyncOutcomeSummary;686 setSyncSummary(payload);687 setSyncStatus(payload.status ?? "idle");688 }689 };690 chrome.runtime?.onMessage?.addListener(onBroadcast);691 onCleanup(() => {692 try { chrome.runtime?.onMessage?.removeListener(onBroadcast); } catch { }693 });694695 if ((licenseRes as { ok: boolean }).ok) {696 setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null);697 }698699 if ((passkeyRes as { ok: boolean }).ok) {700 setPasskeyState(701 (passkeyRes as { ok: boolean; data?: PasskeyProviderState }).data ?? null702 );703 }704705 if ((duressRes as { ok: boolean }).ok) {706 setDuressEnabled((duressRes as { ok: boolean; data?: boolean }).data ?? false);707 }708709 if ((settingsRes as { ok: boolean }).ok) {710 const sData = (settingsRes as { ok: boolean; data?: { declinedCardFingerprints?: string[]; pinEnabled?: boolean } }).data;711 setDeclinedCardCount(sData?.declinedCardFingerprints?.length ?? 0);712 setPinEnabled(sData?.pinEnabled === true);713 }714715 if ((keyRes as { ok: boolean }).ok) {716 setLicenseKey((keyRes as { ok: boolean; data?: string }).data ?? null);717 }718719 const entriesRes = await sendMessage({ type: "GET_ENTRIES" });720 if ((entriesRes as { ok: boolean }).ok) {721 const entries = (entriesRes as { ok: boolean; data?: Array<{id: string; title: string; canary?: Canary}> }).data ?? [];722 setCanaryEntries(723 entries724 .filter((e) => e.canary?.webhookUrl)725 .map((e) => ({ id: e.id, title: e.title, canary: e.canary! }))726 );727 }728729 });730731 function resetPinFlow() {732 setPinFlow(null);733 setPinCurrentPassword("");734 setPinValue("");735 setPinConfirm("");736 setPinError(null);737 }738739 async function handleSetupPin() {740 setPinError(null);741 if (!pinCurrentPassword()) { setPinError("enter your current password"); return; }742 if (!/^\d{4,12}$/.test(pinValue())) { setPinError("PIN must be 4–12 digits"); return; }743 if (pinValue() !== pinConfirm()) { setPinError("PINs do not match"); return; }744 setPinSaving(true);745 const res = await sendMessage({746 type: "SETUP_PIN",747 payload: { currentPassword: pinCurrentPassword(), pin: pinValue() },748 }) as { ok: boolean; error?: string };749 if (res.ok) {750 setPinEnabled(true);751 resetPinFlow();752 } else {753 setPinError(res.error === "incorrect_current_password" ? "incorrect current password" : "couldn't set up PIN");754 }755 setPinSaving(false);756 }757758 async function handleDisablePin() {759 setPinError(null);760 if (!pinCurrentPassword()) { setPinError("enter your current password"); return; }761 setPinSaving(true);762 const res = await sendMessage({763 type: "DISABLE_PIN",764 payload: { currentPassword: pinCurrentPassword() },765 }) as { ok: boolean; error?: string };766 if (res.ok) {767 setPinEnabled(false);768 resetPinFlow();769 } else {770 setPinError(res.error === "incorrect_current_password" ? "incorrect current password" : "couldn't remove PIN");771 }772 setPinSaving(false);773 }774775 async function handleChangePassword() {776 setChangePwError(null);777 if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; }778 if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; }779 if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; }780 if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; }781 setChangePwSaving(true);782 const res = await sendMessage({783 type: "CHANGE_PASSWORD",784 payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() },785 }) as { ok: boolean; error?: string };786 if (res.ok) {787 setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");788 setShowChangePw(false);789 790 791 792 await sendMessage({ type: "SET_RECOVERY_CONFIRM_PENDING" });793 setShowNewRecoveryWords(true);794 } else {795 setChangePwError(res.error ?? "failed to change password");796 }797 setChangePwSaving(false);798 }799800 const [copied, setCopied] = createSignal(false);801802 async function copyLicenseKey(key: string) {803 await navigator.clipboard.writeText(key);804 setCopied(true);805 setTimeout(() => setCopied(false), 2000);806 }807808 async function handleActivateLicense() {809 const key = licenseKeyInput().trim();810 if (!key) return;811 setActivatingLicense(true);812 setLicenseError(null);813 setLicenseSuccess(null);814 const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } });815 setActivatingLicense(false);816 if ((res as { ok: boolean }).ok) {817 const data = (res as { ok: boolean; data?: LicenseInfo }).data;818 if (data?.valid) {819 setLicenseInfo(data);820 setLicenseKey(key);821 setLicenseSuccess("License activated!");822 setLicenseKeyInput("");823 } else {824 setLicenseError("Invalid license key");825 }826 } else {827 setLicenseError("Activation failed");828 }829 }830831 async function handleTogglePasskeys() {832 const current = passkeyState();833 if (!current || !current.supported) return;834 setPasskeySaving(true);835 const res = await sendMessage({836 type: "SET_PASSKEY_PROVIDER_ENABLED",837 payload: { enabled: !current.enabled },838 });839 setPasskeySaving(false);840 if ((res as { ok: boolean }).ok) {841 setPasskeyState(842 (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null843 );844 }845 }846847 async function handleCheckAllBreaches() {848 setCheckingBreaches(true);849 setBreachResult(null);850 const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });851 setCheckingBreaches(false);852 if ((res as { ok: boolean }).ok) {853 const data = (res as { ok: boolean; data?: Record<string, number> }).data ?? {};854 const breached = Object.values(data).filter((count) => count > 0).length;855 setBreachResult(856 breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean"857 );858 props.onBreachCheckDone();859 }860 }861862863 async function handleSetupDuress() {864 const mainPw = duressMainPassword().trim();865 const duressPw = duressPassword().trim();866 const confirmPw = duressConfirm().trim();867868 if (!mainPw) { setDuressError("enter your main password to confirm"); return; }869 if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; }870 if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; }871 if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; }872873 setDuressSaving(true);874 setDuressError(null);875876 const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };877 if (!verifyRes.ok || !verifyRes.data) {878 setDuressError("incorrect main password");879 setDuressSaving(false);880 return;881 }882883 const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string };884 setDuressSaving(false);885 if (res.ok) {886 setDuressEnabled(true);887 setShowDuressSetup(false);888 setDuressPassword("");889 setDuressConfirm("");890 setDuressMainPassword("");891 setDuressSuccess("Duress password set. If entered, a decoy vault will be shown.");892 setTimeout(() => setDuressSuccess(null), 5000);893 } else {894 setDuressError(895 res.error === "session_expired"896 ? "Vault session expired — lock and unlock Peach, then try again"897 : res.error ?? "failed to set duress password"898 );899 }900 }901902 async function handleClearDeclinedCards() {903 await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } });904 setDeclinedCardCount(0);905 }906907 async function handleCountDuplicates() {908 setDedupRunning(true);909 setDedupDone(null);910 const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number };911 setDedupRunning(false);912 if (res.ok) setDedupCount(res.data ?? 0);913 }914915 async function handleDeduplicate() {916 setDedupRunning(true);917 const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number };918 setDedupRunning(false);919 if (res.ok) {920 setDedupDone(res.data ?? 0);921 setDedupCount(0);922 }923 }924925 async function handleDisableDuress() {926 const mainPw = duressMainPassword().trim();927 if (!mainPw) { setDuressError("enter your main password to confirm"); return; }928929 setDuressSaving(true);930 setDuressError(null);931932 const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };933 if (!verifyRes.ok || !verifyRes.data) {934 setDuressError("incorrect main password");935 setDuressSaving(false);936 return;937 }938939 const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string };940 setDuressSaving(false);941 if (res.ok) {942 setDuressEnabled(false);943 setShowDuressSetup(false);944 setDuressMainPassword("");945 setDuressSuccess("Duress password disabled.");946 setTimeout(() => setDuressSuccess(null), 4000);947 } else {948 setDuressError(res.error ?? "failed to disable duress password");949 }950 }951952 async function handleSubmitFeedback() {953 const message = sanitizeFeedbackMessage(fbMessage());954 if (!message) return;955956 setFeedbackState('sending');957958 try {959 const category = FEEDBACK_CATEGORIES.includes(fbCategory())960 ? fbCategory()961 : FEEDBACK_CATEGORIES[0];962 const res = await fetch('https://api.web3forms.com/submit', {963 method: 'POST',964 headers: { 'Content-Type': 'application/json' },965 body: JSON.stringify({966 access_key: WEB3FORMS_KEY,967 subject: `Peach Feedback - ${category}`,968 message,969 from_name: 'Peach User',970 971 botcheck: '',972 }),973 });974975 if (!res.ok) throw new Error(`HTTP ${res.status}`);976 const data = await res.json() as { success: boolean; message?: string };977 if (!data.success) throw new Error(data.message ?? 'unknown error');978979 setFeedbackState('sent');980 setFbMessage('');981 setFbCategory(FEEDBACK_CATEGORIES[0]);982 } catch (err) {983 console.error('[feedback]', err);984 setFeedbackState('error');985 setTimeout(() => setFeedbackState('idle'), 4000);986 }987 }988989 function renderSecurity() {990 return (991 <div class="peach-settings-body">992 <div class="peach-settings-group">993 <p class="peach-settings-sub-label">LOCKING</p>994 <div class="peach-settings-row">995 <span>Lock on close</span>996 <button997 class={`peach-toggle ${props.lockOnClose ? "is-on" : ""}`}998 onClick={() => void props.onLockOnCloseChange(!props.lockOnClose)}999 aria-label="Toggle lock on close"1000 >1001 <span class="peach-toggle-knob"></span>1002 </button>1003 </div>1004 <div class="peach-settings-row">1005 <span>Auto-lock after</span>1006 <select1007 class="peach-settings-select"1008 value={String(props.autoLockMinutes)}1009 onChange={(e) => void props.onAutoLockMinutesChange(Number(e.currentTarget.value))}1010 >1011 <option value="1">1 minute</option>1012 <option value="5">5 minutes</option>1013 <option value="15">15 minutes</option>1014 <option value="30">30 minutes</option>1015 <option value="0">Never</option>1016 </select>1017 </div>10181019 <p class="peach-settings-sub-label">QUICK UNLOCK</p>1020 <div class="peach-settings-row peach-settings-row--meta">1021 <span>PIN unlock</span>1022 <button1023 class="peach-btn peach-btn--subtle peach-btn--sm"1024 onClick={() => { setPinFlow(pinEnabled() ? "disable" : "setup"); setPinError(null); }}1025 >1026 {pinEnabled() ? "remove" : "set up"}1027 </button>1028 </div>1029 <Show when={pinFlow() === "setup"}>1030 <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1031 <input1032 class="peach-settings-input"1033 type="password"1034 placeholder="current password"1035 value={pinCurrentPassword()}1036 onInput={(e) => setPinCurrentPassword((e.target as HTMLInputElement).value)}1037 autocomplete="current-password"1038 />1039 <input1040 class="peach-settings-input"1041 type="password"1042 inputmode="numeric"1043 maxlength="12"1044 placeholder="new 4–12 digit PIN"1045 value={pinValue()}1046 onInput={(e) => setPinValue((e.target as HTMLInputElement).value.replace(/\D/g, "").slice(0, 12))}1047 autocomplete="off"1048 />1049 <input1050 class="peach-settings-input"1051 type="password"1052 inputmode="numeric"1053 maxlength="12"1054 placeholder="confirm PIN"1055 value={pinConfirm()}1056 onInput={(e) => setPinConfirm((e.target as HTMLInputElement).value.replace(/\D/g, "").slice(0, 12))}1057 autocomplete="off"1058 onKeyDown={(e) => { if (e.key === "Enter") void handleSetupPin(); }}1059 />1060 <Show when={pinError()}>1061 <p class="peach-hint peach-hint-error" style="margin:0">{pinError()}</p>1062 </Show>1063 <button1064 class="peach-btn peach-btn--accent peach-btn--sm"1065 style="align-self:flex-start"1066 onClick={() => void handleSetupPin()}1067 disabled={pinSaving() || !pinCurrentPassword() || !/^\d{4,12}$/.test(pinValue()) || !/^\d{4,12}$/.test(pinConfirm())}1068 >1069 {pinSaving() ? "saving…" : "save PIN"}1070 </button>1071 </div>1072 </Show>1073 <Show when={pinFlow() === "disable"}>1074 <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1075 <input1076 class="peach-settings-input"1077 type="password"1078 placeholder="current password"1079 value={pinCurrentPassword()}1080 onInput={(e) => setPinCurrentPassword((e.target as HTMLInputElement).value)}1081 autocomplete="current-password"1082 onKeyDown={(e) => { if (e.key === "Enter") void handleDisablePin(); }}1083 />1084 <Show when={pinError()}>1085 <p class="peach-hint peach-hint-error" style="margin:0">{pinError()}</p>1086 </Show>1087 <button1088 class="peach-btn peach-btn--subtle peach-btn--sm"1089 style="align-self:flex-start"1090 onClick={() => void handleDisablePin()}1091 disabled={pinSaving() || !pinCurrentPassword()}1092 >1093 {pinSaving() ? "removing…" : "remove PIN"}1094 </button>1095 </div>1096 </Show>10971098 <p class="peach-settings-sub-label">BREACH PROTECTION</p>1099 <div class="peach-settings-row">1100 <span>Breach monitoring</span>1101 <button1102 class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}1103 onClick={() => void props.onBreachMonitoringChange(!props.breachMonitoring)}1104 aria-label="Toggle breach monitoring"1105 >1106 <span class="peach-toggle-knob"></span>1107 </button>1108 </div>1109 <div class="peach-settings-row peach-settings-row--meta">1110 <span>Check all passwords</span>1111 <button1112 class="peach-btn peach-btn--subtle peach-btn--sm"1113 onClick={() => void handleCheckAllBreaches()}1114 disabled={checkingBreaches()}1115 >1116 {checkingBreaches() ? "checking..." : "run"}1117 </button>1118 </div>1119 <Show when={breachResult()}>1120 <div class="peach-settings-row peach-settings-row--meta">1121 <span>Last check</span>1122 <span class="peach-settings-value">{breachResult()}</span>1123 </div>1124 </Show>11251126 <p class="peach-settings-sub-label">MASTER PASSWORD</p>1127 <div class="peach-settings-row peach-settings-row--meta">1128 <span>Master password</span>1129 <button1130 class="peach-btn peach-btn--subtle peach-btn--sm"1131 onClick={() => { setShowChangePw((v) => !v); setChangePwError(null); }}1132 >1133 {showChangePw() ? "cancel" : "change"}1134 </button>1135 </div>1136 <Show when={showChangePw()}>1137 <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1138 <input1139 class="peach-settings-input"1140 type="password"1141 placeholder="current password"1142 value={changePwCurrent()}1143 onInput={(e) => setChangePwCurrent((e.target as HTMLInputElement).value)}1144 autocomplete="current-password"1145 />1146 <input1147 class="peach-settings-input"1148 type="password"1149 placeholder="new password (min 12 chars)"1150 value={changePwNew()}1151 onInput={(e) => setChangePwNew((e.target as HTMLInputElement).value)}1152 autocomplete="new-password"1153 />1154 <input1155 class="peach-settings-input"1156 type="password"1157 placeholder="confirm new password"1158 value={changePwConfirm()}1159 onInput={(e) => setChangePwConfirm((e.target as HTMLInputElement).value)}1160 autocomplete="new-password"1161 onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }}1162 />1163 <Show when={changePwError()}>1164 <p class="peach-hint peach-hint-error" style="margin:0">{changePwError()}</p>1165 </Show>1166 <Show when={changePwSuccess()}>1167 <p class="peach-hint" style="margin:0;color:var(--peach-accent)">password changed ✓</p>1168 </Show>1169 <button1170 class="peach-btn peach-btn--accent peach-btn--sm"1171 style="align-self:flex-start"1172 onClick={() => void handleChangePassword()}1173 disabled={changePwSaving() || !changePwCurrent() || !changePwNew() || !changePwConfirm()}1174 >1175 {changePwSaving() ? "saving…" : "save new password"}1176 </button>1177 </div>1178 </Show>11791180 {}1181 <div class="peach-settings-row peach-settings-row--meta">1182 <span>Recovery words</span>1183 <button1184 class="peach-btn peach-btn--subtle peach-btn--sm"1185 onClick={() => {1186 if (showViewWords()) { closeViewWords(); } else { setShowViewWords(true); }1187 }}1188 >1189 {showViewWords() ? "cancel" : "view"}1190 </button>1191 </div>1192 <Show when={showViewWords()}>1193 <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1194 <Show1195 when={viewedWords()}1196 fallback={1197 <>1198 <p class="peach-hint" style="margin:0;color:var(--peach-t2)">1199 Enter your master password to reveal your recovery words.1200 </p>1201 <input1202 class="peach-settings-input"1203 type="password"1204 placeholder="master password"1205 value={viewWordsPassword()}1206 onInput={(e) => setViewWordsPassword((e.target as HTMLInputElement).value)}1207 autocomplete="current-password"1208 onKeyDown={(e) => { if (e.key === "Enter") void handleViewRecoveryWords(); }}1209 />1210 <Show when={viewWordsError()}>1211 <p class="peach-hint peach-hint-error" style="margin:0">{viewWordsError()}</p>1212 </Show>1213 <button1214 class="peach-btn peach-btn--accent peach-btn--sm"1215 style="align-self:flex-start"1216 onClick={() => void handleViewRecoveryWords()}1217 disabled={viewWordsBusy() || !viewWordsPassword()}1218 >1219 {viewWordsBusy() ? "checking…" : "reveal recovery words"}1220 </button>1221 </>1222 }1223 >1224 <div class="peach-recovery-words-grid">1225 <For each={viewedWords()!}>1226 {(word, i) => (1227 <span class="peach-recovery-word"><b>{i() + 1}</b> {word}</span>1228 )}1229 </For>1230 </div>1231 <button1232 class="peach-btn peach-btn--subtle peach-btn--sm"1233 style="align-self:flex-start"1234 onClick={closeViewWords}1235 >done</button>1236 </Show>1237 </div>1238 </Show>12391240 <p class="peach-settings-sub-label">1241 DURESS PASSWORD1242 <Show when={!isPro()}>1243 <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>1244 </Show>1245 </p>1246 <div class="peach-settings-row peach-settings-row--top">1247 <div class="peach-settings-row-main">1248 <span>1249 Duress password1250 <Show when={!isPro()}>1251 <span class="peach-pro-badge">Pro</span>1252 </Show>1253 </span>1254 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">1255 If entered under coercion, shows a decoy empty vault instead of your real one.1256 </span>1257 </div>1258 <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>1259 {duressEnabled() ? "on" : "off"}1260 </div>1261 </div>12621263 <Show when={duressEnabled() && !showDuressSetup()}>1264 <div class="peach-settings-row peach-settings-row--meta">1265 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1266 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.1267 </span>1268 </div>1269 </Show>12701271 <Show when={duressSuccess()}>1272 <div class="peach-settings-row peach-settings-row--meta">1273 <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>1274 </div>1275 </Show>12761277 <Show when={!showDuressSetup()}>1278 <div class="peach-settings-row peach-settings-row--meta">1279 <button1280 class="peach-btn peach-btn--subtle peach-btn--sm"1281 onClick={() => {1282 1283 1284 1285 1286 1287 if (!isPro() && !duressEnabled()) {1288 props.onProGate?.("duress_vault");1289 setActiveCard("pro");1290 return;1291 }1292 setShowDuressSetup(true);1293 setDuressError(null);1294 setDuressSuccess(null);1295 setDuressPassword("");1296 setDuressConfirm("");1297 setDuressMainPassword("");1298 }}1299 >1300 {duressEnabled() ? "Change / disable" : "Set up"}1301 </button>1302 </div>1303 </Show>13041305 <Show when={showDuressSetup()}>1306 <div class="peach-duress-form">1307 <label class="peach-label">main password (to confirm)</label>1308 <input1309 class="peach-settings-input"1310 type="password"1311 placeholder="your current master password"1312 autocomplete="current-password"1313 value={duressMainPassword()}1314 onInput={(e) => setDuressMainPassword(e.currentTarget.value)}1315 />1316 <Show when={!duressEnabled()}>1317 <label class="peach-label">new duress password</label>1318 <input1319 class="peach-settings-input"1320 type="password"1321 placeholder="duress password (min 8 chars)"1322 autocomplete="new-password"1323 value={duressPassword()}1324 onInput={(e) => setDuressPassword(e.currentTarget.value)}1325 />1326 <label class="peach-label">confirm duress password</label>1327 <input1328 class="peach-settings-input"1329 type="password"1330 placeholder="confirm duress password"1331 autocomplete="new-password"1332 value={duressConfirm()}1333 onInput={(e) => setDuressConfirm(e.currentTarget.value)}1334 />1335 </Show>13361337 <Show when={duressError()}>1338 <p class="peach-hint peach-hint-error">{duressError()}</p>1339 </Show>13401341 <div class="peach-duress-actions">1342 <button1343 class="peach-btn peach-btn--subtle peach-btn--sm"1344 onClick={() => { setShowDuressSetup(false); setDuressError(null); }}1345 >1346 cancel1347 </button>1348 <Show when={duressEnabled()}>1349 <button1350 class="peach-btn peach-btn--ghost peach-btn--sm"1351 style="color:var(--peach-breach)"1352 onClick={() => void handleDisableDuress()}1353 disabled={duressSaving()}1354 >1355 {duressSaving() ? "disabling..." : "disable duress"}1356 </button>1357 </Show>1358 <Show when={!duressEnabled()}>1359 <button1360 class="peach-btn peach-btn--accent peach-btn--sm"1361 onClick={() => void handleSetupDuress()}1362 disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}1363 >1364 {duressSaving() ? "saving..." : "save"}1365 </button>1366 </Show>1367 </div>1368 </div>1369 </Show>13701371 <p class="peach-settings-sub-label">1372 CANARY ENTRIES1373 <Show when={!isPro()}>1374 <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>1375 </Show>1376 </p>1377 <div class="peach-settings-row peach-settings-row--top">1378 <div class="peach-settings-row-main">1379 <span>1380 Canary entries1381 <Show when={!isPro()}>1382 <span class="peach-pro-badge">Pro</span>1383 </Show>1384 </span>1385 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1386 Silently pings a webhook when an entry is accessed. Configure canary on any entry in your vault.1387 </span>1388 </div>1389 <span class="peach-settings-value" style="flex-shrink:0">1390 {canaryEntries().length > 0 ? `${canaryEntries().length} active` : "none"}1391 </span>1392 </div>1393 <Show when={canaryEntries().length > 0}>1394 <For each={canaryEntries()}>1395 {(entry) => (1396 <div class="peach-settings-row peach-settings-row--meta">1397 <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0">{entry.title}</span>1398 <button1399 class="peach-btn peach-btn--subtle peach-btn--sm"1400 onClick={() => void handleTestCanary(entry)}1401 disabled={canaryTestingId() === entry.id}1402 style="flex-shrink:0"1403 >1404 {canaryTestingId() === entry.id1405 ? "sending…"1406 : canaryTestResult()?.id === entry.id1407 ? (canaryTestResult()!.ok ? "sent ✓" : "failed ✗")1408 : "test"}1409 </button>1410 </div>1411 )}1412 </For>1413 </Show>14141415 <p class="peach-settings-sub-label">PASSKEYS</p>1416 <div class="peach-settings-row peach-settings-row--top">1417 <div class="peach-settings-row-main">1418 <span>Intercept passkey requests</span>1419 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">1420 Peach can handle passkey sign-in and creation on supported Chromium browsers.1421 </span>1422 </div>1423 <button1424 class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}1425 onClick={() => void handleTogglePasskeys()}1426 aria-label="Toggle passkey interception"1427 disabled={!passkeyState()?.supported || passkeySaving()}1428 >1429 <span class="peach-toggle-knob"></span>1430 </button>1431 </div>1432 <div class="peach-settings-row peach-settings-row--meta">1433 <span>Status</span>1434 <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>1435 </div>1436 <div class="peach-settings-row peach-settings-row--meta">1437 <span>Browser</span>1438 <span class="peach-settings-value">1439 {passkeyState()?.browserLabel ?? "Unknown"}1440 {passkeyState()?.supported ? " ✓" : " (interception unavailable)"}1441 </span>1442 </div>1443 <div class="peach-info-notice" style="margin-top:8px">1444 Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.1445 </div>1446 <Show when={passkeyState()?.conflict}>1447 <div class="peach-settings-row peach-settings-row--meta">1448 <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>1449 </div>1450 </Show>1451 </div>1452 </div>1453 );1454 }14551456 function renderVault() {1457 return (1458 <div class="peach-settings-body">1459 <div class="peach-settings-group">1460 <p class="peach-settings-sub-label">ENTRIES</p>1461 <div class="peach-settings-row peach-settings-row--meta">1462 <span>Entries</span>1463 <span class="peach-settings-value">{props.entriesCount}</span>1464 </div>1465 <div class="peach-settings-row peach-settings-row--meta">1466 <span>Duplicates</span>1467 <Show when={dedupDone() !== null} fallback={1468 <button1469 class="peach-btn peach-btn--subtle peach-btn--sm"1470 onClick={() => void handleCountDuplicates()}1471 disabled={dedupRunning()}1472 >1473 {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}1474 </button>1475 }>1476 <span class="peach-settings-value" style="color:var(--peach-sync-ok)">1477 {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}1478 </span>1479 </Show>1480 </div>1481 <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>1482 <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">1483 <div class="peach-restore-warning">1484 {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.1485 </div>1486 <div style="display:flex;gap:6px">1487 <button1488 class="peach-btn peach-btn--subtle peach-btn--sm"1489 onClick={() => setDedupCount(null)}1490 >1491 cancel1492 </button>1493 <button1494 class="peach-btn peach-btn--accent peach-btn--sm"1495 onClick={() => void handleDeduplicate()}1496 disabled={dedupRunning()}1497 >1498 {dedupRunning() ? "removing..." : "remove duplicates"}1499 </button>1500 </div>1501 </div>1502 </Show>15031504 <p class="peach-settings-sub-label">MAINTENANCE</p>1505 <div class="peach-settings-row peach-settings-row--meta">1506 <span>Trash auto-delete</span>1507 <select1508 class="peach-settings-select"1509 value={String(props.trashRetentionDays)}1510 onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}1511 >1512 <option value="7">7 days</option>1513 <option value="30">30 days</option>1514 <option value="90">90 days</option>1515 <option value="0">Never</option>1516 </select>1517 </div>1518 <div class="peach-settings-row peach-settings-row--meta">1519 <span>Advanced Vault Health</span>1520 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>1521 open1522 </button>1523 </div>1524 <Show when={declinedCardCount() > 0}>1525 <div class="peach-settings-row peach-settings-row--meta">1526 <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>1527 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>1528 Clear1529 </button>1530 </div>1531 </Show>15321533 <p class="peach-settings-sub-label">DATA</p>1534 <div class="peach-settings-row peach-settings-row--meta">1535 <span>Import passwords</span>1536 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>1537 open1538 </button>1539 </div>1540 <div class="peach-settings-row peach-settings-row--meta">1541 <span>Export backup</span>1542 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onExport}>1543 open1544 </button>1545 </div>1546 <div class="peach-settings-row peach-settings-row--meta">1547 <span>1548 Peach Codex1549 <Show when={!isPro()}>1550 <span class="peach-pro-badge">Pro</span>1551 </Show>1552 </span>1553 <button1554 class="peach-btn peach-btn--subtle peach-btn--sm"1555 onClick={() => props.onOpenCodexGenerate?.()}1556 >1557 open1558 </button>1559 </div>1560 {1561}1562 <div class="peach-settings-row peach-settings-row--meta">1563 <span>Restore from Codex</span>1564 <button1565 class="peach-btn peach-btn--subtle peach-btn--sm"1566 onClick={() => props.onOpenCodexRestore?.()}1567 >1568 open1569 </button>1570 </div>15711572 <Show when={hasReleaseNotes()}>1573 <p class="peach-settings-sub-label">RELEASE NOTES</p>1574 <div class="peach-settings-row peach-settings-row--meta">1575 <span>v{PEACH_VERSION}</span>1576 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => setShowWhatsNew(true)}>1577 view1578 </button>1579 </div>1580 </Show>15811582 <span class="peach-settings-sub-label">VAULT HISTORY</span>1583 <Show when={historyLoading()}>1584 <p class="peach-settings-hint">Loading…</p>1585 </Show>1586 <Show when={!historyLoading() && historySnapshots().length === 0}>1587 <p class="peach-settings-hint">1588 No snapshots yet. Peach saves automatically before every change —1589 saves, syncs, and imports.1590 </p>1591 </Show>1592 <Show when={!historyLoading() && historySnapshots().length > 0}>1593 <p class="peach-settings-hint">1594 Restoring locks your vault. Unlock with the password that1595 encrypted that snapshot to confirm.1596 </p>1597 <div class="peach-history-list">1598 <For each={historySnapshots().slice(0, historyShowAll() ? undefined : 3)}>1599 {(snap) => (1600 <div class="peach-history-row">1601 <div class="peach-history-meta">1602 <span class="peach-history-time">1603 {formatSnapshotTime(snap.savedAt)}1604 </span>1605 <span class="peach-history-sub">1606 {[1607 snap.entryCount !== null1608 ? `up to ${snap.entryCount} ${snap.entryCount === 1 ? "entry" : "entries"}`1609 : null,1610 reasonLabel(snap.reason),1611 ]1612 .filter(Boolean)1613 .join(" · ")}1614 </span>1615 </div>1616 <button1617 class={`peach-btn peach-btn--subtle peach-btn--sm peach-history-btn${1618 historyRestoring() === snap.id ? " is-busy" : ""1619 }`}1620 onClick={() => void handleRestoreVaultSnapshot(snap.id)}1621 disabled={historyRestoring() !== null}1622 >1623 <Show when={historyRestoring() === snap.id} fallback="Restore">1624 …1625 </Show>1626 </button>1627 </div>1628 )}1629 </For>1630 </div>1631 <Show when={historySnapshots().length > 3}>1632 <button1633 class="peach-settings-advanced-toggle"1634 onClick={() => setHistoryShowAll((v) => !v)}1635 aria-expanded={historyShowAll()}1636 >1637 <span class="peach-settings-hint" style="margin:0">1638 {historyShowAll() ? "show less" : `${historySnapshots().length - 3} older`}1639 </span>1640 <span class="peach-settings-advanced-chevron">1641 {historyShowAll() ? "▲" : "▼"}1642 </span>1643 </button>1644 </Show>1645 <p class="peach-history-footer">1646 {historySnapshots().length} of 10 snapshots1647 </p>1648 </Show>16491650 <button1651 class="peach-settings-advanced-toggle"1652 onClick={() => setShowVaultAdvanced((v) => !v)}1653 aria-expanded={showVaultAdvanced()}1654 >1655 <span class="peach-settings-sub-label" style="margin:0">ADVANCED</span>1656 <span class="peach-settings-advanced-chevron">1657 {showVaultAdvanced() ? "▲" : "▼"}1658 </span>1659 </button>16601661 <Show when={showVaultAdvanced()}>1662 <div class="peach-danger-card">1663 <div class="peach-danger-card-head">1664 <span class="peach-danger-card-glyph" aria-hidden="true">⟳</span>1665 <h3 class="peach-danger-card-title">Replace vault from backup</h3>1666 </div>1667 <p class="peach-danger-card-copy">1668 Wipes the current vault and restores from a backup file.1669 Recoverable only if your backup is good.1670 </p>1671 <button1672 class="peach-danger-card-button"1673 onClick={() => props.onOpenFileImport?.()}1674 >1675 Choose backup file1676 </button>1677 </div>16781679 <Show when={props.onOpenPanic}>1680 <div class="peach-danger-card peach-danger-card--hazard">1681 <div class="peach-danger-card-head">1682 <span class="peach-danger-card-glyph" aria-hidden="true">⚠</span>1683 <h3 class="peach-danger-card-title">Destroy vault</h3>1684 </div>1685 <p class="peach-danger-card-copy">1686 Permanently wipes every entry, setting, and license on this1687 device. There is no recovery.1688 </p>1689 <button1690 class="peach-danger-card-button"1691 onClick={() => props.onOpenPanic?.()}1692 >1693 Begin destruction1694 </button>1695 </div>1696 </Show>1697 </Show>1698 </div>1699 </div>1700 );1701 }17021703 function renderSync() {1704 return (1705 <div class="peach-settings-body">1706 <div class="peach-settings-group">1707 <p class="peach-settings-sub-label" style="margin-top:0">SYNC STATUS</p>1708 <div1709 class="peach-settings-row peach-settings-row--meta"1710 style="align-items:center;gap:8px"1711 role="status"1712 aria-label={syncLightLabel()}1713 title={syncLightLabel()}1714 >1715 <span class={`stk-pip stk-pip--sync is-${syncLight()}`} aria-hidden="true" />1716 <span class="peach-settings-value" style="color:var(--peach-t2);line-height:1.5;flex:1;min-width:0;overflow-wrap:anywhere">1717 {syncFlash() ?? lastSyncLine()}1718 </span>1719 </div>1720 <Show when={pairedDevices().length > 0}>1721 <div class="peach-settings-row peach-settings-row--meta">1722 <span class="peach-settings-value" style="color:var(--peach-t2)">1723 {(syncSummary().entriesSent ?? 0) + (syncSummary().entriesReceived ?? 0) > 01724 ? `${syncSummary().entriesSent ?? 0} sent · ${syncSummary().entriesReceived ?? 0} received`1725 : ""}1726 </span>1727 <button1728 class="peach-btn peach-btn--subtle peach-btn--sm"1729 aria-label="Sync this vault now"1730 disabled={syncStatus() === "syncing"}1731 onClick={() => void syncNow()}1732 >{syncStatus() === "syncing" ? "syncing…" : "sync now"}</button>1733 </div>1734 </Show>17351736 <p class="peach-settings-sub-label">SECURE SYNC RESET</p>1737 <div class="peach-settings-row peach-settings-row--top">1738 <div class="peach-settings-row-main">1739 <span class="peach-settings-value" style="color:var(--peach-t1)">1740 {secureResetHeadline(generation()?.state)}1741 </span>1742 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1743 {secureResetDetail(generation()?.state)}1744 </span>1745 </div>1746 <Show1747 when={1748 generation()?.state === "LEGACY_ACTIVE" ||1749 generation()?.state === "RESET_REQUIRED" ||1750 generation()?.state === "RESET_CONFIRMED"1751 }1752 >1753 <button1754 class="peach-btn peach-btn--subtle peach-btn--sm"1755 onClick={() => {1756 setResetError(null);1757 setResetPassword("");1758 setResetOpen(true);1759 }}1760 >reset sync</button>1761 </Show>1762 </div>1763 <Show when={resetError()}>1764 <p1765 class="peach-settings-value"1766 role="alert"1767 style="color:var(--peach-danger,#e5805c);font-size:11px;line-height:1.5"1768 >{resetError()}</p>1769 </Show>1770 <Show when={resetOpen()}>1771 <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1772 <p class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1773 {SECURE_RESET_DISCLOSURE}1774 </p>1775 <input1776 class="peach-settings-input"1777 type="password"1778 placeholder="master password"1779 value={resetPassword()}1780 onInput={(e) => setResetPassword((e.target as HTMLInputElement).value)}1781 autocomplete="current-password"1782 />1783 <div style="display:flex;gap:4px">1784 <button1785 class="peach-btn peach-btn--subtle peach-btn--sm"1786 disabled={resetting()}1787 onClick={() => void runSecureReset()}1788 >{resetting() ? "resetting…" : "reset"}</button>1789 <button1790 class="peach-btn peach-btn--subtle peach-btn--sm"1791 disabled={resetting()}1792 onClick={() => setResetOpen(false)}1793 >cancel</button>1794 </div>1795 </div>1796 </Show>17971798 <p class="peach-settings-sub-label">ADD A DEVICE</p>1799 <button1800 class="peach-sync-pair-option"1801 onClick={() => props.onOpenSyncPairing?.("code")}1802 >1803 <span class="peach-sync-pair-option-icon" aria-hidden="true">1804 <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">1805 <rect x="6" y="2.5" width="8" height="15" rx="1.6" />1806 <path d="M9 15 L11 15" />1807 </svg>1808 </span>1809 <span class="peach-sync-pair-option-main">1810 <span class="peach-sync-pair-option-title">Show a code</span>1811 <span class="peach-sync-pair-option-sub">Scan it with Peach on your phone, then confirm one 8-digit code</span>1812 </span>1813 <span class="peach-sync-pair-option-chevron" aria-hidden="true">›</span>1814 </button>1815 <Show when={!isPro()}>1816 <p class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2);margin:8px 2px 0">1817 At least one device needs Peach Pro — pair with a Pro device and it carries over to here.1818 </p>1819 </Show>18201821 <Show when={pairedDevices().length > 0}>1822 <p class="peach-settings-sub-label">PAIRED DEVICES</p>1823 <For each={pairedDevices()}>1824 {(d) => (1825 <div class="peach-settings-row peach-settings-row--top">1826 <div class="peach-settings-row-main">1827 <Show1828 when={renameDeviceId() === d.deviceId}1829 fallback={<span>{d.localAlias ?? d.deviceName ?? "Unnamed device"}</span>}1830 >1831 <input1832 class="peach-settings-input"1833 aria-label={`Rename ${d.localAlias ?? d.deviceName ?? "device"}`}1834 maxlength={64}1835 value={renameDraft()}1836 onInput={(event) => setRenameDraft(event.currentTarget.value)}1837 onKeyDown={(event) => {1838 if (event.key === "Enter") void renameDevice(d.deviceId);1839 if (event.key === "Escape") setRenameDeviceId(null);1840 }}1841 />1842 </Show>1843 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1844 {deviceTypeLabel(d.deviceType)} · {syncRelativeTime(d.lastSyncedAt)}{isSyncStale(d) ? " · not seen recently" : ""}1845 </span>1846 </div>1847 <Show1848 when={confirmUnpair() === d.deviceId}1849 fallback={1850 <div style="display:flex;gap:4px;flex-shrink:0">1851 <Show1852 when={renameDeviceId() === d.deviceId}1853 fallback={1854 <button1855 class="peach-btn peach-btn--subtle peach-btn--sm"1856 onClick={() => {1857 setRenameDraft(d.localAlias ?? d.deviceName ?? "");1858 setRenameDeviceId(d.deviceId);1859 }}1860 >rename</button>1861 }1862 >1863 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => setRenameDeviceId(null)}>cancel</button>1864 <button class="peach-btn peach-btn--subtle peach-btn--sm" disabled={!renameDraft().trim()} onClick={() => void renameDevice(d.deviceId)}>save</button>1865 </Show>1866 <button1867 class="peach-btn peach-btn--subtle peach-btn--sm"1868 onClick={() => setConfirmUnpair(d.deviceId)}1869 >remove</button>1870 </div>1871 }1872 >1873 {18741875187618771878}1879 <div class="peach-unpair-confirm">1880 <p class="peach-unpair-confirm-note">1881 Stops this device receiving future changes. It doesn't1882 lock it out of what it already has — if it's lost or1883 stolen, also change your master password and generate1884 new recovery words.1885 </p>1886 <div style="display:flex;gap:6px;justify-content:flex-end">1887 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => setConfirmUnpair(null)}>cancel</button>1888 <button1889 class="peach-btn peach-btn--sm"1890 style="background:var(--peach-breach);border-color:var(--peach-breach)"1891 onClick={() => void unpairDevice(d.deviceId)}1892 >remove</button>1893 </div>1894 </div>1895 </Show>1896 </div>1897 )}1898 </For>1899 </Show>19001901 <Show when={pendingVouched().length > 0}>1902 <p class="peach-settings-sub-label">1903 PENDING DEVICES1904 <span class="peach-pro-badge" style="background:#C8A840;color:#1a1a1a">{pendingVouched().length}</span>1905 </p>1906 <For each={pendingVouched()}>1907 {(dev) => (1908 <div class="peach-settings-row peach-settings-row--top">1909 <div class="peach-settings-row-main">1910 <span>{dev.deviceName}</span>1911 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1912 Key {dev.longTermPublicKeyHex.slice(0, 12).match(/.{4}/g)?.join(" ")} ·1913 requested {new Date(dev.receivedAt).toISOString().slice(0, 16).replace("T", " ")} UTC ·1914 vouched by device {dev.vouchedByDeviceId}. Allowing it can read1915 existing vault contents and all future changes.1916 </span>1917 </div>1918 <div style="display:flex;gap:6px;flex-shrink:0">1919 <button1920 class="peach-btn peach-btn--sm"1921 disabled={vouchedBusy() === dev.deviceId}1922 onClick={() => void confirmVouched(dev.deviceId)}1923 >Allow</button>1924 <button1925 class="peach-btn peach-btn--subtle peach-btn--sm"1926 disabled={vouchedBusy() === dev.deviceId}1927 onClick={() => void rejectVouched(dev.deviceId)}1928 >Reject</button>1929 </div>1930 </div>1931 )}1932 </For>1933 </Show>19341935 <button1936 class="peach-settings-advanced-toggle peach-sync-disclosure-toggle"1937 onClick={() => setShowSyncConnection((value) => !value)}1938 aria-expanded={showSyncConnection()}1939 aria-controls="peach-sync-connection"1940 >1941 <span>1942 <span class="peach-settings-sub-label" style="margin:0">CONNECTION</span>1943 <span class="peach-sync-disclosure-summary">Relay and privacy choices</span>1944 </span>1945 <span class="peach-settings-advanced-chevron">1946 {showSyncConnection() ? "▲" : "▼"}1947 </span>1948 </button>1949 <Show when={showSyncConnection()}>1950 <div id="peach-sync-connection" class="peach-sync-disclosure-content">1951 <SyncModeSelector />1952 </div>1953 </Show>19541955 <button1956 class="peach-settings-advanced-toggle peach-sync-disclosure-toggle"1957 onClick={() => setShowSyncRecovery((value) => !value)}1958 aria-expanded={showSyncRecovery()}1959 aria-controls="peach-sync-recovery"1960 >1961 <span>1962 <span class="peach-settings-sub-label" style="margin:0">RECOVERY & SECURITY</span>1963 <span class="peach-sync-disclosure-summary">For a lost or stolen device</span>1964 </span>1965 <span class="peach-settings-advanced-chevron">1966 {showSyncRecovery() ? "▲" : "▼"}1967 </span>1968 </button>1969 <Show when={showSyncRecovery()}>1970 <div id="peach-sync-recovery" class="peach-sync-disclosure-content">1971 <Show1972 when={rotateConfirmOpen()}1973 fallback={1974 <div class="peach-settings-row peach-settings-row--top">1975 <div class="peach-settings-row-main">1976 <span>Rotate sync identity</span>1977 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1978 Disconnects every device and replaces the keys used for sync.1979 </span>1980 </div>1981 <button1982 class="peach-btn peach-btn--subtle peach-btn--sm"1983 style="color:var(--peach-breach);border-color:color-mix(in srgb,var(--peach-breach) 40%,transparent)"1984 onClick={() => { setRotateError(null); setRotateConfirmOpen(true); }}1985 >rotate</button>1986 </div>1987 }1988 >1989 <div class="peach-info-notice" style="margin-top:8px;border-color:color-mix(in srgb,var(--peach-breach) 40%,transparent)">1990 This disconnects every paired device and changes your 12 recovery words. You'll be shown the new words next.1991 <Show when={rotateError()}>1992 <div style="color:var(--peach-breach);margin-top:6px">{rotateError()}</div>1993 </Show>1994 <div style="display:flex;gap:6px;margin-top:10px">1995 <button1996 class="peach-btn peach-btn--subtle peach-btn--sm"1997 disabled={rotating()}1998 onClick={() => setRotateConfirmOpen(false)}1999 >Cancel</button>2000 <button2001 class="peach-btn peach-btn--sm"2002 style="background:var(--peach-breach);border-color:var(--peach-breach)"2003 disabled={rotating()}2004 onClick={() => void doRotateSyncIdentity()}2005 >{rotating() ? "Rotating…" : "Rotate sync identity"}</button>2006 </div>2007 </div>2008 </Show>2009 </div>2010 </Show>2011 </div>2012 </div>2013 );2014 }20152016 function renderAppearance() {2017 const sizeOptions: Array<{ id: "medium" | "large"; label: string }> = [2018 { id: "medium", label: "Default" },2019 { id: "large", label: "Large" },2020 ];2021 return (2022 <div class="peach-settings-body">2023 <div class="peach-settings-group">2024 <p class="peach-settings-sub-label">THEME</p>2025 <div class="peach-settings-row peach-settings-row--meta">2026 <span>Theme</span>2027 <button2028 class="peach-btn peach-btn--subtle peach-btn--sm"2029 onClick={() =>2030 props.onThemeChange(props.theme === "dark" ? "light" : "dark")2031 }2032 >2033 {props.theme === "dark" ? "Switch to light" : "Switch to dark"}2034 </button>2035 </div>20362037 <p class="peach-settings-sub-label">ACCENT</p>2038 <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">2039 <span>Accent</span>2040 <div class="peach-accent-picker">2041 <For each={ACCENT_COLORS}>2042 {(color) => (2043 <button2044 class="peach-accent-swatch peach-swatch"2045 classList={{ "is-active": props.accentColor === color.name }}2046 style={{ background: color.hex }}2047 onClick={() => void props.onAccentChange(color.name)}2048 aria-label={color.name}2049 title={color.name}2050 />2051 )}2052 </For>2053 </div>2054 </div>20552056 <p class="peach-settings-sub-label">TEXT SIZE</p>2057 <div class="peach-settings-row peach-settings-row--meta">2058 <span>Size</span>2059 <div class="peach-pill-group">2060 <For each={sizeOptions}>2061 {(opt) => (2062 <button2063 class="peach-btn peach-btn--ghost peach-btn--sm"2064 classList={{ "is-active": props.textSize === opt.id }}2065 onClick={() => void props.onTextSizeChange(opt.id)}2066 >2067 {opt.label}2068 </button>2069 )}2070 </For>2071 </div>2072 </div>20732074 <p class="peach-settings-sub-label">MOTION</p>2075 <div class="peach-settings-row peach-settings-row--meta">2076 <span>Reduce motion</span>2077 <button2078 class="peach-btn peach-btn--subtle peach-btn--sm"2079 onClick={() => void props.onReduceMotionChange(!props.reduceMotion)}2080 >2081 {props.reduceMotion ? "On" : "Off"}2082 </button>2083 </div>2084 </div>2085 </div>2086 );2087 }20882089 function renderShortcuts() {2090 const [capturingAction, setCapturingAction] = createSignal<ShortcutAction | null>(null);2091 const [conflictMsg, setConflictMsg] = createSignal<string | null>(null);20922093 function startCapture(action: ShortcutAction) {2094 setConflictMsg(null);2095 setCapturingAction(action);2096 }2097 function cancelCapture() {2098 setCapturingAction(null);2099 setConflictMsg(null);2100 }2101 function commitBinding(action: ShortcutAction, binding: Keybinding) {2102 const conflict = findConflict(props.shortcuts, binding, action);2103 if (conflict) {2104 const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict);2105 setConflictMsg(`Already bound to "${other?.label ?? conflict}"`);2106 return;2107 }2108 const next = { ...props.shortcuts, [action]: binding };2109 setCapturingAction(null);2110 setConflictMsg(null);2111 void props.onShortcutsChange(next);2112 }2113 function resetAction(action: ShortcutAction) {2114 const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] };2115 void props.onShortcutsChange(next);2116 }2117 function resetAll() {2118 void props.onShortcutsChange({ ...DEFAULT_BINDINGS });2119 }21202121 2122 2123 function handleCaptureKey(e: KeyboardEvent) {2124 e.preventDefault();2125 e.stopPropagation();2126 if (e.key === "Escape") { cancelCapture(); return; }2127 const binding = keybindingFromEvent(e);2128 if (!binding) return; 2129 const action = capturingAction();2130 if (action) commitBinding(action, binding);2131 }21322133 return (2134 <div class="peach-settings-body">2135 <div class="peach-settings-group peach-shortcuts-group">2136 <p class="peach-settings-sub-label">ENTRY VIEW</p>2137 <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry")}>2138 {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}2139 </For>2140 </div>21412142 <div class="peach-settings-group peach-shortcuts-group">2143 <p class="peach-settings-sub-label">ANYWHERE IN POPUP</p>2144 <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global")}>2145 {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}2146 </For>2147 </div>21482149 <Show when={conflictMsg()}>2150 <div class="peach-shortcuts-conflict">{conflictMsg()}</div>2151 </Show>21522153 <div class="peach-settings-group" style="display:flex;flex-direction:column;gap:8px">2154 <div class="peach-settings-row peach-settings-row--meta">2155 <span>Inline autofill on page</span>2156 <a2157 class="peach-settings-link"2158 href="chrome://extensions/shortcuts"2159 onClick={(e) => {2160 e.preventDefault();2161 chrome.tabs.create({ url: "chrome://extensions/shortcuts" });2162 }}2163 >2164 ⌘⇧P — customize ↗2165 </a>2166 </div>2167 <button2168 class="peach-btn peach-btn--subtle peach-btn--sm"2169 style="align-self:flex-start"2170 onClick={resetAll}2171 >2172 Reset all to defaults2173 </button>2174 </div>2175 </div>2176 );21772178 function renderRow(2179 desc: typeof SHORTCUT_DESCRIPTORS[number],2180 capturing: () => ShortcutAction | null,2181 onStart: (a: ShortcutAction) => void,2182 onCancel: () => void,2183 onReset: (a: ShortcutAction) => void,2184 onKey: (e: KeyboardEvent) => void,2185 ) {2186 const isActive = () => capturing() === desc.action;2187 const binding = () => props.shortcuts[desc.action];2188 const isCustom = () => {2189 const b = binding();2190 const d = DEFAULT_BINDINGS[desc.action];2191 return b.key !== d.key2192 || Boolean(b.ctrl) !== Boolean(d.ctrl)2193 || Boolean(b.meta) !== Boolean(d.meta)2194 || Boolean(b.shift) !== Boolean(d.shift)2195 || Boolean(b.alt) !== Boolean(d.alt);2196 };2197 return (2198 <div class="peach-shortcut-row">2199 <span class="peach-shortcut-row-label">{desc.label}</span>2200 <button2201 class="peach-shortcut-chip"2202 classList={{ "is-capturing": isActive(), "is-custom": isCustom() }}2203 onClick={() => isActive() ? onCancel() : onStart(desc.action)}2204 onKeyDown={(e) => { if (isActive()) onKey(e); }}2205 ref={(el) => {2206 2207 if (el) {2208 queueMicrotask(() => { if (isActive()) el.focus(); });2209 }2210 }}2211 >2212 {isActive() ? "Press a key…" : formatKeybinding(binding())}2213 </button>2214 <Show when={isCustom() && !isActive()}>2215 <button2216 class="peach-shortcut-reset"2217 title="Reset to default"2218 onClick={() => onReset(desc.action)}2219 >2220 ↺2221 </button>2222 </Show>2223 </div>2224 );2225 }2226 }22272228 const PRO_FEATURES: Array<{2229 feature: ProFeature;2230 name: string;2231 desc: string;2232 }> = [2233 { feature: "sync", name: "Peach Sync", desc: "Encrypted · peer-to-peer" },2234 { feature: "codex", name: "Peach Codex", desc: "Restore from paper" },2235 { feature: "passkeys", name: "Passkeys", desc: "Zero-knowledge WebAuthn" },2236 { feature: "advanced_health", name: "Advanced Vault Health", desc: "Reuse · age · exposure" },2237 ];22382239 const PRO_SECURITY_FEATURES: Array<{2240 feature: ProFeature;2241 name: string;2242 desc: string;2243 }> = [2244 { feature: "canary_entry", name: "Canary entries", desc: "Detects vault compromise" },2245 { feature: "duress_vault", name: "Duress vault", desc: "Decoy under coercion" },2246 ];22472248 const [licenseKey, setLicenseKey] = createSignal<string | null>(null);2249 const [showKey, setShowKey] = createSignal(false);22502251 const [deactivating, setDeactivating] = createSignal(false);22522253 async function handleDeactivateLicense() {2254 setDeactivating(true);2255 await sendMessage({ type: "DEACTIVATE_LICENSE" });2256 setLicenseInfo(null);2257 await props.onLicenseChange?.();2258 setDeactivating(false);2259 }22602261 async function handleTestCanary(entry: {id: string; title: string; canary: Canary}) {2262 setCanaryTestingId(entry.id);2263 setCanaryTestResult(null);2264 const res = await sendMessage({2265 type: "FIRE_CANARY_PING",2266 payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger: "test" },2267 }) as { ok: boolean };2268 setCanaryTestResult({ id: entry.id, ok: res.ok });2269 setCanaryTestingId(null);2270 setTimeout(() => setCanaryTestResult((r) => r?.id === entry.id ? null : r), 3000);2271 }22722273 function renderLicense() {2274 async function onActivate() {2275 await handleActivateLicense();2276 if ((licenseInfo()?.valid ?? false) && licenseInfo()?.plan === "pro") {2277 await props.onLicenseChange?.();2278 }2279 }22802281 return (2282 <div class="peach-settings-body">2283 <Show2284 when={isPro()}2285 fallback={2286 <div class="peach-pro-upgrade">2287 <p class="peach-pro-eyebrow">PEACH PRO · ONE-TIME</p>22882289 <h2 class="peach-pro-headline">2290 Every<em>thing,</em><br />every<em>where.</em>2291 </h2>22922293 <p class="peach-pro-subtitle">2294 One key unlocks every Pro feature — for the lifetime of the app.2295 </p>22962297 <div class="peach-pro-grid">2298 <For each={PRO_FEATURES}>2299 {(item, i) => (2300 <div2301 class="peach-pro-grid-item"2302 classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}2303 >2304 <span class="peach-pro-grid-num">{String(i() + 1).padStart(2, "0")}</span>2305 <span class="peach-pro-grid-name">{item.name}</span>2306 <span class="peach-pro-grid-desc">{item.desc}</span>2307 </div>2308 )}2309 </For>2310 </div>23112312 <p class="peach-pro-section-label">ADVANCED SECURITY</p>2313 <div class="peach-pro-grid">2314 <For each={PRO_SECURITY_FEATURES}>2315 {(item, i) => (2316 <div2317 class="peach-pro-grid-item"2318 classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}2319 >2320 <span class="peach-pro-grid-num">{String(PRO_FEATURES.length + i() + 1).padStart(2, "0")}</span>2321 <span class="peach-pro-grid-name">{item.name}</span>2322 <span class="peach-pro-grid-desc">{item.desc}</span>2323 </div>2324 )}2325 </For>2326 </div>23272328 <div class="peach-pro-upgrade-actions">2329 <p class="peach-pro-pricing">2330 $39 <span class="peach-pro-pricing-sub">once · yours forever</span>2331 </p>2332 <a2333 href="https://buy.polar.sh/polar_cl_IXSFIPojx67rcOTksn8LkH5mnU3osPHtYqBIl1eyuIz"2334 target="_blank"2335 rel="noopener noreferrer"2336 class="peach-pro-upgrade-cta"2337 >2338 Get Peach Pro2339 </a>2340 <div class="peach-pro-key-divider"><span>ALREADY HAVE A KEY</span></div>2341 <div class="peach-pro-key-inline">2342 <input2343 class="peach-pro-key-inline-input"2344 type="text"2345 placeholder="PEACH-XXXX-XXXX-XXXX"2346 value={licenseKeyInput()}2347 onInput={(e) => setLicenseKeyInput(e.currentTarget.value)}2348 onKeyDown={(e) => { if (e.key === "Enter") void onActivate(); }}2349 autocomplete="off"2350 spellcheck={false}2351 />2352 <button2353 class="peach-pro-key-apply"2354 onClick={() => void onActivate()}2355 disabled={activatingLicense() || !licenseKeyInput().trim()}2356 >2357 {activatingLicense() ? "…" : "APPLY"}2358 </button>2359 </div>2360 <Show when={licenseError()}>2361 <p class="peach-pro-key-error">{licenseError()}</p>2362 </Show>2363 </div>2364 </div>2365 }2366 >2367 <div class="peach-pro-active">2368 <div class="peach-pro-active-hero">2369 <button2370 class="peach-pro-active-mark-wrap"2371 type="button"2372 aria-label="Celebrate"2373 onClick={handleProCelebrationClick}2374 >2375 <PeachMark class="peach-pro-active-mark" />2376 <Show when={justActivated()}>2377 <div class="peach-pro-sparks" aria-hidden="true">2378 <span class="peach-pro-spark" style="--spark-x:0px;--spark-y:-54px;--spark-delay:0ms">✦</span>2379 <span class="peach-pro-spark" style="--spark-x:38px;--spark-y:-38px;--spark-delay:50ms">✦</span>2380 <span class="peach-pro-spark" style="--spark-x:54px;--spark-y:0px;--spark-delay:110ms">·</span>2381 <span class="peach-pro-spark" style="--spark-x:38px;--spark-y:38px;--spark-delay:30ms">✦</span>2382 <span class="peach-pro-spark" style="--spark-x:0px;--spark-y:54px;--spark-delay:80ms">·</span>2383 <span class="peach-pro-spark" style="--spark-x:-38px;--spark-y:38px;--spark-delay:130ms">✦</span>2384 <span class="peach-pro-spark" style="--spark-x:-54px;--spark-y:0px;--spark-delay:55ms">·</span>2385 <span class="peach-pro-spark" style="--spark-x:-38px;--spark-y:-38px;--spark-delay:95ms">✦</span>2386 </div>2387 </Show>2388 </button>2389 <h2 class="peach-pro-active-title">Peach Pro</h2>2390 <p class="peach-pro-active-since">{formatActivatedSince(licenseInfo()?.issued)}</p>2391 <Show when={justActivated()}>2392 <p class="peach-pro-thankyou-text" aria-live="polite">thank you ✦</p>2393 </Show>2394 </div>23952396 <Show when={licenseKey()}>2397 <div class="peach-settings-license">2398 <span class="peach-settings-label">LICENSE KEY</span>2399 <div class="peach-settings-license-row">2400 <Show2401 when={showKey()}2402 fallback={2403 <span class="peach-settings-license-key peach-settings-license-key--masked" aria-label="License key hidden">2404 ••••••••••••••••••••••••••••2405 </span>2406 }2407 >2408 <span class="peach-settings-license-key">{licenseKey()!}</span>2409 </Show>2410 <div class="peach-settings-license-actions">2411 <button2412 class="peach-settings-license-copy"2413 title="Copy license key"2414 onClick={() => copyLicenseKey(licenseKey()!)}2415 >2416 {copied() ? "✓" : "copy"}2417 </button>2418 <button2419 class="peach-settings-license-toggle"2420 aria-label={showKey() ? "Hide license key" : "Reveal license key"}2421 title={showKey() ? "Hide" : "Reveal"}2422 onClick={() => setShowKey(v => !v)}2423 >2424 <Show2425 when={showKey()}2426 fallback={2427 <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">2428 <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />2429 <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />2430 </svg>2431 }2432 >2433 <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">2434 <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />2435 <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />2436 <line x1="2" y1="2" x2="12" y2="12" />2437 </svg>2438 </Show>2439 </button>2440 </div>2441 </div>2442 </div>2443 </Show>24442445 <div class="peach-pro-active-features">2446 <For each={[...PRO_FEATURES, ...PRO_SECURITY_FEATURES]}>2447 {(item) => (2448 <div class="peach-pro-active-feature">2449 <span class="peach-pro-active-feature-mark">✦</span>2450 <span class="peach-pro-active-feature-name">{item.name}</span>2451 </div>2452 )}2453 </For>2454 </div>24552456 <button2457 class="peach-pro-deactivate"2458 onClick={() => void handleDeactivateLicense()}2459 disabled={deactivating()}2460 >2461 {deactivating() ? "Deactivating…" : "Deactivate"}2462 </button>2463 </div>2464 </Show>2465 </div>2466 );2467 }24682469 function renderFeedback() {2470 return (2471 <div class="peach-settings-body">2472 <div class="peach-settings-group peach-feedback-group">2473 <Show2474 when={feedbackState() === 'sent'}2475 fallback={2476 <>2477 <p class="peach-feedback-intro">2478 Help us improve Peach. Every report goes directly to our inbox.2479 </p>24802481 <p class="peach-settings-sub-label">TYPE</p>2482 <div class="peach-feedback-chips">2483 <For each={FEEDBACK_CATEGORIES}>2484 {(cat) => {2485 const selected = () => fbCategory() === cat;2486 return (2487 <button2488 type="button"2489 class={`peach-feedback-chip ${selected() ? "is-active" : ""}`}2490 aria-pressed={selected()}2491 onClick={() => setFbCategory(cat)}2492 >2493 {cat}2494 </button>2495 );2496 }}2497 </For>2498 </div>24992500 <p class="peach-settings-sub-label">MESSAGE</p>2501 <textarea2502 class="peach-feedback-textarea"2503 placeholder="Describe the issue or idea..."2504 maxLength={MAX_FEEDBACK_MESSAGE_LENGTH}2505 value={fbMessage()}2506 onInput={(e) => setFbMessage(e.currentTarget.value)}2507 />25082509 <p class="peach-feedback-meta">2510 Version {version} · will be included automatically2511 </p>25122513 <button2514 type="button"2515 class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"2516 disabled={!fbMessage().trim() || feedbackState() === 'sending'}2517 onClick={() => void handleSubmitFeedback()}2518 >2519 <Switch>2520 <Match when={feedbackState() === 'idle'}>Send</Match>2521 <Match when={feedbackState() === 'sending'}>Sending...</Match>2522 <Match when={feedbackState() === 'error'}>Failed - try again</Match>2523 </Switch>2524 </button>25252526 <p class="peach-feedback-anon">2527 Sent anonymously · no account required2528 </p>2529 </>2530 }2531 >2532 <div class="peach-feedback-thanks" role="status" aria-live="polite">2533 <p class="peach-feedback-thanks-title">Thank you</p>2534 <p>2535 Your note is in the queue. Someone will process it soon.2536 </p>2537 </div>2538 </Show>2539 </div>2540 </div>2541 );2542 }25432544 const bodies: StackBodies = {2545 protection: renderSecurity(),2546 vault: renderVault(),2547 sync: renderSync(),2548 appearance: renderAppearance(),2549 shortcuts: renderShortcuts(),2550 pro: renderLicense(),2551 feedback: renderFeedback(),2552 };25532554 return (2555 <Show2556 when={showWhatsNew()}2557 fallback={2558 <Show2559 when={showNewRecoveryWords()}2560 fallback={2561 <div2562 class="peach-overlay-view peach-settings-overlay"2563 classList={{ "ov-enter-right": !props.isExiting, "ov-exit-right": !!props.isExiting }}2564 style="display:flex;flex-direction:column"2565 >2566 <StackDeck2567 active={activeCard()}2568 onActiveChange={setActiveCard}2569 onBack={props.onBack}2570 cards={cards()}2571 bodies={bodies}2572 version={version}2573 isFanning={isFanning()}2574 celebratingCardId={justActivated() ? "pro" : null}2575 isPro={isPro()}2576 />2577 </div>2578 }2579 >2580 <RecoveryWordDisplayView onDone={() => setShowNewRecoveryWords(false)} />2581 </Show>2582 }2583 >2584 <div class="peach-overlay-view peach-settings-overlay">2585 <WhatsNewView mode="history" onContinue={() => setShowWhatsNew(false)} />2586 </div>2587 </Show>2588 );2589}