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 lockedSyncRequiresUnlock: boolean;49 trashRetentionDays: number;50 theme: "dark" | "light";51 accentColor: AccentColorName;52 textSize: "small" | "medium" | "large";53 reduceMotion: boolean;54 isPro?: boolean;55 initialSection?: string | null;56 upgradeHighlight?: ProFeature | null;57 onBreachMonitoringChange: (enabled: boolean) => void | Promise<void>;58 onLockOnCloseChange: (enabled: boolean) => void | Promise<void>;59 onAutoLockMinutesChange: (minutes: number) => void | Promise<void>;60 onLockedSyncRequiresUnlockChange: (enabled: boolean) => void | Promise<void>;61 onTrashRetentionChange: (days: number) => void | Promise<void>;62 onThemeChange: (theme: "dark" | "light") => void;63 onAccentChange: (name: AccentColorName) => void | Promise<void>;64 onTextSizeChange: (size: "small" | "medium" | "large") => void | Promise<void>;65 onReduceMotionChange: (enabled: boolean) => void | Promise<void>;66 shortcuts: Record<ShortcutAction, Keybinding>;67 onShortcutsChange: (active: Record<ShortcutAction, Keybinding>) => void | Promise<void>;68 onLicenseChange?: () => void | Promise<void>;69 isExiting?: boolean;70 onBack: () => void;71 onImport: () => void;72 onExport: () => void;73 onBreachCheckDone: () => void;74 onOpenHealth?: () => void;75 onOpenSyncPairing?: (method?: "code") => void;76 onOpenFileImport?: () => void;77 onOpenPanic?: () => void;78 onOpenCodexGenerate?: () => void;79 onOpenCodexRestore?: () => void;80 818283 onProGate?: (feature: ProFeature) => void;84 onCardChange?: (id: CardId) => void;85}8687const FEEDBACK_CATEGORIES = ["Bug report", "Feature request", "Question", "Other"];88const MAX_FEEDBACK_MESSAGE_LENGTH = 4000;89const PRO_CELEBRATION_CARD_STEP_MS = 150;90const PRO_CELEBRATION_THANK_YOU_MS = 3200;9192function sanitizeFeedbackMessage(value: string): string {93 return value94 .trim()95 .slice(0, MAX_FEEDBACK_MESSAGE_LENGTH)96 97 .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "")98 .replace(/&/g, "&")99 .replace(/</g, "<")100 .replace(/>/g, ">")101 .replace(/"/g, """)102 .replace(/'/g, "'");103}104105function formatActivatedSince(issued: string | undefined): string {106 if (!issued) return "Active";107 try {108 const d = new Date(issued);109 if (Number.isNaN(d.getTime())) return "Active";110 return `Active since ${d.toLocaleDateString(undefined, { month: "long", year: "numeric" })}`;111 } catch {112 return "Active";113 }114}115116function initialSectionToCard(value: string | null | undefined): CardId | null {117 if (!value) return null;118 if (value === "security") return "protection";119 if (value === "license") return "pro";120 if (121 value === "protection" ||122 value === "vault" ||123 value === "sync" ||124 value === "appearance" ||125 value === "shortcuts" ||126 value === "pro" ||127 value === "feedback"128 ) {129 return value;130 }131 return null;132}133134135136137138const SECURE_RESET_DISCLOSURE =139 "Peach will archive this device's old Sync trust and start the new Sync generation. " +140 "Every other device must be updated first, then re-paired with this one — they will " +141 "not continue on their own. Your vault and your recovery words are not changed.";142143function secureResetHeadline(state: string | undefined): string {144 switch (state) {145 case "LEGACY_ACTIVE":146 return "Legacy Sync generation";147 case "RESET_REQUIRED":148 return "Reset required";149 case "RESET_CONFIRMED":150 return "Reset not completed";151 case "V2_ANCHOR":152 case "REPAIRING":153 return "Not re-paired";154 case "V2_ACTIVE":155 return "New Sync generation";156 case "UNKNOWN":157 return "Sync generation unreadable";158 default:159 return "Sync generation";160 }161}162163function secureResetDetail(state: string | undefined): string {164 switch (state) {165 case "LEGACY_ACTIVE":166 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.";167 case "RESET_REQUIRED":168 return "Sync is stopped until the reset completes. Update Peach on every other device, then reset this one and re-pair each device.";169 case "RESET_CONFIRMED":170 return "The reset did not finish. Nothing was lost — the old trust is archived. Try again.";171 case "V2_ANCHOR":172 case "REPAIRING":173 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.";174 case "V2_ACTIVE":175 return "This device is on the new Sync generation. Each device is re-paired individually.";176 case "UNKNOWN":177 return "This device's Sync generation can't be read, so sync is stopped. Reinstall or contact support.";178 default:179 return "Sync state is unavailable.";180 }181}182183export function SettingsView(props: SettingsProps) {184 const manifest = chrome.runtime.getManifest();185 const version = manifest.version;186 const initial = props.initialSection;187 const [activeCard, setActiveCard] = createSignal<CardId>(188 props.upgradeHighlight ? "pro" : initialSectionToCard(initial) ?? "pro"189 );190191 createEffect(() => { props.onCardChange?.(activeCard()); });192193 const [justActivated, setJustActivated] = createSignal(false);194 const [isFanning, setIsFanning] = createSignal(false);195196 function triggerCelebration() {197 if (isFanning()) return;198 setJustActivated(false);199 setIsFanning(true);200 setActiveCard("pro");201 const fanOrder: CardId[] = ["protection", "vault", "sync", "appearance", "shortcuts", "feedback"];202 fanOrder.forEach((id, i) => {203 window.setTimeout(() => setActiveCard(id), (i + 1) * PRO_CELEBRATION_CARD_STEP_MS);204 });205 window.setTimeout(() => {206 setActiveCard("pro");207 setIsFanning(false);208 setJustActivated(true);209 window.setTimeout(() => setJustActivated(false), PRO_CELEBRATION_THANK_YOU_MS);210 }, (fanOrder.length + 1) * PRO_CELEBRATION_CARD_STEP_MS);211 }212213 function handleProCelebrationClick(e: MouseEvent) {214 e.stopPropagation();215 triggerCelebration();216 }217218 const [checkingBreaches, setCheckingBreaches] = createSignal(false);219 const [breachResult, setBreachResult] = createSignal<string | null>(null);220 const [licenseInfo, setLicenseInfo] = createSignal<LicenseInfo | null>(null);221 const [licenseKeyInput, setLicenseKeyInput] = createSignal("");222 const [licenseError, setLicenseError] = createSignal<string | null>(null);223 const [, setLicenseSuccess] = createSignal<string | null>(null);224 const [activatingLicense, setActivatingLicense] = createSignal(false);225226 const isPro = () => props.isPro ?? false;227 const cards = createMemo<StackCardSpec[]>(() => [228 {229 id: "pro",230 num: "I · FOREVER",231 title: "Peach Pro",232 lede: isPro() ? "active · paid once, yours forever" : "$39 once · yours forever",233 bgVar: "--stack-pro-bg",234 fgVar: "--stack-pro-fg",235 icon: "spark",236 onIconClick: isPro() ? triggerCelebration : undefined,237 },238 {239 id: "protection",240 num: "II · PROTECT",241 title: "Protection",242 lede: `${props.breachMonitoring ? "breaches watched" : "breach watch off"} · ${props.lockOnClose ? "locks on close" : "manual lock"}`,243 bgVar: "--stack-protect-bg",244 fgVar: "--stack-protect-fg",245 icon: "shield",246 },247 {248 id: "vault",249 num: "III · KEEP",250 title: "Vault",251 lede: `${props.entriesCount} ${props.entriesCount === 1 ? "entry" : "entries"} · backups, history, cleanup`,252 bgVar: "--stack-vault-bg",253 fgVar: "--stack-vault-fg",254 icon: "lock",255 },256 {257 id: "sync",258 num: "IV · ECHO",259 title: "Sync",260 lede: isPro() ? "End to End Encryption" : "Pro · encrypted device sync",261 bgVar: "--stack-sync-bg",262 fgVar: "--stack-sync-fg",263 icon: "sync",264 },265 {266 id: "appearance",267 num: "V · DRESS",268 title: "Appearance",269 lede: `${props.theme} · ${props.accentColor} · ${props.textSize}`,270 bgVar: "--stack-appear-bg",271 fgVar: "--stack-appear-fg",272 icon: "sun",273 },274 {275 id: "shortcuts",276 num: "VI · KEYS",277 title: "Shortcuts",278 lede: `${SHORTCUT_DESCRIPTORS.length} bindings · what your fingers know`,279 bgVar: "--stack-shortcut-bg",280 fgVar: "--stack-shortcut-fg",281 icon: "keys",282 },283 {284 id: "feedback",285 num: "VII · ECHO",286 title: "Feedback",287 lede: `version ${version} · tell us what would make it better`,288 bgVar: "--stack-feedback-bg",289 fgVar: "--stack-feedback-fg",290 icon: "bubble",291 },292 ]);293 const [passkeyState, setPasskeyState] = createSignal<PasskeyProviderState | null>(null);294 const [passkeySaving, setPasskeySaving] = createSignal(false);295296 297 298 interface PendingVouched {299 deviceId: string;300 deviceName: string;301 longTermPublicKeyHex: string;302 receivedAt: number;303 vouchedByDeviceId: string;304 }305 const [pendingVouched, setPendingVouched] = createSignal<PendingVouched[]>([]);306 const [vouchedBusy, setVouchedBusy] = createSignal<string | null>(null);307 308 const [pairedDevices, setPairedDevices] = createSignal<PairedDevice[]>([]);309 const [trustDataCorrupted, setTrustDataCorrupted] = createSignal(false);310311 const [confirmUnpair, setConfirmUnpair] = createSignal<string | null>(null);312 const [unpairError, setUnpairError] = createSignal<string | null>(null);313 const [renameDeviceId, setRenameDeviceId] = createSignal<string | null>(null);314 const [renameDraft, setRenameDraft] = createSignal("");315 const [syncStatus, setSyncStatus] = createSignal<"idle" | "syncing" | "error">("idle");316 const [syncFlash, setSyncFlash] = createSignal<string | null>(null);317 318 319 const [syncSummary, setSyncSummary] = createSignal<SyncOutcomeSummary>({});320 321 322 const [generation, setGeneration] = createSignal<SyncGeneration | null>(null);323 const [resetOpen, setResetOpen] = createSignal(false);324 const [resetPassword, setResetPassword] = createSignal("");325 const [resetError, setResetError] = createSignal<string | null>(null);326 const [resetting, setResetting] = createSignal(false);327 const syncLight = () =>328 syncStatus() === "syncing"329 ? "busy"330 : syncIndicator(syncSummary(), pairedDevices().length);331 const syncLightLabel = () => syncIndicatorLabel(syncLight(), syncSummary());332 const lastSyncLine = () => {333 const s = syncSummary();334 if (!s.outcome) return "No sync attempted yet";335 return `${syncRelativeTime(s.finishedAt)} · ${describeSyncOutcome(s)}`;336 };337 async function loadSyncState() {338 const r = (await sendMessage({ type: "GET_SYNC_STATE" })) as {339 ok: boolean;340 data?: SyncOutcomeSummary;341 };342 if (r.ok && r.data) {343 setSyncSummary(r.data);344 setSyncStatus(r.data.status ?? "idle");345 }346 }347348 async function loadGeneration() {349 const r = (await sendMessage({ type: "GET_SYNC_GENERATION" })) as {350 ok: boolean;351 data?: SyncGeneration | null;352 };353 if (r.ok) setGeneration(r.data ?? null);354 }355356 async function runSecureReset() {357 setResetting(true);358 const r = (await sendMessage({359 type: "SECURE_RESET",360 payload: { password: resetPassword() },361 })) as { ok: boolean; data?: SyncGeneration | null; error?: string };362 setResetting(false);363 setResetPassword("");364 if (!r.ok) {365 setResetError(r.error ?? "The reset did not complete — nothing was lost, try again");366 await loadGeneration();367 return;368 }369 setResetError(null);370 setResetOpen(false);371 if (r.data) setGeneration(r.data);372 }373374 const STALE_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000;375 function syncRelativeTime(when: number | undefined): string {376 if (!when) return "Not synced yet";377 const elapsed = Date.now() - when;378 if (elapsed < 60_000) return "Just now";379 const minutes = Math.floor(elapsed / 60_000);380 if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"} ago`;381 const hours = Math.floor(minutes / 60);382 if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;383 const days = Math.floor(hours / 24);384 return days < 7 ? `${days} day${days === 1 ? "" : "s"} ago` : "Not seen recently";385 }386 function isSyncStale(d: PairedDevice): boolean {387 return d.lastSyncedAt !== undefined && Date.now() - d.lastSyncedAt > STALE_THRESHOLD_MS;388 }389 function deviceTypeLabel(t: PairedDevice["deviceType"]): string {390 switch (t) {391 case "android": return "Android";392 case "ios": return "iPhone";393 case "extension": return "Browser";394 default: return "Device";395 }396 }397398 async function loadPairedDevices() {399 if (!isPro()) return;400 const r = (await sendMessage({ type: "LIST_PAIRED_DEVICES" })) as {401 ok: boolean;402 error?: string;403 data?: { devices: PairedDevice[] };404 };405 if (r.ok) {406 setTrustDataCorrupted(false);407 setPairedDevices(r.data?.devices ?? []);408 } else if (r.error === "trust_data_corrupted") {409 setPairedDevices([]);410 setTrustDataCorrupted(true);411 }412 }413 async function unpairDevice(deviceId: string) {414 const result = (await sendMessage({ type: "UNPAIR_DEVICE", payload: { deviceId } })) as {415 ok: boolean;416 error?: string;417 };418 if (!result.ok) {419 setUnpairError(result.error ?? "Could not remove this device");420 return;421 }422 setUnpairError(null);423 setConfirmUnpair(null);424 await loadPairedDevices();425 }426 async function renameDevice(deviceId: string) {427 const name = renameDraft().trim();428 if (!name) return;429 const result = (await sendMessage({430 type: "RENAME_PAIRED_DEVICE",431 payload: { deviceId, name },432 })) as { ok: boolean };433 if (!result.ok) return;434 setRenameDeviceId(null);435 await loadPairedDevices();436 }437 async function syncNow() {438 setSyncStatus("syncing");439 setSyncFlash(null);440 const res = (await sendMessage({441 type: "TRIGGER_SYNC",442 payload: { reason: "manual", force: true },443 })) as { ok: boolean; error?: string };444 if (!res.ok) {445 setSyncStatus("error");446 setSyncSummary({ status: "error", outcome: "run_failed", detail: res.error });447 return;448 }449 450 451 452 let s: SyncOutcomeSummary = { status: "syncing" };453 for (let i = 0; i < 40 && s.status === "syncing"; i++) {454 await new Promise((r) => setTimeout(r, 250));455 const state = (await sendMessage({ type: "GET_SYNC_STATE" })) as {456 ok: boolean;457 data?: SyncOutcomeSummary;458 };459 s = state.ok ? state.data ?? { status: "idle" } : { status: "error", outcome: "run_failed" };460 }461 setSyncSummary(s);462 setSyncStatus(s.status ?? "idle");463 464 465 setSyncFlash(describeSyncOutcome(s));466 setTimeout(() => setSyncFlash(null), 4000);467 await loadPairedDevices();468 }469 470 const [rotateConfirmOpen, setRotateConfirmOpen] = createSignal(false);471 const [rotating, setRotating] = createSignal(false);472 const [rotateError, setRotateError] = createSignal<string | null>(null);473 const [showSyncConnection, setShowSyncConnection] = createSignal(false);474 const [showSyncRecovery, setShowSyncRecovery] = createSignal(false);475476 async function loadPendingVouched() {477 const r = (await sendMessage({ type: "GET_PENDING_VOUCHED_DEVICES" })) as {478 ok: boolean;479 data?: PendingVouched[];480 };481 if (r.ok) setPendingVouched(r.data ?? []);482 }483 async function confirmVouched(deviceId: string) {484 setVouchedBusy(deviceId);485 try {486 await sendMessage({ type: "CONFIRM_VOUCHED_DEVICE", payload: { deviceId } });487 await loadPendingVouched();488 } finally {489 setVouchedBusy(null);490 }491 }492 async function rejectVouched(deviceId: string) {493 setVouchedBusy(deviceId);494 try {495 await sendMessage({ type: "REJECT_VOUCHED_DEVICE", payload: { deviceId } });496 await loadPendingVouched();497 } finally {498 setVouchedBusy(null);499 }500 }501 async function doRotateSyncIdentity() {502 setRotateError(null);503 setRotating(true);504 try {505 const r = (await sendMessage({ type: "ROTATE_SYNC_IDENTITY" })) as {506 ok: boolean;507 error?: string;508 };509 if (!r.ok) {510 setRotateError(r.error ?? "Rotation failed.");511 return;512 }513 setRotateConfirmOpen(false);514 515 516 setShowNewRecoveryWords(true);517 } finally {518 setRotating(false);519 }520 }521522 const [dedupCount, setDedupCount] = createSignal<number | null>(null);523 const [dedupRunning, setDedupRunning] = createSignal(false);524 const [dedupDone, setDedupDone] = createSignal<number | null>(null);525526 const [declinedCardCount, setDeclinedCardCount] = createSignal(0);527528 529 530 531 const [historySnapshots, setHistorySnapshots] = createSignal<VaultSnapshot[]>([]);532 const [historyLoading, setHistoryLoading] = createSignal(false);533 const [historyRestoring, setHistoryRestoring] = createSignal<string | null>(null);534 const [historyShowAll, setHistoryShowAll] = createSignal(false);535536 async function loadVaultHistory() {537 setHistoryLoading(true);538 try {539 const res = await sendMessage({ type: "GET_VAULT_HISTORY" });540 const list = (res as { ok: boolean; data?: VaultSnapshot[] }).data ?? [];541 setHistorySnapshots(list);542 } finally {543 setHistoryLoading(false);544 }545 }546547 async function handleRestoreVaultSnapshot(id: string) {548 if (historyRestoring()) return;549 setHistoryRestoring(id);550 const res = await sendMessage({551 type: "RESTORE_VAULT_SNAPSHOT",552 payload: { snapshotId: id },553 });554 setHistoryRestoring(null);555 if ((res as { ok: boolean }).ok) {556 557 558 props.onBack();559 }560 }561562 function formatSnapshotTime(savedAt: number): string {563 const diff = Date.now() - savedAt;564 if (diff < 60_000) return "Just now";565 if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;566 const d = new Date(savedAt);567 const isToday = new Date().toDateString() === d.toDateString();568 const isYest =569 new Date(Date.now() - 86_400_000).toDateString() === d.toDateString();570 const t = d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });571 if (isToday) return `Today ${t}`;572 if (isYest) return `Yesterday ${t}`;573 return `${d.toLocaleDateString([], { month: "short", day: "numeric" })} ${t}`;574 }575576 function reasonLabel(reason: VaultSnapshot["reason"]): string {577 return (578 {579 local_save: "saved",580 sync_received: "synced",581 import: "imported",582 restore: "restored",583 } as const584 )[reason] ?? reason;585 }586587 const [showVaultAdvanced, setShowVaultAdvanced] = createSignal(false);588 const [showWhatsNew, setShowWhatsNew] = createSignal(false);589590 const [showChangePw, setShowChangePw] = createSignal(false);591 const [changePwCurrent, setChangePwCurrent] = createSignal("");592 const [changePwNew, setChangePwNew] = createSignal("");593 const [changePwConfirm, setChangePwConfirm] = createSignal("");594 const [changePwSaving, setChangePwSaving] = createSignal(false);595 const [changePwError, setChangePwError] = createSignal<string | null>(null);596 const [changePwSuccess] = createSignal(false);597 const [showNewRecoveryWords, setShowNewRecoveryWords] = createSignal(false);598599 const [pinEnabled, setPinEnabled] = createSignal(false);600 const [pinFlow, setPinFlow] = createSignal<"setup" | "disable" | null>(null);601 const [pinCurrentPassword, setPinCurrentPassword] = createSignal("");602 const [pinValue, setPinValue] = createSignal("");603 const [pinConfirm, setPinConfirm] = createSignal("");604 const [pinSaving, setPinSaving] = createSignal(false);605 const [pinError, setPinError] = createSignal<string | null>(null);606607 608 const [showViewWords, setShowViewWords] = createSignal(false);609 const [viewWordsPassword, setViewWordsPassword] = createSignal("");610 const [viewWordsError, setViewWordsError] = createSignal<string | null>(null);611 const [viewedWords, setViewedWords] = createSignal<string[] | null>(null);612 const [viewWordsBusy, setViewWordsBusy] = createSignal(false);613614 async function handleViewRecoveryWords() {615 setViewWordsError(null);616 if (!viewWordsPassword()) {617 setViewWordsError("Enter your master password.");618 return;619 }620 setViewWordsBusy(true);621 try {622 const r = (await sendMessage({623 type: "GET_RECOVERY_WORDS",624 payload: { password: viewWordsPassword() },625 })) as { ok: boolean; data?: string[]; error?: string };626 if (r.ok) {627 setViewedWords(r.data ?? []);628 setViewWordsPassword("");629 return;630 }631 switch (r.error) {632 case "wrong_password":633 setViewWordsError("Incorrect password");634 break;635 case "password_required":636 setViewWordsError("Enter your master password.");637 break;638 case "recovery_not_setup":639 setViewWordsError("Recovery words aren't set up for this vault yet.");640 break;641 default:642 setViewWordsError("Couldn't retrieve recovery words.");643 }644 } finally {645 setViewWordsBusy(false);646 }647 }648 function closeViewWords() {649 setShowViewWords(false);650 setViewWordsPassword("");651 setViewWordsError(null);652 setViewedWords(null);653 }654655 const [duressEnabled, setDuressEnabled] = createSignal(false);656 const [showDuressSetup, setShowDuressSetup] = createSignal(false);657 const [duressPassword, setDuressPassword] = createSignal("");658 const [duressConfirm, setDuressConfirm] = createSignal("");659 const [duressMainPassword, setDuressMainPassword] = createSignal("");660 const [duressSaving, setDuressSaving] = createSignal(false);661 const [duressError, setDuressError] = createSignal<string | null>(null);662 const [duressSuccess, setDuressSuccess] = createSignal<string | null>(null);663664 const [canaryEntries, setCanaryEntries] = createSignal<Array<{id: string; title: string; canary: Canary}>>([]);665666 const [canaryTestingId, setCanaryTestingId] = createSignal<string | null>(null);667 const [canaryTestResult, setCanaryTestResult] = createSignal<{id: string; ok: boolean} | null>(null);668669 const [fbCategory, setFbCategory] = createSignal("Bug report");670 const [fbMessage, setFbMessage] = createSignal("");671 type FeedbackState = 'idle' | 'sending' | 'sent' | 'error';672 const [feedbackState, setFeedbackState] = createSignal<FeedbackState>('idle');673674 createEffect(() => {675 if (props.upgradeHighlight) {676 setActiveCard("pro");677 }678 });679680 onMount(async () => {681 const [licenseRes, passkeyRes, duressRes, settingsRes, keyRes] = await Promise.all([682 sendMessage({ type: "GET_LICENSE" }),683 sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }),684 sendMessage({ type: "IS_DURESS_ENABLED" }),685 sendMessage({ type: "GET_SETTINGS" }),686 sendMessage({ type: "GET_LICENSE_KEY" }),687 ]);688689 690 691 void loadVaultHistory();692693 694 695 void loadPendingVouched();696 void loadPairedDevices();697 void loadSyncState();698 void loadGeneration();699 const onBroadcast = (msg: unknown) => {700 const m = msg as { type?: string; payload?: unknown } | null;701 if (m?.type === "VOUCHED_DEVICE_PENDING") {702 void loadPendingVouched();703 }704 if (m?.type === "SYNC_STATE_CHANGED") {705 const payload = (m.payload ?? {}) as SyncOutcomeSummary;706 setSyncSummary(payload);707 setSyncStatus(payload.status ?? "idle");708 }709 };710 chrome.runtime?.onMessage?.addListener(onBroadcast);711 onCleanup(() => {712 try { chrome.runtime?.onMessage?.removeListener(onBroadcast); } catch { }713 });714715 if ((licenseRes as { ok: boolean }).ok) {716 setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null);717 }718719 if ((passkeyRes as { ok: boolean }).ok) {720 setPasskeyState(721 (passkeyRes as { ok: boolean; data?: PasskeyProviderState }).data ?? null722 );723 }724725 if ((duressRes as { ok: boolean }).ok) {726 setDuressEnabled((duressRes as { ok: boolean; data?: boolean }).data ?? false);727 }728729 if ((settingsRes as { ok: boolean }).ok) {730 const sData = (settingsRes as { ok: boolean; data?: { declinedCardFingerprints?: string[]; pinEnabled?: boolean } }).data;731 setDeclinedCardCount(sData?.declinedCardFingerprints?.length ?? 0);732 setPinEnabled(sData?.pinEnabled === true);733 }734735 if ((keyRes as { ok: boolean }).ok) {736 setLicenseKey((keyRes as { ok: boolean; data?: string }).data ?? null);737 }738739 const entriesRes = await sendMessage({ type: "GET_ENTRIES" });740 if ((entriesRes as { ok: boolean }).ok) {741 const entries = (entriesRes as { ok: boolean; data?: Array<{id: string; title: string; canary?: Canary}> }).data ?? [];742 setCanaryEntries(743 entries744 .filter((e) => e.canary?.webhookUrl)745 .map((e) => ({ id: e.id, title: e.title, canary: e.canary! }))746 );747 }748749 });750751 function resetPinFlow() {752 setPinFlow(null);753 setPinCurrentPassword("");754 setPinValue("");755 setPinConfirm("");756 setPinError(null);757 }758759 async function handleSetupPin() {760 setPinError(null);761 if (!pinCurrentPassword()) { setPinError("enter your current password"); return; }762 if (!/^\d{4,12}$/.test(pinValue())) { setPinError("PIN must be 4–12 digits"); return; }763 if (pinValue() !== pinConfirm()) { setPinError("PINs do not match"); return; }764 setPinSaving(true);765 const res = await sendMessage({766 type: "SETUP_PIN",767 payload: { currentPassword: pinCurrentPassword(), pin: pinValue() },768 }) as { ok: boolean; error?: string };769 if (res.ok) {770 setPinEnabled(true);771 resetPinFlow();772 } else {773 setPinError(res.error === "incorrect_current_password" ? "incorrect current password" : "couldn't set up PIN");774 }775 setPinSaving(false);776 }777778 async function handleDisablePin() {779 setPinError(null);780 if (!pinCurrentPassword()) { setPinError("enter your current password"); return; }781 setPinSaving(true);782 const res = await sendMessage({783 type: "DISABLE_PIN",784 payload: { currentPassword: pinCurrentPassword() },785 }) as { ok: boolean; error?: string };786 if (res.ok) {787 setPinEnabled(false);788 resetPinFlow();789 } else {790 setPinError(res.error === "incorrect_current_password" ? "incorrect current password" : "couldn't remove PIN");791 }792 setPinSaving(false);793 }794795 async function handleChangePassword() {796 setChangePwError(null);797 if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; }798 if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; }799 if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; }800 if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; }801 setChangePwSaving(true);802 const res = await sendMessage({803 type: "CHANGE_PASSWORD",804 payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() },805 }) as { ok: boolean; error?: string };806 if (res.ok) {807 setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");808 setShowChangePw(false);809 810 811 812 await sendMessage({ type: "SET_RECOVERY_CONFIRM_PENDING" });813 setShowNewRecoveryWords(true);814 } else {815 setChangePwError(res.error ?? "failed to change password");816 }817 setChangePwSaving(false);818 }819820 const [copied, setCopied] = createSignal(false);821822 async function copyLicenseKey(key: string) {823 await navigator.clipboard.writeText(key);824 setCopied(true);825 setTimeout(() => setCopied(false), 2000);826 }827828 async function handleActivateLicense() {829 const key = licenseKeyInput().trim();830 if (!key) return;831 setActivatingLicense(true);832 setLicenseError(null);833 setLicenseSuccess(null);834 const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } });835 setActivatingLicense(false);836 if ((res as { ok: boolean }).ok) {837 const data = (res as { ok: boolean; data?: LicenseInfo }).data;838 if (data?.valid) {839 setLicenseInfo(data);840 setLicenseKey(key);841 setLicenseSuccess("License activated!");842 setLicenseKeyInput("");843 } else {844 setLicenseError("Invalid license key");845 }846 } else {847 setLicenseError("Activation failed");848 }849 }850851 async function handleTogglePasskeys() {852 const current = passkeyState();853 if (!current || !current.supported) return;854 setPasskeySaving(true);855 const res = await sendMessage({856 type: "SET_PASSKEY_PROVIDER_ENABLED",857 payload: { enabled: !current.enabled },858 });859 setPasskeySaving(false);860 if ((res as { ok: boolean }).ok) {861 setPasskeyState(862 (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null863 );864 }865 }866867 async function handleCheckAllBreaches() {868 setCheckingBreaches(true);869 setBreachResult(null);870 const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });871 setCheckingBreaches(false);872 if ((res as { ok: boolean }).ok) {873 const data = (res as { ok: boolean; data?: Record<string, number> }).data ?? {};874 const breached = Object.values(data).filter((count) => count > 0).length;875 setBreachResult(876 breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean"877 );878 props.onBreachCheckDone();879 }880 }881882883 async function handleSetupDuress() {884 const mainPw = duressMainPassword().trim();885 const duressPw = duressPassword().trim();886 const confirmPw = duressConfirm().trim();887888 if (!mainPw) { setDuressError("enter your main password to confirm"); return; }889 if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; }890 if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; }891 if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; }892893 setDuressSaving(true);894 setDuressError(null);895896 const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };897 if (!verifyRes.ok || !verifyRes.data) {898 setDuressError("incorrect main password");899 setDuressSaving(false);900 return;901 }902903 const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string };904 setDuressSaving(false);905 if (res.ok) {906 setDuressEnabled(true);907 setShowDuressSetup(false);908 setDuressPassword("");909 setDuressConfirm("");910 setDuressMainPassword("");911 setDuressSuccess("Duress password set. If entered, a decoy vault will be shown.");912 setTimeout(() => setDuressSuccess(null), 5000);913 } else {914 setDuressError(915 res.error === "session_expired"916 ? "Vault session expired — lock and unlock Peach, then try again"917 : res.error ?? "failed to set duress password"918 );919 }920 }921922 async function handleClearDeclinedCards() {923 await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } });924 setDeclinedCardCount(0);925 }926927 async function handleCountDuplicates() {928 setDedupRunning(true);929 setDedupDone(null);930 const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number };931 setDedupRunning(false);932 if (res.ok) setDedupCount(res.data ?? 0);933 }934935 async function handleDeduplicate() {936 setDedupRunning(true);937 const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number };938 setDedupRunning(false);939 if (res.ok) {940 setDedupDone(res.data ?? 0);941 setDedupCount(0);942 }943 }944945 async function handleDisableDuress() {946 const mainPw = duressMainPassword().trim();947 if (!mainPw) { setDuressError("enter your main password to confirm"); return; }948949 setDuressSaving(true);950 setDuressError(null);951952 const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };953 if (!verifyRes.ok || !verifyRes.data) {954 setDuressError("incorrect main password");955 setDuressSaving(false);956 return;957 }958959 const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string };960 setDuressSaving(false);961 if (res.ok) {962 setDuressEnabled(false);963 setShowDuressSetup(false);964 setDuressMainPassword("");965 setDuressSuccess("Duress password disabled.");966 setTimeout(() => setDuressSuccess(null), 4000);967 } else {968 setDuressError(res.error ?? "failed to disable duress password");969 }970 }971972 async function handleSubmitFeedback() {973 const message = sanitizeFeedbackMessage(fbMessage());974 if (!message) return;975976 setFeedbackState('sending');977978 try {979 const category = FEEDBACK_CATEGORIES.includes(fbCategory())980 ? fbCategory()981 : FEEDBACK_CATEGORIES[0];982 const res = await fetch('https://api.web3forms.com/submit', {983 method: 'POST',984 headers: { 'Content-Type': 'application/json' },985 body: JSON.stringify({986 access_key: WEB3FORMS_KEY,987 subject: `Peach Feedback - ${category}`,988 message,989 from_name: 'Peach User',990 991 botcheck: '',992 }),993 });994995 if (!res.ok) throw new Error(`HTTP ${res.status}`);996 const data = await res.json() as { success: boolean; message?: string };997 if (!data.success) throw new Error(data.message ?? 'unknown error');998999 setFeedbackState('sent');1000 setFbMessage('');1001 setFbCategory(FEEDBACK_CATEGORIES[0]);1002 } catch (err) {1003 console.error('[feedback]', err);1004 setFeedbackState('error');1005 setTimeout(() => setFeedbackState('idle'), 4000);1006 }1007 }10081009 function renderSecurity() {1010 return (1011 <div class="peach-settings-body">1012 <div class="peach-settings-group">1013 <p class="peach-settings-sub-label">LOCKING</p>1014 <div class="peach-settings-row">1015 <span>Lock on close</span>1016 <button1017 class={`peach-toggle ${props.lockOnClose ? "is-on" : ""}`}1018 onClick={() => void props.onLockOnCloseChange(!props.lockOnClose)}1019 aria-label="Toggle lock on close"1020 >1021 <span class="peach-toggle-knob"></span>1022 </button>1023 </div>1024 <div class="peach-settings-row">1025 <span>Auto-lock after</span>1026 <select1027 class="peach-settings-select"1028 value={String(props.autoLockMinutes)}1029 onChange={(e) => void props.onAutoLockMinutesChange(Number(e.currentTarget.value))}1030 >1031 <option value="1">1 minute</option>1032 <option value="5">5 minutes</option>1033 <option value="15">15 minutes</option>1034 <option value="30">30 minutes</option>1035 <option value="0">Never</option>1036 </select>1037 </div>1038 <p class="peach-settings-sub-label">SYNC WHILE LOCKED</p>1039 <div class="peach-settings-row">1040 <label for="locked-sync-custody">Private key custody</label>1041 <select1042 id="locked-sync-custody"1043 class="peach-settings-select"1044 value={props.lockedSyncRequiresUnlock ? "unlock_required" : "session"}1045 onChange={(e) => void props.onLockedSyncRequiresUnlockChange(e.currentTarget.value === "unlock_required")}1046 >1047 <option value="session">Keep Sync available</option>1048 <option value="unlock_required">Require unlock after restart</option>1049 </select>1050 </div>1051 <p class="peach-settings-help">1052 {props.lockedSyncRequiresUnlock1053 ? "The Sync identity is not persisted while locked. Unlock Peach after a background restart to resume Sync."1054 : "Keeps locked Sync available during this browser session."}1055 </p>1056 <p class="peach-settings-sub-label">QUICK UNLOCK</p>1057 <div class="peach-settings-row peach-settings-row--meta">1058 <span>PIN unlock</span>1059 <button1060 class="peach-btn peach-btn--subtle peach-btn--sm"1061 onClick={() => { setPinFlow(pinEnabled() ? "disable" : "setup"); setPinError(null); }}1062 >1063 {pinEnabled() ? "remove" : "set up"}1064 </button>1065 </div>1066 <Show when={pinFlow() === "setup"}>1067 <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1068 <input1069 class="peach-settings-input"1070 type="password"1071 placeholder="current password"1072 value={pinCurrentPassword()}1073 onInput={(e) => setPinCurrentPassword((e.target as HTMLInputElement).value)}1074 autocomplete="current-password"1075 />1076 <input1077 class="peach-settings-input"1078 type="password"1079 inputmode="numeric"1080 maxlength="12"1081 placeholder="new 4–12 digit PIN"1082 value={pinValue()}1083 onInput={(e) => setPinValue((e.target as HTMLInputElement).value.replace(/\D/g, "").slice(0, 12))}1084 autocomplete="off"1085 />1086 <input1087 class="peach-settings-input"1088 type="password"1089 inputmode="numeric"1090 maxlength="12"1091 placeholder="confirm PIN"1092 value={pinConfirm()}1093 onInput={(e) => setPinConfirm((e.target as HTMLInputElement).value.replace(/\D/g, "").slice(0, 12))}1094 autocomplete="off"1095 onKeyDown={(e) => { if (e.key === "Enter") void handleSetupPin(); }}1096 />1097 <Show when={pinError()}>1098 <p class="peach-hint peach-hint-error" style="margin:0">{pinError()}</p>1099 </Show>1100 <button1101 class="peach-btn peach-btn--accent peach-btn--sm"1102 style="align-self:flex-start"1103 onClick={() => void handleSetupPin()}1104 disabled={pinSaving() || !pinCurrentPassword() || !/^\d{4,12}$/.test(pinValue()) || !/^\d{4,12}$/.test(pinConfirm())}1105 >1106 {pinSaving() ? "saving…" : "save PIN"}1107 </button>1108 </div>1109 </Show>1110 <Show when={pinFlow() === "disable"}>1111 <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1112 <input1113 class="peach-settings-input"1114 type="password"1115 placeholder="current password"1116 value={pinCurrentPassword()}1117 onInput={(e) => setPinCurrentPassword((e.target as HTMLInputElement).value)}1118 autocomplete="current-password"1119 onKeyDown={(e) => { if (e.key === "Enter") void handleDisablePin(); }}1120 />1121 <Show when={pinError()}>1122 <p class="peach-hint peach-hint-error" style="margin:0">{pinError()}</p>1123 </Show>1124 <button1125 class="peach-btn peach-btn--subtle peach-btn--sm"1126 style="align-self:flex-start"1127 onClick={() => void handleDisablePin()}1128 disabled={pinSaving() || !pinCurrentPassword()}1129 >1130 {pinSaving() ? "removing…" : "remove PIN"}1131 </button>1132 </div>1133 </Show>11341135 <p class="peach-settings-sub-label">BREACH PROTECTION</p>1136 <div class="peach-settings-row">1137 <span>Breach monitoring</span>1138 <button1139 class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}1140 onClick={() => void props.onBreachMonitoringChange(!props.breachMonitoring)}1141 aria-label="Toggle breach monitoring"1142 >1143 <span class="peach-toggle-knob"></span>1144 </button>1145 </div>1146 <div class="peach-settings-row peach-settings-row--meta">1147 <span>Check all passwords</span>1148 <button1149 class="peach-btn peach-btn--subtle peach-btn--sm"1150 onClick={() => void handleCheckAllBreaches()}1151 disabled={checkingBreaches()}1152 >1153 {checkingBreaches() ? "checking..." : "run"}1154 </button>1155 </div>1156 <Show when={breachResult()}>1157 <div class="peach-settings-row peach-settings-row--meta">1158 <span>Last check</span>1159 <span class="peach-settings-value">{breachResult()}</span>1160 </div>1161 </Show>11621163 <p class="peach-settings-sub-label">MASTER PASSWORD</p>1164 <div class="peach-settings-row peach-settings-row--meta">1165 <span>Master password</span>1166 <button1167 class="peach-btn peach-btn--subtle peach-btn--sm"1168 onClick={() => { setShowChangePw((v) => !v); setChangePwError(null); }}1169 >1170 {showChangePw() ? "cancel" : "change"}1171 </button>1172 </div>1173 <Show when={showChangePw()}>1174 <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1175 <input1176 class="peach-settings-input"1177 type="password"1178 placeholder="current password"1179 value={changePwCurrent()}1180 onInput={(e) => setChangePwCurrent((e.target as HTMLInputElement).value)}1181 autocomplete="current-password"1182 />1183 <input1184 class="peach-settings-input"1185 type="password"1186 placeholder="new password (min 12 chars)"1187 value={changePwNew()}1188 onInput={(e) => setChangePwNew((e.target as HTMLInputElement).value)}1189 autocomplete="new-password"1190 />1191 <input1192 class="peach-settings-input"1193 type="password"1194 placeholder="confirm new password"1195 value={changePwConfirm()}1196 onInput={(e) => setChangePwConfirm((e.target as HTMLInputElement).value)}1197 autocomplete="new-password"1198 onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }}1199 />1200 <Show when={changePwError()}>1201 <p class="peach-hint peach-hint-error" style="margin:0">{changePwError()}</p>1202 </Show>1203 <Show when={changePwSuccess()}>1204 <p class="peach-hint" style="margin:0;color:var(--peach-accent)">password changed ✓</p>1205 </Show>1206 <button1207 class="peach-btn peach-btn--accent peach-btn--sm"1208 style="align-self:flex-start"1209 onClick={() => void handleChangePassword()}1210 disabled={changePwSaving() || !changePwCurrent() || !changePwNew() || !changePwConfirm()}1211 >1212 {changePwSaving() ? "saving…" : "save new password"}1213 </button>1214 </div>1215 </Show>12161217 {}1218 <div class="peach-settings-row peach-settings-row--meta">1219 <span>Recovery words</span>1220 <button1221 class="peach-btn peach-btn--subtle peach-btn--sm"1222 onClick={() => {1223 if (showViewWords()) { closeViewWords(); } else { setShowViewWords(true); }1224 }}1225 >1226 {showViewWords() ? "cancel" : "view"}1227 </button>1228 </div>1229 <Show when={showViewWords()}>1230 <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1231 <Show1232 when={viewedWords()}1233 fallback={1234 <>1235 <p class="peach-hint" style="margin:0;color:var(--peach-t2)">1236 Enter your master password to reveal your recovery words.1237 </p>1238 <input1239 class="peach-settings-input"1240 type="password"1241 placeholder="master password"1242 value={viewWordsPassword()}1243 onInput={(e) => setViewWordsPassword((e.target as HTMLInputElement).value)}1244 autocomplete="current-password"1245 onKeyDown={(e) => { if (e.key === "Enter") void handleViewRecoveryWords(); }}1246 />1247 <Show when={viewWordsError()}>1248 <p class="peach-hint peach-hint-error" style="margin:0">{viewWordsError()}</p>1249 </Show>1250 <button1251 class="peach-btn peach-btn--accent peach-btn--sm"1252 style="align-self:flex-start"1253 onClick={() => void handleViewRecoveryWords()}1254 disabled={viewWordsBusy() || !viewWordsPassword()}1255 >1256 {viewWordsBusy() ? "checking…" : "reveal recovery words"}1257 </button>1258 </>1259 }1260 >1261 <div class="peach-recovery-words-grid">1262 <For each={viewedWords()!}>1263 {(word, i) => (1264 <span class="peach-recovery-word"><b>{i() + 1}</b> {word}</span>1265 )}1266 </For>1267 </div>1268 <button1269 class="peach-btn peach-btn--subtle peach-btn--sm"1270 style="align-self:flex-start"1271 onClick={closeViewWords}1272 >done</button>1273 </Show>1274 </div>1275 </Show>12761277 <p class="peach-settings-sub-label">1278 DURESS PASSWORD1279 <Show when={!isPro()}>1280 <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>1281 </Show>1282 </p>1283 <div class="peach-settings-row peach-settings-row--top">1284 <div class="peach-settings-row-main">1285 <span>1286 Duress password1287 <Show when={!isPro()}>1288 <span class="peach-pro-badge">Pro</span>1289 </Show>1290 </span>1291 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">1292 If entered under coercion, shows a decoy empty vault instead of your real one.1293 </span>1294 </div>1295 <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>1296 {duressEnabled() ? "on" : "off"}1297 </div>1298 </div>12991300 <Show when={duressEnabled() && !showDuressSetup()}>1301 <div class="peach-settings-row peach-settings-row--meta">1302 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1303 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.1304 </span>1305 </div>1306 </Show>13071308 <Show when={duressSuccess()}>1309 <div class="peach-settings-row peach-settings-row--meta">1310 <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>1311 </div>1312 </Show>13131314 <Show when={!showDuressSetup()}>1315 <div class="peach-settings-row peach-settings-row--meta">1316 <button1317 class="peach-btn peach-btn--subtle peach-btn--sm"1318 onClick={() => {1319 1320 1321 1322 1323 1324 if (!isPro() && !duressEnabled()) {1325 props.onProGate?.("duress_vault");1326 setActiveCard("pro");1327 return;1328 }1329 setShowDuressSetup(true);1330 setDuressError(null);1331 setDuressSuccess(null);1332 setDuressPassword("");1333 setDuressConfirm("");1334 setDuressMainPassword("");1335 }}1336 >1337 {duressEnabled() ? "Change / disable" : "Set up"}1338 </button>1339 </div>1340 </Show>13411342 <Show when={showDuressSetup()}>1343 <div class="peach-duress-form">1344 <label class="peach-label">main password (to confirm)</label>1345 <input1346 class="peach-settings-input"1347 type="password"1348 placeholder="your current master password"1349 autocomplete="current-password"1350 value={duressMainPassword()}1351 onInput={(e) => setDuressMainPassword(e.currentTarget.value)}1352 />1353 <Show when={!duressEnabled()}>1354 <label class="peach-label">new duress password</label>1355 <input1356 class="peach-settings-input"1357 type="password"1358 placeholder="duress password (min 8 chars)"1359 autocomplete="new-password"1360 value={duressPassword()}1361 onInput={(e) => setDuressPassword(e.currentTarget.value)}1362 />1363 <label class="peach-label">confirm duress password</label>1364 <input1365 class="peach-settings-input"1366 type="password"1367 placeholder="confirm duress password"1368 autocomplete="new-password"1369 value={duressConfirm()}1370 onInput={(e) => setDuressConfirm(e.currentTarget.value)}1371 />1372 </Show>13731374 <Show when={duressError()}>1375 <p class="peach-hint peach-hint-error">{duressError()}</p>1376 </Show>13771378 <div class="peach-duress-actions">1379 <button1380 class="peach-btn peach-btn--subtle peach-btn--sm"1381 onClick={() => { setShowDuressSetup(false); setDuressError(null); }}1382 >1383 cancel1384 </button>1385 <Show when={duressEnabled()}>1386 <button1387 class="peach-btn peach-btn--ghost peach-btn--sm"1388 style="color:var(--peach-breach)"1389 onClick={() => void handleDisableDuress()}1390 disabled={duressSaving()}1391 >1392 {duressSaving() ? "disabling..." : "disable duress"}1393 </button>1394 </Show>1395 <Show when={!duressEnabled()}>1396 <button1397 class="peach-btn peach-btn--accent peach-btn--sm"1398 onClick={() => void handleSetupDuress()}1399 disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}1400 >1401 {duressSaving() ? "saving..." : "save"}1402 </button>1403 </Show>1404 </div>1405 </div>1406 </Show>14071408 <p class="peach-settings-sub-label">1409 CANARY ENTRIES1410 <Show when={!isPro()}>1411 <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>1412 </Show>1413 </p>1414 <div class="peach-settings-row peach-settings-row--top">1415 <div class="peach-settings-row-main">1416 <span>1417 Canary entries1418 <Show when={!isPro()}>1419 <span class="peach-pro-badge">Pro</span>1420 </Show>1421 </span>1422 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1423 Silently pings a webhook when an entry is accessed. Configure canary on any entry in your vault.1424 </span>1425 </div>1426 <span class="peach-settings-value" style="flex-shrink:0">1427 {canaryEntries().length > 0 ? `${canaryEntries().length} active` : "none"}1428 </span>1429 </div>1430 <Show when={canaryEntries().length > 0}>1431 <For each={canaryEntries()}>1432 {(entry) => (1433 <div class="peach-settings-row peach-settings-row--meta">1434 <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0">{entry.title}</span>1435 <button1436 class="peach-btn peach-btn--subtle peach-btn--sm"1437 onClick={() => void handleTestCanary(entry)}1438 disabled={canaryTestingId() === entry.id}1439 style="flex-shrink:0"1440 >1441 {canaryTestingId() === entry.id1442 ? "sending…"1443 : canaryTestResult()?.id === entry.id1444 ? (canaryTestResult()!.ok ? "sent ✓" : "failed ✗")1445 : "test"}1446 </button>1447 </div>1448 )}1449 </For>1450 </Show>14511452 <p class="peach-settings-sub-label">PASSKEYS</p>1453 <div class="peach-settings-row peach-settings-row--top">1454 <div class="peach-settings-row-main">1455 <span>Intercept passkey requests</span>1456 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">1457 Peach can handle passkey sign-in and creation on supported Chromium browsers.1458 </span>1459 </div>1460 <button1461 class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}1462 onClick={() => void handleTogglePasskeys()}1463 aria-label="Toggle passkey interception"1464 disabled={!passkeyState()?.supported || passkeySaving()}1465 >1466 <span class="peach-toggle-knob"></span>1467 </button>1468 </div>1469 <div class="peach-settings-row peach-settings-row--meta">1470 <span>Status</span>1471 <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>1472 </div>1473 <div class="peach-settings-row peach-settings-row--meta">1474 <span>Browser</span>1475 <span class="peach-settings-value">1476 {passkeyState()?.browserLabel ?? "Unknown"}1477 {passkeyState()?.supported ? " ✓" : " (interception unavailable)"}1478 </span>1479 </div>1480 <div class="peach-info-notice" style="margin-top:8px">1481 Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.1482 </div>1483 <Show when={passkeyState()?.conflict}>1484 <div class="peach-settings-row peach-settings-row--meta">1485 <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>1486 </div>1487 </Show>1488 </div>1489 </div>1490 );1491 }14921493 function renderVault() {1494 return (1495 <div class="peach-settings-body">1496 <div class="peach-settings-group">1497 <p class="peach-settings-sub-label">ENTRIES</p>1498 <div class="peach-settings-row peach-settings-row--meta">1499 <span>Entries</span>1500 <span class="peach-settings-value">{props.entriesCount}</span>1501 </div>1502 <div class="peach-settings-row peach-settings-row--meta">1503 <span>Duplicates</span>1504 <Show when={dedupDone() !== null} fallback={1505 <button1506 class="peach-btn peach-btn--subtle peach-btn--sm"1507 onClick={() => void handleCountDuplicates()}1508 disabled={dedupRunning()}1509 >1510 {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}1511 </button>1512 }>1513 <span class="peach-settings-value" style="color:var(--peach-sync-ok)">1514 {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}1515 </span>1516 </Show>1517 </div>1518 <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>1519 <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">1520 <div class="peach-restore-warning">1521 {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.1522 </div>1523 <div style="display:flex;gap:6px">1524 <button1525 class="peach-btn peach-btn--subtle peach-btn--sm"1526 onClick={() => setDedupCount(null)}1527 >1528 cancel1529 </button>1530 <button1531 class="peach-btn peach-btn--accent peach-btn--sm"1532 onClick={() => void handleDeduplicate()}1533 disabled={dedupRunning()}1534 >1535 {dedupRunning() ? "removing..." : "remove duplicates"}1536 </button>1537 </div>1538 </div>1539 </Show>15401541 <p class="peach-settings-sub-label">MAINTENANCE</p>1542 <div class="peach-settings-row peach-settings-row--meta">1543 <span>Trash auto-delete</span>1544 <select1545 class="peach-settings-select"1546 value={String(props.trashRetentionDays)}1547 onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}1548 >1549 <option value="7">7 days</option>1550 <option value="30">30 days</option>1551 <option value="90">90 days</option>1552 <option value="0">Never</option>1553 </select>1554 </div>1555 <div class="peach-settings-row peach-settings-row--meta">1556 <span>Advanced Vault Health</span>1557 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>1558 open1559 </button>1560 </div>1561 <Show when={declinedCardCount() > 0}>1562 <div class="peach-settings-row peach-settings-row--meta">1563 <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>1564 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>1565 Clear1566 </button>1567 </div>1568 </Show>15691570 <p class="peach-settings-sub-label">DATA</p>1571 <div class="peach-settings-row peach-settings-row--meta">1572 <span>Import passwords</span>1573 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>1574 open1575 </button>1576 </div>1577 <div class="peach-settings-row peach-settings-row--meta">1578 <span>Export backup</span>1579 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onExport}>1580 open1581 </button>1582 </div>1583 <div class="peach-settings-row peach-settings-row--meta">1584 <span>1585 Peach Codex1586 <Show when={!isPro()}>1587 <span class="peach-pro-badge">Pro</span>1588 </Show>1589 </span>1590 <button1591 class="peach-btn peach-btn--subtle peach-btn--sm"1592 onClick={() => props.onOpenCodexGenerate?.()}1593 >1594 open1595 </button>1596 </div>1597 {1598}1599 <div class="peach-settings-row peach-settings-row--meta">1600 <span>Restore from Codex</span>1601 <button1602 class="peach-btn peach-btn--subtle peach-btn--sm"1603 onClick={() => props.onOpenCodexRestore?.()}1604 >1605 open1606 </button>1607 </div>16081609 <Show when={hasReleaseNotes()}>1610 <p class="peach-settings-sub-label">RELEASE NOTES</p>1611 <div class="peach-settings-row peach-settings-row--meta">1612 <span>v{PEACH_VERSION}</span>1613 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => setShowWhatsNew(true)}>1614 view1615 </button>1616 </div>1617 </Show>16181619 <span class="peach-settings-sub-label">VAULT HISTORY</span>1620 <Show when={historyLoading()}>1621 <p class="peach-settings-hint">Loading…</p>1622 </Show>1623 <Show when={!historyLoading() && historySnapshots().length === 0}>1624 <p class="peach-settings-hint">1625 No snapshots yet. Peach saves automatically before every change —1626 saves, syncs, and imports.1627 </p>1628 </Show>1629 <Show when={!historyLoading() && historySnapshots().length > 0}>1630 <p class="peach-settings-hint">1631 Restoring locks your vault. Unlock with the password that1632 encrypted that snapshot to confirm.1633 </p>1634 <div class="peach-history-list">1635 <For each={historySnapshots().slice(0, historyShowAll() ? undefined : 3)}>1636 {(snap) => (1637 <div class="peach-history-row">1638 <div class="peach-history-meta">1639 <span class="peach-history-time">1640 {formatSnapshotTime(snap.savedAt)}1641 </span>1642 <span class="peach-history-sub">1643 {[1644 snap.entryCount !== null1645 ? `up to ${snap.entryCount} ${snap.entryCount === 1 ? "entry" : "entries"}`1646 : null,1647 reasonLabel(snap.reason),1648 ]1649 .filter(Boolean)1650 .join(" · ")}1651 </span>1652 </div>1653 <button1654 class={`peach-btn peach-btn--subtle peach-btn--sm peach-history-btn${1655 historyRestoring() === snap.id ? " is-busy" : ""1656 }`}1657 onClick={() => void handleRestoreVaultSnapshot(snap.id)}1658 disabled={historyRestoring() !== null}1659 >1660 <Show when={historyRestoring() === snap.id} fallback="Restore">1661 …1662 </Show>1663 </button>1664 </div>1665 )}1666 </For>1667 </div>1668 <Show when={historySnapshots().length > 3}>1669 <button1670 class="peach-settings-advanced-toggle"1671 onClick={() => setHistoryShowAll((v) => !v)}1672 aria-expanded={historyShowAll()}1673 >1674 <span class="peach-settings-hint" style="margin:0">1675 {historyShowAll() ? "show less" : `${historySnapshots().length - 3} older`}1676 </span>1677 <span class="peach-settings-advanced-chevron">1678 {historyShowAll() ? "▲" : "▼"}1679 </span>1680 </button>1681 </Show>1682 <p class="peach-history-footer">1683 {historySnapshots().length} of 10 snapshots1684 </p>1685 </Show>16861687 <button1688 class="peach-settings-advanced-toggle"1689 onClick={() => setShowVaultAdvanced((v) => !v)}1690 aria-expanded={showVaultAdvanced()}1691 >1692 <span class="peach-settings-sub-label" style="margin:0">ADVANCED</span>1693 <span class="peach-settings-advanced-chevron">1694 {showVaultAdvanced() ? "▲" : "▼"}1695 </span>1696 </button>16971698 <Show when={showVaultAdvanced()}>1699 <div class="peach-danger-card">1700 <div class="peach-danger-card-head">1701 <span class="peach-danger-card-glyph" aria-hidden="true">⟳</span>1702 <h3 class="peach-danger-card-title">Replace vault from backup</h3>1703 </div>1704 <p class="peach-danger-card-copy">1705 Wipes the current vault and restores from a backup file.1706 Recoverable only if your backup is good.1707 </p>1708 <button1709 class="peach-danger-card-button"1710 onClick={() => props.onOpenFileImport?.()}1711 >1712 Choose backup file1713 </button>1714 </div>17151716 <Show when={props.onOpenPanic}>1717 <div class="peach-danger-card peach-danger-card--hazard">1718 <div class="peach-danger-card-head">1719 <span class="peach-danger-card-glyph" aria-hidden="true">⚠</span>1720 <h3 class="peach-danger-card-title">Destroy vault</h3>1721 </div>1722 <p class="peach-danger-card-copy">1723 Permanently wipes every entry, setting, and license on this1724 device. There is no recovery.1725 </p>1726 <button1727 class="peach-danger-card-button"1728 onClick={() => props.onOpenPanic?.()}1729 >1730 Begin destruction1731 </button>1732 </div>1733 </Show>1734 </Show>1735 </div>1736 </div>1737 );1738 }17391740 function renderSync() {1741 return (1742 <div class="peach-settings-body">1743 <div class="peach-settings-group">1744 <p class="peach-settings-sub-label" style="margin-top:0">SYNC STATUS</p>1745 <Show when={trustDataCorrupted()}>1746 <p class="peach-settings-value" role="alert" style="color:var(--peach-danger,#e5805c);line-height:1.5">1747 Device trust data is damaged — re-pair your devices after updating1748 </p>1749 </Show>1750 <div1751 class="peach-settings-row peach-settings-row--meta"1752 style="align-items:center;gap:8px"1753 aria-label={syncLightLabel()}1754 title={syncLightLabel()}1755 >1756 <span class={`stk-pip stk-pip--sync is-${syncLight()}`} aria-hidden="true" />1757 <span class="peach-settings-value" style="color:var(--peach-t2);line-height:1.5;flex:1;min-width:0;overflow-wrap:anywhere">1758 {syncFlash() ?? lastSyncLine()}1759 </span>1760 {17611762}1763 <Show when={pairedDevices().length > 0}>1764 <button1765 class="peach-btn peach-btn--subtle peach-btn--sm"1766 aria-label="Sync this vault now"1767 disabled={syncStatus() === "syncing"}1768 onClick={() => void syncNow()}1769 >{syncStatus() === "syncing" ? "syncing…" : "sync now"}</button>1770 </Show>1771 </div>17721773 {17741775}1776 <Show when={generation()?.state && generation()?.state !== "V2_ACTIVE"}>1777 <p class="peach-settings-sub-label">SECURE SYNC RESET</p>1778 <div class="peach-settings-row peach-settings-row--top">1779 <div class="peach-settings-row-main">1780 <span class="peach-settings-value" style="color:var(--peach-t1)">1781 {secureResetHeadline(generation()?.state)}1782 </span>1783 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1784 {secureResetDetail(generation()?.state)}1785 </span>1786 </div>1787 <Show1788 when={1789 generation()?.state === "LEGACY_ACTIVE" ||1790 generation()?.state === "RESET_REQUIRED" ||1791 generation()?.state === "RESET_CONFIRMED"1792 }1793 >1794 <button1795 class="peach-btn peach-btn--subtle peach-btn--sm"1796 onClick={() => {1797 setResetError(null);1798 setResetPassword("");1799 setResetOpen(true);1800 }}1801 >reset sync</button>1802 </Show>1803 </div>1804 <Show when={resetError()}>1805 <p1806 class="peach-settings-value"1807 role="alert"1808 style="color:var(--peach-danger,#e5805c);font-size:11px;line-height:1.5"1809 >{resetError()}</p>1810 </Show>1811 <Show when={resetOpen()}>1812 <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1813 <p class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1814 {SECURE_RESET_DISCLOSURE}1815 </p>1816 <input1817 class="peach-settings-input"1818 type="password"1819 placeholder="master password"1820 value={resetPassword()}1821 onInput={(e) => setResetPassword((e.target as HTMLInputElement).value)}1822 autocomplete="current-password"1823 />1824 <div style="display:flex;gap:4px">1825 <button1826 class="peach-btn peach-btn--subtle peach-btn--sm"1827 disabled={resetting()}1828 onClick={() => void runSecureReset()}1829 >{resetting() ? "resetting…" : "reset"}</button>1830 <button1831 class="peach-btn peach-btn--subtle peach-btn--sm"1832 disabled={resetting()}1833 onClick={() => setResetOpen(false)}1834 >cancel</button>1835 </div>1836 </div>1837 </Show>1838 </Show>18391840 <Show when={pairedDevices().length > 0}>1841 <p class="peach-settings-sub-label">PAIRED DEVICES</p>1842 <For each={pairedDevices()}>1843 {(d) => (1844 <div class="peach-settings-row peach-settings-row--top">1845 <div class="peach-settings-row-main">1846 <Show1847 when={renameDeviceId() === d.deviceId}1848 fallback={<span>{d.localAlias ?? d.deviceName ?? "Unnamed device"}</span>}1849 >1850 <input1851 class="peach-settings-input"1852 aria-label={`Rename ${d.localAlias ?? d.deviceName ?? "device"}`}1853 maxlength={64}1854 value={renameDraft()}1855 onInput={(event) => setRenameDraft(event.currentTarget.value)}1856 onKeyDown={(event) => {1857 if (event.key === "Enter") void renameDevice(d.deviceId);1858 if (event.key === "Escape") setRenameDeviceId(null);1859 }}1860 />1861 </Show>1862 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1863 {deviceTypeLabel(d.deviceType)} · {syncRelativeTime(d.lastSyncedAt)}{isSyncStale(d) ? " · not seen recently" : ""}1864 </span>1865 </div>1866 <Show1867 when={confirmUnpair() === d.deviceId}1868 fallback={1869 <div style="display:flex;gap:4px;flex-shrink:0">1870 <Show1871 when={renameDeviceId() === d.deviceId}1872 fallback={1873 <button1874 class="peach-btn peach-btn--subtle peach-btn--sm"1875 onClick={() => {1876 setRenameDraft(d.localAlias ?? d.deviceName ?? "");1877 setRenameDeviceId(d.deviceId);1878 }}1879 >rename</button>1880 }1881 >1882 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => setRenameDeviceId(null)}>cancel</button>1883 <button class="peach-btn peach-btn--subtle peach-btn--sm" disabled={!renameDraft().trim()} onClick={() => void renameDevice(d.deviceId)}>save</button>1884 </Show>1885 <button1886 class="peach-btn peach-btn--subtle peach-btn--sm"1887 onClick={() => {1888 setUnpairError(null);1889 setConfirmUnpair(d.deviceId);1890 }}1891 >remove</button>1892 </div>1893 }1894 >1895 {18961897189818991900}1901 <div class="peach-unpair-confirm">1902 <p class="peach-unpair-confirm-note">1903 Stops this device receiving future changes. It doesn't1904 lock it out of what it already has — if it's lost or1905 stolen, also change your master password and generate1906 new recovery words.1907 </p>1908 <div style="display:flex;gap:6px;justify-content:flex-end">1909 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => setConfirmUnpair(null)}>cancel</button>1910 <button1911 class="peach-btn peach-btn--sm"1912 style="background:var(--peach-breach);border-color:var(--peach-breach)"1913 onClick={() => void unpairDevice(d.deviceId)}1914 >remove</button>1915 </div>1916 </div>1917 <Show when={unpairError()}>1918 <p class="peach-settings-value" role="alert" style="color:var(--peach-danger,#e5805c);font-size:11px;line-height:1.5">1919 {unpairError()}1920 </p>1921 </Show>1922 </Show>1923 </div>1924 )}1925 </For>1926 </Show>19271928 <Show when={pendingVouched().length > 0}>1929 <p class="peach-settings-sub-label">1930 PENDING DEVICES1931 <span class="peach-pro-badge" style="background:#C8A840;color:#1a1a1a">{pendingVouched().length}</span>1932 </p>1933 <For each={pendingVouched()}>1934 {(dev) => (1935 <div class="peach-settings-row peach-settings-row--top">1936 <div class="peach-settings-row-main">1937 <span>{dev.deviceName}</span>1938 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1939 Key {dev.longTermPublicKeyHex.slice(0, 12).match(/.{4}/g)?.join(" ")} ·1940 requested {new Date(dev.receivedAt).toISOString().slice(0, 16).replace("T", " ")} UTC ·1941 vouched by device {dev.vouchedByDeviceId}. Allowing it can read1942 existing vault contents and all future changes.1943 </span>1944 </div>1945 <div style="display:flex;gap:6px;flex-shrink:0">1946 <button1947 class="peach-btn peach-btn--sm"1948 disabled={vouchedBusy() === dev.deviceId}1949 onClick={() => void confirmVouched(dev.deviceId)}1950 >Allow</button>1951 <button1952 class="peach-btn peach-btn--subtle peach-btn--sm"1953 disabled={vouchedBusy() === dev.deviceId}1954 onClick={() => void rejectVouched(dev.deviceId)}1955 >Reject</button>1956 </div>1957 </div>1958 )}1959 </For>1960 </Show>19611962 <p class="peach-settings-sub-label">ADD A DEVICE</p>1963 <button1964 class="peach-sync-pair-option"1965 onClick={() => props.onOpenSyncPairing?.("code")}1966 >1967 <span class="peach-sync-pair-option-icon" aria-hidden="true">1968 <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">1969 <rect x="6" y="2.5" width="8" height="15" rx="1.6" />1970 <path d="M9 15 L11 15" />1971 </svg>1972 </span>1973 <span class="peach-sync-pair-option-main">1974 <span class="peach-sync-pair-option-title">Show a code</span>1975 <span class="peach-sync-pair-option-sub">Scan it with Peach on your phone, then confirm one 8-digit code</span>1976 </span>1977 <span class="peach-sync-pair-option-chevron" aria-hidden="true">›</span>1978 </button>1979 <Show when={!isPro()}>1980 <p class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2);margin:8px 2px 0">1981 At least one device needs Peach Pro — pair with a Pro device and it carries over to here.1982 </p>1983 </Show>19841985 <button1986 class="peach-settings-advanced-toggle peach-sync-disclosure-toggle"1987 onClick={() => setShowSyncConnection((value) => !value)}1988 aria-expanded={showSyncConnection()}1989 aria-controls="peach-sync-connection"1990 >1991 <span>1992 <span class="peach-settings-sub-label" style="margin:0">CONNECTION</span>1993 <span class="peach-sync-disclosure-summary">Relay and privacy choices</span>1994 </span>1995 <span class="peach-settings-advanced-chevron">1996 {showSyncConnection() ? "▲" : "▼"}1997 </span>1998 </button>1999 <Show when={showSyncConnection()}>2000 <div id="peach-sync-connection" class="peach-sync-disclosure-content">2001 <SyncModeSelector />2002 </div>2003 </Show>20042005 <button2006 class="peach-settings-advanced-toggle peach-sync-disclosure-toggle"2007 onClick={() => setShowSyncRecovery((value) => !value)}2008 aria-expanded={showSyncRecovery()}2009 aria-controls="peach-sync-recovery"2010 >2011 <span>2012 <span class="peach-settings-sub-label" style="margin:0">RECOVERY & SECURITY</span>2013 <span class="peach-sync-disclosure-summary">For a lost or stolen device</span>2014 </span>2015 <span class="peach-settings-advanced-chevron">2016 {showSyncRecovery() ? "▲" : "▼"}2017 </span>2018 </button>2019 <Show when={showSyncRecovery()}>2020 <div id="peach-sync-recovery" class="peach-sync-disclosure-content">2021 <Show2022 when={rotateConfirmOpen()}2023 fallback={2024 <div class="peach-settings-row peach-settings-row--top">2025 <div class="peach-settings-row-main">2026 <span>Rotate sync identity</span>2027 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">2028 Disconnects every device and replaces the keys used for sync.2029 </span>2030 </div>2031 <button2032 class="peach-btn peach-btn--subtle peach-btn--sm"2033 style="color:var(--peach-breach);border-color:color-mix(in srgb,var(--peach-breach) 40%,transparent)"2034 onClick={() => { setRotateError(null); setRotateConfirmOpen(true); }}2035 >rotate</button>2036 </div>2037 }2038 >2039 <div class="peach-info-notice" style="margin-top:8px;border-color:color-mix(in srgb,var(--peach-breach) 40%,transparent)">2040 This disconnects every paired device and changes your 12 recovery words. You'll be shown the new words next.2041 <Show when={rotateError()}>2042 <div style="color:var(--peach-breach);margin-top:6px">{rotateError()}</div>2043 </Show>2044 <div style="display:flex;gap:6px;margin-top:10px">2045 <button2046 class="peach-btn peach-btn--subtle peach-btn--sm"2047 disabled={rotating()}2048 onClick={() => setRotateConfirmOpen(false)}2049 >Cancel</button>2050 <button2051 class="peach-btn peach-btn--sm"2052 style="background:var(--peach-breach);border-color:var(--peach-breach)"2053 disabled={rotating()}2054 onClick={() => void doRotateSyncIdentity()}2055 >{rotating() ? "Rotating…" : "Rotate sync identity"}</button>2056 </div>2057 </div>2058 </Show>2059 </div>2060 </Show>2061 </div>2062 </div>2063 );2064 }20652066 function renderAppearance() {2067 const sizeOptions: Array<{ id: "medium" | "large"; label: string }> = [2068 { id: "medium", label: "Default" },2069 { id: "large", label: "Large" },2070 ];2071 return (2072 <div class="peach-settings-body">2073 <div class="peach-settings-group">2074 <p class="peach-settings-sub-label">THEME</p>2075 <div class="peach-settings-row peach-settings-row--meta">2076 <span>Theme</span>2077 <button2078 class="peach-btn peach-btn--subtle peach-btn--sm"2079 onClick={() =>2080 props.onThemeChange(props.theme === "dark" ? "light" : "dark")2081 }2082 >2083 {props.theme === "dark" ? "Switch to light" : "Switch to dark"}2084 </button>2085 </div>20862087 <p class="peach-settings-sub-label">ACCENT</p>2088 <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">2089 <span>Accent</span>2090 <div class="peach-accent-picker">2091 <For each={ACCENT_COLORS}>2092 {(color) => (2093 <button2094 class="peach-accent-swatch peach-swatch"2095 classList={{ "is-active": props.accentColor === color.name }}2096 style={{ background: color.hex }}2097 onClick={() => void props.onAccentChange(color.name)}2098 aria-label={color.name}2099 title={color.name}2100 />2101 )}2102 </For>2103 </div>2104 </div>21052106 <p class="peach-settings-sub-label">TEXT SIZE</p>2107 <div class="peach-settings-row peach-settings-row--meta">2108 <span>Size</span>2109 <div class="peach-pill-group">2110 <For each={sizeOptions}>2111 {(opt) => (2112 <button2113 class="peach-btn peach-btn--ghost peach-btn--sm"2114 classList={{ "is-active": props.textSize === opt.id }}2115 onClick={() => void props.onTextSizeChange(opt.id)}2116 >2117 {opt.label}2118 </button>2119 )}2120 </For>2121 </div>2122 </div>21232124 <p class="peach-settings-sub-label">MOTION</p>2125 <div class="peach-settings-row peach-settings-row--meta">2126 <span>Reduce motion</span>2127 <button2128 class="peach-btn peach-btn--subtle peach-btn--sm"2129 onClick={() => void props.onReduceMotionChange(!props.reduceMotion)}2130 >2131 {props.reduceMotion ? "On" : "Off"}2132 </button>2133 </div>2134 </div>2135 </div>2136 );2137 }21382139 function renderShortcuts() {2140 const [capturingAction, setCapturingAction] = createSignal<ShortcutAction | null>(null);2141 const [conflictMsg, setConflictMsg] = createSignal<string | null>(null);21422143 function startCapture(action: ShortcutAction) {2144 setConflictMsg(null);2145 setCapturingAction(action);2146 }2147 function cancelCapture() {2148 setCapturingAction(null);2149 setConflictMsg(null);2150 }2151 function commitBinding(action: ShortcutAction, binding: Keybinding) {2152 const conflict = findConflict(props.shortcuts, binding, action);2153 if (conflict) {2154 const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict);2155 setConflictMsg(`Already bound to "${other?.label ?? conflict}"`);2156 return;2157 }2158 const next = { ...props.shortcuts, [action]: binding };2159 setCapturingAction(null);2160 setConflictMsg(null);2161 void props.onShortcutsChange(next);2162 }2163 function resetAction(action: ShortcutAction) {2164 const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] };2165 void props.onShortcutsChange(next);2166 }2167 function resetAll() {2168 void props.onShortcutsChange({ ...DEFAULT_BINDINGS });2169 }21702171 2172 2173 function handleCaptureKey(e: KeyboardEvent) {2174 e.preventDefault();2175 e.stopPropagation();2176 if (e.key === "Escape") { cancelCapture(); return; }2177 const binding = keybindingFromEvent(e);2178 if (!binding) return; 2179 const action = capturingAction();2180 if (action) commitBinding(action, binding);2181 }21822183 return (2184 <div class="peach-settings-body">2185 <div class="peach-settings-group peach-shortcuts-group">2186 <p class="peach-settings-sub-label">ENTRY VIEW</p>2187 <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry")}>2188 {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}2189 </For>2190 </div>21912192 <div class="peach-settings-group peach-shortcuts-group">2193 <p class="peach-settings-sub-label">ANYWHERE IN POPUP</p>2194 <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global")}>2195 {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}2196 </For>2197 </div>21982199 <Show when={conflictMsg()}>2200 <div class="peach-shortcuts-conflict">{conflictMsg()}</div>2201 </Show>22022203 <div class="peach-settings-group" style="display:flex;flex-direction:column;gap:8px">2204 <div class="peach-settings-row peach-settings-row--meta">2205 <span>Inline autofill on page</span>2206 <a2207 class="peach-settings-link"2208 href="chrome://extensions/shortcuts"2209 onClick={(e) => {2210 e.preventDefault();2211 chrome.tabs.create({ url: "chrome://extensions/shortcuts" });2212 }}2213 >2214 ⌘⇧P — customize ↗2215 </a>2216 </div>2217 <button2218 class="peach-btn peach-btn--subtle peach-btn--sm"2219 style="align-self:flex-start"2220 onClick={resetAll}2221 >2222 Reset all to defaults2223 </button>2224 </div>2225 </div>2226 );22272228 function renderRow(2229 desc: typeof SHORTCUT_DESCRIPTORS[number],2230 capturing: () => ShortcutAction | null,2231 onStart: (a: ShortcutAction) => void,2232 onCancel: () => void,2233 onReset: (a: ShortcutAction) => void,2234 onKey: (e: KeyboardEvent) => void,2235 ) {2236 const isActive = () => capturing() === desc.action;2237 const binding = () => props.shortcuts[desc.action];2238 const isCustom = () => {2239 const b = binding();2240 const d = DEFAULT_BINDINGS[desc.action];2241 return b.key !== d.key2242 || Boolean(b.ctrl) !== Boolean(d.ctrl)2243 || Boolean(b.meta) !== Boolean(d.meta)2244 || Boolean(b.shift) !== Boolean(d.shift)2245 || Boolean(b.alt) !== Boolean(d.alt);2246 };2247 return (2248 <div class="peach-shortcut-row">2249 <span class="peach-shortcut-row-label">{desc.label}</span>2250 <button2251 class="peach-shortcut-chip"2252 classList={{ "is-capturing": isActive(), "is-custom": isCustom() }}2253 onClick={() => isActive() ? onCancel() : onStart(desc.action)}2254 onKeyDown={(e) => { if (isActive()) onKey(e); }}2255 ref={(el) => {2256 2257 if (el) {2258 queueMicrotask(() => { if (isActive()) el.focus(); });2259 }2260 }}2261 >2262 {isActive() ? "Press a key…" : formatKeybinding(binding())}2263 </button>2264 <Show when={isCustom() && !isActive()}>2265 <button2266 class="peach-shortcut-reset"2267 title="Reset to default"2268 onClick={() => onReset(desc.action)}2269 >2270 ↺2271 </button>2272 </Show>2273 </div>2274 );2275 }2276 }22772278 const PRO_FEATURES: Array<{2279 feature: ProFeature;2280 name: string;2281 desc: string;2282 }> = [2283 { feature: "sync", name: "Peach Sync", desc: "Encrypted · peer-to-peer" },2284 { feature: "codex", name: "Peach Codex", desc: "Restore from paper" },2285 { feature: "passkeys", name: "Passkeys", desc: "Zero-knowledge WebAuthn" },2286 { feature: "advanced_health", name: "Advanced Vault Health", desc: "Reuse · age · exposure" },2287 ];22882289 const PRO_SECURITY_FEATURES: Array<{2290 feature: ProFeature;2291 name: string;2292 desc: string;2293 }> = [2294 { feature: "canary_entry", name: "Canary entries", desc: "Detects vault compromise" },2295 { feature: "duress_vault", name: "Duress vault", desc: "Decoy under coercion" },2296 ];22972298 const [licenseKey, setLicenseKey] = createSignal<string | null>(null);2299 const [showKey, setShowKey] = createSignal(false);23002301 const [deactivating, setDeactivating] = createSignal(false);23022303 async function handleDeactivateLicense() {2304 setDeactivating(true);2305 await sendMessage({ type: "DEACTIVATE_LICENSE" });2306 setLicenseInfo(null);2307 await props.onLicenseChange?.();2308 setDeactivating(false);2309 }23102311 async function handleTestCanary(entry: {id: string; title: string; canary: Canary}) {2312 setCanaryTestingId(entry.id);2313 setCanaryTestResult(null);2314 const res = await sendMessage({2315 type: "FIRE_CANARY_PING",2316 payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger: "test" },2317 }) as { ok: boolean };2318 setCanaryTestResult({ id: entry.id, ok: res.ok });2319 setCanaryTestingId(null);2320 setTimeout(() => setCanaryTestResult((r) => r?.id === entry.id ? null : r), 3000);2321 }23222323 function renderLicense() {2324 async function onActivate() {2325 await handleActivateLicense();2326 if ((licenseInfo()?.valid ?? false) && licenseInfo()?.plan === "pro") {2327 await props.onLicenseChange?.();2328 }2329 }23302331 return (2332 <div class="peach-settings-body">2333 <Show2334 when={isPro()}2335 fallback={2336 <div class="peach-pro-upgrade">2337 <p class="peach-pro-eyebrow">PEACH PRO · ONE-TIME</p>23382339 <h2 class="peach-pro-headline">2340 Every<em>thing,</em><br />every<em>where.</em>2341 </h2>23422343 <p class="peach-pro-subtitle">2344 One key unlocks every Pro feature — for the lifetime of the app.2345 </p>23462347 <div class="peach-pro-grid">2348 <For each={PRO_FEATURES}>2349 {(item, i) => (2350 <div2351 class="peach-pro-grid-item"2352 classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}2353 >2354 <span class="peach-pro-grid-num">{String(i() + 1).padStart(2, "0")}</span>2355 <span class="peach-pro-grid-name">{item.name}</span>2356 <span class="peach-pro-grid-desc">{item.desc}</span>2357 </div>2358 )}2359 </For>2360 </div>23612362 <p class="peach-pro-section-label">ADVANCED SECURITY</p>2363 <div class="peach-pro-grid">2364 <For each={PRO_SECURITY_FEATURES}>2365 {(item, i) => (2366 <div2367 class="peach-pro-grid-item"2368 classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}2369 >2370 <span class="peach-pro-grid-num">{String(PRO_FEATURES.length + i() + 1).padStart(2, "0")}</span>2371 <span class="peach-pro-grid-name">{item.name}</span>2372 <span class="peach-pro-grid-desc">{item.desc}</span>2373 </div>2374 )}2375 </For>2376 </div>23772378 <div class="peach-pro-upgrade-actions">2379 <p class="peach-pro-pricing">2380 $39 <span class="peach-pro-pricing-sub">once · yours forever</span>2381 </p>2382 <a2383 href="https://buy.polar.sh/polar_cl_IXSFIPojx67rcOTksn8LkH5mnU3osPHtYqBIl1eyuIz"2384 target="_blank"2385 rel="noopener noreferrer"2386 class="peach-pro-upgrade-cta"2387 >2388 Get Peach Pro2389 </a>2390 <div class="peach-pro-key-divider"><span>ALREADY HAVE A KEY</span></div>2391 <div class="peach-pro-key-inline">2392 <input2393 class="peach-pro-key-inline-input"2394 type="text"2395 placeholder="PEACH-XXXX-XXXX-XXXX"2396 value={licenseKeyInput()}2397 onInput={(e) => setLicenseKeyInput(e.currentTarget.value)}2398 onKeyDown={(e) => { if (e.key === "Enter") void onActivate(); }}2399 autocomplete="off"2400 spellcheck={false}2401 />2402 <button2403 class="peach-pro-key-apply"2404 onClick={() => void onActivate()}2405 disabled={activatingLicense() || !licenseKeyInput().trim()}2406 >2407 {activatingLicense() ? "…" : "APPLY"}2408 </button>2409 </div>2410 <Show when={licenseError()}>2411 <p class="peach-pro-key-error">{licenseError()}</p>2412 </Show>2413 </div>2414 </div>2415 }2416 >2417 <div class="peach-pro-active">2418 <div class="peach-pro-active-hero">2419 <button2420 class="peach-pro-active-mark-wrap"2421 type="button"2422 aria-label="Celebrate"2423 onClick={handleProCelebrationClick}2424 >2425 <PeachMark class="peach-pro-active-mark" />2426 <Show when={justActivated()}>2427 <div class="peach-pro-sparks" aria-hidden="true">2428 <span class="peach-pro-spark" style="--spark-x:0px;--spark-y:-54px;--spark-delay:0ms">✦</span>2429 <span class="peach-pro-spark" style="--spark-x:38px;--spark-y:-38px;--spark-delay:50ms">✦</span>2430 <span class="peach-pro-spark" style="--spark-x:54px;--spark-y:0px;--spark-delay:110ms">·</span>2431 <span class="peach-pro-spark" style="--spark-x:38px;--spark-y:38px;--spark-delay:30ms">✦</span>2432 <span class="peach-pro-spark" style="--spark-x:0px;--spark-y:54px;--spark-delay:80ms">·</span>2433 <span class="peach-pro-spark" style="--spark-x:-38px;--spark-y:38px;--spark-delay:130ms">✦</span>2434 <span class="peach-pro-spark" style="--spark-x:-54px;--spark-y:0px;--spark-delay:55ms">·</span>2435 <span class="peach-pro-spark" style="--spark-x:-38px;--spark-y:-38px;--spark-delay:95ms">✦</span>2436 </div>2437 </Show>2438 </button>2439 <h2 class="peach-pro-active-title">Peach Pro</h2>2440 <p class="peach-pro-active-since">{formatActivatedSince(licenseInfo()?.issued)}</p>2441 <Show when={justActivated()}>2442 <p class="peach-pro-thankyou-text" aria-live="polite">thank you ✦</p>2443 </Show>2444 </div>24452446 <Show when={licenseKey()}>2447 <div class="peach-settings-license">2448 <span class="peach-settings-label">LICENSE KEY</span>2449 <div class="peach-settings-license-row">2450 <Show2451 when={showKey()}2452 fallback={2453 <span class="peach-settings-license-key peach-settings-license-key--masked" aria-label="License key hidden">2454 ••••••••••••••••••••••••••••2455 </span>2456 }2457 >2458 <span class="peach-settings-license-key">{licenseKey()!}</span>2459 </Show>2460 <div class="peach-settings-license-actions">2461 <button2462 class="peach-settings-license-copy"2463 title="Copy license key"2464 onClick={() => copyLicenseKey(licenseKey()!)}2465 >2466 {copied() ? "✓" : "copy"}2467 </button>2468 <button2469 class="peach-settings-license-toggle"2470 aria-label={showKey() ? "Hide license key" : "Reveal license key"}2471 title={showKey() ? "Hide" : "Reveal"}2472 onClick={() => setShowKey(v => !v)}2473 >2474 <Show2475 when={showKey()}2476 fallback={2477 <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">2478 <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />2479 <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />2480 </svg>2481 }2482 >2483 <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">2484 <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />2485 <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />2486 <line x1="2" y1="2" x2="12" y2="12" />2487 </svg>2488 </Show>2489 </button>2490 </div>2491 </div>2492 </div>2493 </Show>24942495 <div class="peach-pro-active-features">2496 <For each={[...PRO_FEATURES, ...PRO_SECURITY_FEATURES]}>2497 {(item) => (2498 <div class="peach-pro-active-feature">2499 <span class="peach-pro-active-feature-mark">✦</span>2500 <span class="peach-pro-active-feature-name">{item.name}</span>2501 </div>2502 )}2503 </For>2504 </div>25052506 <button2507 class="peach-pro-deactivate"2508 onClick={() => void handleDeactivateLicense()}2509 disabled={deactivating()}2510 >2511 {deactivating() ? "Deactivating…" : "Deactivate"}2512 </button>2513 </div>2514 </Show>2515 </div>2516 );2517 }25182519 function renderFeedback() {2520 return (2521 <div class="peach-settings-body">2522 <div class="peach-settings-group peach-feedback-group">2523 <Show2524 when={feedbackState() === 'sent'}2525 fallback={2526 <>2527 <p class="peach-feedback-intro">2528 Help us improve Peach. Every report goes directly to our inbox.2529 </p>25302531 <p class="peach-settings-sub-label">TYPE</p>2532 <div class="peach-feedback-chips">2533 <For each={FEEDBACK_CATEGORIES}>2534 {(cat) => {2535 const selected = () => fbCategory() === cat;2536 return (2537 <button2538 type="button"2539 class={`peach-feedback-chip ${selected() ? "is-active" : ""}`}2540 aria-pressed={selected()}2541 onClick={() => setFbCategory(cat)}2542 >2543 {cat}2544 </button>2545 );2546 }}2547 </For>2548 </div>25492550 <p class="peach-settings-sub-label">MESSAGE</p>2551 <textarea2552 class="peach-feedback-textarea"2553 placeholder="Describe the issue or idea..."2554 maxLength={MAX_FEEDBACK_MESSAGE_LENGTH}2555 value={fbMessage()}2556 onInput={(e) => setFbMessage(e.currentTarget.value)}2557 />25582559 <p class="peach-feedback-meta">2560 Version {version} · will be included automatically2561 </p>25622563 <button2564 type="button"2565 class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"2566 disabled={!fbMessage().trim() || feedbackState() === 'sending'}2567 onClick={() => void handleSubmitFeedback()}2568 >2569 <Switch>2570 <Match when={feedbackState() === 'idle'}>Send</Match>2571 <Match when={feedbackState() === 'sending'}>Sending...</Match>2572 <Match when={feedbackState() === 'error'}>Failed - try again</Match>2573 </Switch>2574 </button>25752576 <p class="peach-feedback-anon">2577 Sent anonymously · no account required2578 </p>2579 </>2580 }2581 >2582 <div class="peach-feedback-thanks" role="status" aria-live="polite">2583 <p class="peach-feedback-thanks-title">Thank you</p>2584 <p>2585 Your note is in the queue. Someone will process it soon.2586 </p>2587 </div>2588 </Show>2589 </div>2590 </div>2591 );2592 }25932594 const bodies: StackBodies = {2595 protection: renderSecurity(),2596 vault: renderVault(),2597 sync: renderSync(),2598 appearance: renderAppearance(),2599 shortcuts: renderShortcuts(),2600 pro: renderLicense(),2601 feedback: renderFeedback(),2602 };26032604 return (2605 <Show2606 when={showWhatsNew()}2607 fallback={2608 <Show2609 when={showNewRecoveryWords()}2610 fallback={2611 <div2612 class="peach-overlay-view peach-settings-overlay"2613 classList={{ "ov-enter-right": !props.isExiting, "ov-exit-right": !!props.isExiting }}2614 style="display:flex;flex-direction:column"2615 >2616 <StackDeck2617 active={activeCard()}2618 onActiveChange={setActiveCard}2619 onBack={props.onBack}2620 cards={cards()}2621 bodies={bodies}2622 version={version}2623 isFanning={isFanning()}2624 celebratingCardId={justActivated() ? "pro" : null}2625 isPro={isPro()}2626 />2627 </div>2628 }2629 >2630 <RecoveryWordDisplayView onDone={() => setShowNewRecoveryWords(false)} />2631 </Show>2632 }2633 >2634 <div class="peach-overlay-view peach-settings-overlay">2635 <WhatsNewView mode="history" onContinue={() => setShowWhatsNew(false)} />2636 </div>2637 </Show>2638 );2639}