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";36import { PRO_FEATURES, PRO_SECURITY_FEATURES } from "../pro-features";3738interface LicenseInfo {39 valid: boolean;40 plan: "free" | "pro";41 issued?: string;42}4344interface SettingsProps {45 entriesCount: number;46 breachMonitoring: boolean;47 lockOnClose: boolean;48 autoLockMinutes: number;49 lockedSyncRequiresUnlock: boolean;50 trashRetentionDays: number;51 theme: "dark" | "light";52 accentColor: AccentColorName;53 textSize: "small" | "medium" | "large";54 reduceMotion: boolean;55 isPro?: boolean;56 initialSection?: string | null;57 upgradeHighlight?: ProFeature | null;58 onBreachMonitoringChange: (enabled: boolean) => void | Promise<void>;59 onLockOnCloseChange: (enabled: boolean) => void | Promise<void>;60 onAutoLockMinutesChange: (minutes: number) => void | Promise<void>;61 onLockedSyncRequiresUnlockChange: (enabled: boolean) => void | Promise<void>;62 onTrashRetentionChange: (days: number) => void | Promise<void>;63 onThemeChange: (theme: "dark" | "light") => void;64 onAccentChange: (name: AccentColorName) => void | Promise<void>;65 onTextSizeChange: (size: "small" | "medium" | "large") => void | Promise<void>;66 onReduceMotionChange: (enabled: boolean) => void | Promise<void>;67 shortcuts: Record<ShortcutAction, Keybinding>;68 onShortcutsChange: (active: Record<ShortcutAction, Keybinding>) => void | Promise<void>;69 onLicenseChange?: () => void | Promise<void>;70 isExiting?: boolean;71 onBack: () => void;72 onImport: () => void;73 onExport: () => void;74 onBreachCheckDone: () => void;75 onOpenHealth?: () => void;76 onOpenSyncPairing?: (method?: "code") => void;77 onOpenFileImport?: () => void;78 onOpenPanic?: () => void;79 onOpenCodexGenerate?: () => void;80 onOpenCodexRestore?: () => void;81 828384 onProGate?: (feature: ProFeature) => void;85 onCardChange?: (id: CardId) => void;86}8788const FEEDBACK_CATEGORIES = ["Bug report", "Feature request", "Question", "Other"];89const MAX_FEEDBACK_MESSAGE_LENGTH = 4000;90const PRO_CELEBRATION_CARD_STEP_MS = 150;91const PRO_CELEBRATION_THANK_YOU_MS = 3200;9293function sanitizeFeedbackMessage(value: string): string {94 return value95 .trim()96 .slice(0, MAX_FEEDBACK_MESSAGE_LENGTH)97 98 .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "")99 .replace(/&/g, "&")100 .replace(/</g, "<")101 .replace(/>/g, ">")102 .replace(/"/g, """)103 .replace(/'/g, "'");104}105106function formatActivatedSince(issued: string | undefined): string {107 if (!issued) return "Active";108 try {109 const d = new Date(issued);110 if (Number.isNaN(d.getTime())) return "Active";111 return `Active since ${d.toLocaleDateString(undefined, { month: "long", year: "numeric" })}`;112 } catch {113 return "Active";114 }115}116117function initialSectionToCard(value: string | null | undefined): CardId | null {118 if (!value) return null;119 if (value === "security") return "protection";120 if (value === "license") return "pro";121 if (122 value === "protection" ||123 value === "vault" ||124 value === "sync" ||125 value === "appearance" ||126 value === "shortcuts" ||127 value === "pro" ||128 value === "feedback"129 ) {130 return value;131 }132 return null;133}134135136137138139const SECURE_RESET_DISCLOSURE =140 "Peach will archive this device's old Sync trust and start the new Sync generation. " +141 "Every other device must be updated first, then re-paired with this one — they will " +142 "not continue on their own. Your vault and your recovery words are not changed.";143144function secureResetHeadline(state: string | undefined): string {145 switch (state) {146 case "LEGACY_ACTIVE":147 return "Legacy Sync generation";148 case "RESET_REQUIRED":149 return "Reset required";150 case "RESET_CONFIRMED":151 return "Reset not completed";152 case "V2_ANCHOR":153 case "REPAIRING":154 return "Not re-paired";155 case "V2_ACTIVE":156 return "New Sync generation";157 case "UNKNOWN":158 return "Sync generation unreadable";159 default:160 return "Sync generation";161 }162}163164function secureResetDetail(state: string | undefined): string {165 switch (state) {166 case "LEGACY_ACTIVE":167 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.";168 case "RESET_REQUIRED":169 return "Sync is stopped until the reset completes. Update Peach on every other device, then reset this one and re-pair each device.";170 case "RESET_CONFIRMED":171 return "The reset did not finish. Nothing was lost — the old trust is archived. Try again.";172 case "V2_ANCHOR":173 case "REPAIRING":174 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.";175 case "V2_ACTIVE":176 return "This device is on the new Sync generation. Each device is re-paired individually.";177 case "UNKNOWN":178 return "This device's Sync generation can't be read, so sync is stopped. Reinstall or contact support.";179 default:180 return "Sync state is unavailable.";181 }182}183184export function SettingsView(props: SettingsProps) {185 const manifest = chrome.runtime.getManifest();186 const version = manifest.version;187 const initial = props.initialSection;188 const [activeCard, setActiveCard] = createSignal<CardId>(189 props.upgradeHighlight ? "pro" : initialSectionToCard(initial) ?? "pro"190 );191192 createEffect(() => { props.onCardChange?.(activeCard()); });193194 const [justActivated, setJustActivated] = createSignal(false);195 const [isFanning, setIsFanning] = createSignal(false);196197 function triggerCelebration() {198 if (isFanning()) return;199 setJustActivated(false);200 setIsFanning(true);201 setActiveCard("pro");202 const fanOrder: CardId[] = ["protection", "vault", "sync", "appearance", "shortcuts", "feedback"];203 fanOrder.forEach((id, i) => {204 window.setTimeout(() => setActiveCard(id), (i + 1) * PRO_CELEBRATION_CARD_STEP_MS);205 });206 window.setTimeout(() => {207 setActiveCard("pro");208 setIsFanning(false);209 setJustActivated(true);210 window.setTimeout(() => setJustActivated(false), PRO_CELEBRATION_THANK_YOU_MS);211 }, (fanOrder.length + 1) * PRO_CELEBRATION_CARD_STEP_MS);212 }213214 function handleProCelebrationClick(e: MouseEvent) {215 e.stopPropagation();216 triggerCelebration();217 }218219 const [checkingBreaches, setCheckingBreaches] = createSignal(false);220 const [breachResult, setBreachResult] = createSignal<string | null>(null);221 const [licenseInfo, setLicenseInfo] = createSignal<LicenseInfo | null>(null);222 const [licenseKeyInput, setLicenseKeyInput] = createSignal("");223 const [licenseError, setLicenseError] = createSignal<string | null>(null);224 const [, setLicenseSuccess] = createSignal<string | null>(null);225 const [activatingLicense, setActivatingLicense] = createSignal(false);226227 const isPro = () => props.isPro ?? false;228 const cards = createMemo<StackCardSpec[]>(() => [229 {230 id: "pro",231 num: "I · FOREVER",232 title: "Peach Pro",233 lede: isPro() ? "active · paid once, yours forever" : "$39 once · yours forever",234 bgVar: "--stack-pro-bg",235 fgVar: "--stack-pro-fg",236 icon: "spark",237 onIconClick: isPro() ? triggerCelebration : undefined,238 },239 {240 id: "protection",241 num: "II · PROTECT",242 title: "Protection",243 lede: `${props.breachMonitoring ? "breaches watched" : "breach watch off"} · ${props.lockOnClose ? "locks on close" : "manual lock"}`,244 bgVar: "--stack-protect-bg",245 fgVar: "--stack-protect-fg",246 icon: "shield",247 },248 {249 id: "vault",250 num: "III · KEEP",251 title: "Vault",252 lede: `${props.entriesCount} ${props.entriesCount === 1 ? "entry" : "entries"} · backups, history, cleanup`,253 bgVar: "--stack-vault-bg",254 fgVar: "--stack-vault-fg",255 icon: "lock",256 },257 {258 id: "sync",259 num: "IV · ECHO",260 title: "Sync",261 lede: isPro() ? "End to End Encryption" : "Pro · encrypted device sync",262 bgVar: "--stack-sync-bg",263 fgVar: "--stack-sync-fg",264 icon: "sync",265 },266 {267 id: "appearance",268 num: "V · DRESS",269 title: "Appearance",270 lede: `${props.theme} · ${props.accentColor} · ${props.textSize}`,271 bgVar: "--stack-appear-bg",272 fgVar: "--stack-appear-fg",273 icon: "sun",274 },275 {276 id: "shortcuts",277 num: "VI · KEYS",278 title: "Shortcuts",279 lede: `${SHORTCUT_DESCRIPTORS.length} bindings · what your fingers know`,280 bgVar: "--stack-shortcut-bg",281 fgVar: "--stack-shortcut-fg",282 icon: "keys",283 },284 {285 id: "feedback",286 num: "VII · ECHO",287 title: "Feedback",288 lede: `version ${version} · tell us what would make it better`,289 bgVar: "--stack-feedback-bg",290 fgVar: "--stack-feedback-fg",291 icon: "bubble",292 },293 ]);294 const [passkeyState, setPasskeyState] = createSignal<PasskeyProviderState | null>(null);295 const [passkeySaving, setPasskeySaving] = createSignal(false);296297 298 299 interface PendingVouched {300 deviceId: string;301 deviceName: string;302 longTermPublicKeyHex: string;303 receivedAt: number;304 vouchedByDeviceId: string;305 }306 const [pendingVouched, setPendingVouched] = createSignal<PendingVouched[]>([]);307 const [vouchedBusy, setVouchedBusy] = createSignal<string | null>(null);308 309 const [pairedDevices, setPairedDevices] = createSignal<PairedDevice[]>([]);310 const [trustDataCorrupted, setTrustDataCorrupted] = createSignal(false);311312 const [confirmUnpair, setConfirmUnpair] = createSignal<string | null>(null);313 const [unpairError, setUnpairError] = createSignal<string | null>(null);314 const [renameDeviceId, setRenameDeviceId] = createSignal<string | null>(null);315 const [renameDraft, setRenameDraft] = createSignal("");316 const [syncStatus, setSyncStatus] = createSignal<"idle" | "syncing" | "error">("idle");317 const [syncFlash, setSyncFlash] = createSignal<string | null>(null);318 319 320 const [syncSummary, setSyncSummary] = createSignal<SyncOutcomeSummary>({});321 322 323 const [generation, setGeneration] = createSignal<SyncGeneration | null>(null);324 const [resetOpen, setResetOpen] = createSignal(false);325 const [resetPassword, setResetPassword] = createSignal("");326 const [resetError, setResetError] = createSignal<string | null>(null);327 const [resetting, setResetting] = createSignal(false);328 const syncLight = () =>329 syncStatus() === "syncing"330 ? "busy"331 : syncIndicator(syncSummary(), pairedDevices().length);332 const syncLightLabel = () => syncIndicatorLabel(syncLight(), syncSummary());333 const lastSyncLine = () => {334 const s = syncSummary();335 if (!s.outcome) return "No sync attempted yet";336 return `${syncRelativeTime(s.finishedAt)} · ${describeSyncOutcome(s)}`;337 };338 async function loadSyncState() {339 const r = (await sendMessage({ type: "GET_SYNC_STATE" })) as {340 ok: boolean;341 data?: SyncOutcomeSummary;342 };343 if (r.ok && r.data) {344 setSyncSummary(r.data);345 setSyncStatus(r.data.status ?? "idle");346 }347 }348349 async function loadGeneration() {350 const r = (await sendMessage({ type: "GET_SYNC_GENERATION" })) as {351 ok: boolean;352 data?: SyncGeneration | null;353 };354 if (r.ok) setGeneration(r.data ?? null);355 }356357 async function runSecureReset() {358 setResetting(true);359 const r = (await sendMessage({360 type: "SECURE_RESET",361 payload: { password: resetPassword() },362 })) as { ok: boolean; data?: SyncGeneration | null; error?: string };363 setResetting(false);364 setResetPassword("");365 if (!r.ok) {366 setResetError(r.error ?? "The reset did not complete — nothing was lost, try again");367 await loadGeneration();368 return;369 }370 setResetError(null);371 setResetOpen(false);372 if (r.data) setGeneration(r.data);373 }374375 const STALE_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000;376 function syncRelativeTime(when: number | undefined): string {377 if (!when) return "Not synced yet";378 const elapsed = Date.now() - when;379 if (elapsed < 60_000) return "Just now";380 const minutes = Math.floor(elapsed / 60_000);381 if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"} ago`;382 const hours = Math.floor(minutes / 60);383 if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;384 const days = Math.floor(hours / 24);385 return days < 7 ? `${days} day${days === 1 ? "" : "s"} ago` : "Not seen recently";386 }387 function isSyncStale(d: PairedDevice): boolean {388 return d.lastSyncedAt !== undefined && Date.now() - d.lastSyncedAt > STALE_THRESHOLD_MS;389 }390 function deviceTypeLabel(t: PairedDevice["deviceType"]): string {391 switch (t) {392 case "android": return "Android";393 case "ios": return "iPhone";394 case "extension": return "Browser";395 default: return "Device";396 }397 }398399 async function loadPairedDevices() {400 if (!isPro()) return;401 const r = (await sendMessage({ type: "LIST_PAIRED_DEVICES" })) as {402 ok: boolean;403 error?: string;404 data?: { devices: PairedDevice[] };405 };406 if (r.ok) {407 setTrustDataCorrupted(false);408 setPairedDevices(r.data?.devices ?? []);409 } else if (r.error === "trust_data_corrupted") {410 setPairedDevices([]);411 setTrustDataCorrupted(true);412 }413 }414 async function unpairDevice(deviceId: string) {415 const result = (await sendMessage({ type: "UNPAIR_DEVICE", payload: { deviceId } })) as {416 ok: boolean;417 error?: string;418 };419 if (!result.ok) {420 setUnpairError(result.error ?? "Could not remove this device");421 return;422 }423 setUnpairError(null);424 setConfirmUnpair(null);425 await loadPairedDevices();426 }427 async function renameDevice(deviceId: string) {428 const name = renameDraft().trim();429 if (!name) return;430 const result = (await sendMessage({431 type: "RENAME_PAIRED_DEVICE",432 payload: { deviceId, name },433 })) as { ok: boolean };434 if (!result.ok) return;435 setRenameDeviceId(null);436 await loadPairedDevices();437 }438 async function syncNow() {439 setSyncStatus("syncing");440 setSyncFlash(null);441 const res = (await sendMessage({442 type: "TRIGGER_SYNC",443 payload: { reason: "manual", force: true },444 })) as { ok: boolean; error?: string };445 if (!res.ok) {446 setSyncStatus("error");447 setSyncSummary({ status: "error", outcome: "run_failed", detail: res.error });448 return;449 }450 451 452 453 let s: SyncOutcomeSummary = { status: "syncing" };454 for (let i = 0; i < 40 && s.status === "syncing"; i++) {455 await new Promise((r) => setTimeout(r, 250));456 const state = (await sendMessage({ type: "GET_SYNC_STATE" })) as {457 ok: boolean;458 data?: SyncOutcomeSummary;459 };460 s = state.ok ? state.data ?? { status: "idle" } : { status: "error", outcome: "run_failed" };461 }462 setSyncSummary(s);463 setSyncStatus(s.status ?? "idle");464 465 466 setSyncFlash(describeSyncOutcome(s));467 setTimeout(() => setSyncFlash(null), 4000);468 await loadPairedDevices();469 }470 471 const [rotateConfirmOpen, setRotateConfirmOpen] = createSignal(false);472 const [rotating, setRotating] = createSignal(false);473 const [rotateError, setRotateError] = createSignal<string | null>(null);474 const [showSyncConnection, setShowSyncConnection] = createSignal(false);475 const [showSyncRecovery, setShowSyncRecovery] = createSignal(false);476477 async function loadPendingVouched() {478 const r = (await sendMessage({ type: "GET_PENDING_VOUCHED_DEVICES" })) as {479 ok: boolean;480 data?: PendingVouched[];481 };482 if (r.ok) setPendingVouched(r.data ?? []);483 }484 async function confirmVouched(deviceId: string) {485 setVouchedBusy(deviceId);486 try {487 await sendMessage({ type: "CONFIRM_VOUCHED_DEVICE", payload: { deviceId } });488 await loadPendingVouched();489 } finally {490 setVouchedBusy(null);491 }492 }493 async function rejectVouched(deviceId: string) {494 setVouchedBusy(deviceId);495 try {496 await sendMessage({ type: "REJECT_VOUCHED_DEVICE", payload: { deviceId } });497 await loadPendingVouched();498 } finally {499 setVouchedBusy(null);500 }501 }502 async function doRotateSyncIdentity() {503 setRotateError(null);504 setRotating(true);505 try {506 const r = (await sendMessage({ type: "ROTATE_SYNC_IDENTITY" })) as {507 ok: boolean;508 error?: string;509 };510 if (!r.ok) {511 setRotateError(r.error ?? "Rotation failed.");512 return;513 }514 setRotateConfirmOpen(false);515 516 517 setShowNewRecoveryWords(true);518 } finally {519 setRotating(false);520 }521 }522523 const [dedupCount, setDedupCount] = createSignal<number | null>(null);524 const [dedupRunning, setDedupRunning] = createSignal(false);525 const [dedupDone, setDedupDone] = createSignal<number | null>(null);526527 const [declinedCardCount, setDeclinedCardCount] = createSignal(0);528529 530 531 532 const [historySnapshots, setHistorySnapshots] = createSignal<VaultSnapshot[]>([]);533 const [historyLoading, setHistoryLoading] = createSignal(false);534 const [historyRestoring, setHistoryRestoring] = createSignal<string | null>(null);535 const [historyShowAll, setHistoryShowAll] = createSignal(false);536537 async function loadVaultHistory() {538 setHistoryLoading(true);539 try {540 const res = await sendMessage({ type: "GET_VAULT_HISTORY" });541 const list = (res as { ok: boolean; data?: VaultSnapshot[] }).data ?? [];542 setHistorySnapshots(list);543 } finally {544 setHistoryLoading(false);545 }546 }547548 async function handleRestoreVaultSnapshot(id: string) {549 if (historyRestoring()) return;550 setHistoryRestoring(id);551 const res = await sendMessage({552 type: "RESTORE_VAULT_SNAPSHOT",553 payload: { snapshotId: id },554 });555 setHistoryRestoring(null);556 if ((res as { ok: boolean }).ok) {557 558 559 props.onBack();560 }561 }562563 function formatSnapshotTime(savedAt: number): string {564 const diff = Date.now() - savedAt;565 if (diff < 60_000) return "Just now";566 if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;567 const d = new Date(savedAt);568 const isToday = new Date().toDateString() === d.toDateString();569 const isYest =570 new Date(Date.now() - 86_400_000).toDateString() === d.toDateString();571 const t = d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });572 if (isToday) return `Today ${t}`;573 if (isYest) return `Yesterday ${t}`;574 return `${d.toLocaleDateString([], { month: "short", day: "numeric" })} ${t}`;575 }576577 function reasonLabel(reason: VaultSnapshot["reason"]): string {578 return (579 {580 local_save: "saved",581 sync_received: "synced",582 import: "imported",583 restore: "restored",584 } as const585 )[reason] ?? reason;586 }587588 const [showVaultAdvanced, setShowVaultAdvanced] = createSignal(false);589 const [showWhatsNew, setShowWhatsNew] = createSignal(false);590591 const [showChangePw, setShowChangePw] = createSignal(false);592 const [changePwCurrent, setChangePwCurrent] = createSignal("");593 const [changePwNew, setChangePwNew] = createSignal("");594 const [changePwConfirm, setChangePwConfirm] = createSignal("");595 const [changePwSaving, setChangePwSaving] = createSignal(false);596 const [changePwError, setChangePwError] = createSignal<string | null>(null);597 const [changePwSuccess] = createSignal(false);598 const [showNewRecoveryWords, setShowNewRecoveryWords] = createSignal(false);599600 const [pinEnabled, setPinEnabled] = createSignal(false);601 const [pinFlow, setPinFlow] = createSignal<"setup" | "disable" | null>(null);602 const [pinCurrentPassword, setPinCurrentPassword] = createSignal("");603 const [pinValue, setPinValue] = createSignal("");604 const [pinConfirm, setPinConfirm] = createSignal("");605 const [pinSaving, setPinSaving] = createSignal(false);606 const [pinError, setPinError] = createSignal<string | null>(null);607608 609 const [showViewWords, setShowViewWords] = createSignal(false);610 const [viewWordsPassword, setViewWordsPassword] = createSignal("");611 const [viewWordsError, setViewWordsError] = createSignal<string | null>(null);612 const [viewedWords, setViewedWords] = createSignal<string[] | null>(null);613 const [viewWordsBusy, setViewWordsBusy] = createSignal(false);614615 async function handleViewRecoveryWords() {616 setViewWordsError(null);617 if (!viewWordsPassword()) {618 setViewWordsError("Enter your master password.");619 return;620 }621 setViewWordsBusy(true);622 try {623 const r = (await sendMessage({624 type: "GET_RECOVERY_WORDS",625 payload: { password: viewWordsPassword() },626 })) as { ok: boolean; data?: string[]; error?: string };627 if (r.ok) {628 setViewedWords(r.data ?? []);629 setViewWordsPassword("");630 return;631 }632 switch (r.error) {633 case "wrong_password":634 setViewWordsError("Incorrect password");635 break;636 case "password_required":637 setViewWordsError("Enter your master password.");638 break;639 case "recovery_not_setup":640 setViewWordsError("Recovery words aren't set up for this vault yet.");641 break;642 default:643 setViewWordsError("Couldn't retrieve recovery words.");644 }645 } finally {646 setViewWordsBusy(false);647 }648 }649 function closeViewWords() {650 setShowViewWords(false);651 setViewWordsPassword("");652 setViewWordsError(null);653 setViewedWords(null);654 }655656 const [duressEnabled, setDuressEnabled] = createSignal(false);657 const [showDuressSetup, setShowDuressSetup] = createSignal(false);658 const [duressPassword, setDuressPassword] = createSignal("");659 const [duressConfirm, setDuressConfirm] = createSignal("");660 const [duressMainPassword, setDuressMainPassword] = createSignal("");661 const [duressSaving, setDuressSaving] = createSignal(false);662 const [duressError, setDuressError] = createSignal<string | null>(null);663 const [duressSuccess, setDuressSuccess] = createSignal<string | null>(null);664665 const [canaryEntries, setCanaryEntries] = createSignal<Array<{id: string; title: string; canary: Canary}>>([]);666667 const [canaryTestingId, setCanaryTestingId] = createSignal<string | null>(null);668 const [canaryTestResult, setCanaryTestResult] = createSignal<{id: string; ok: boolean} | null>(null);669670 const [fbCategory, setFbCategory] = createSignal("Bug report");671 const [fbMessage, setFbMessage] = createSignal("");672 type FeedbackState = 'idle' | 'sending' | 'sent' | 'error';673 const [feedbackState, setFeedbackState] = createSignal<FeedbackState>('idle');674675 createEffect(() => {676 if (props.upgradeHighlight) {677 setActiveCard("pro");678 }679 });680681 onMount(async () => {682 const [licenseRes, passkeyRes, duressRes, settingsRes, keyRes] = await Promise.all([683 sendMessage({ type: "GET_LICENSE" }),684 sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }),685 sendMessage({ type: "IS_DURESS_ENABLED" }),686 sendMessage({ type: "GET_SETTINGS" }),687 sendMessage({ type: "GET_LICENSE_KEY" }),688 ]);689690 691 692 void loadVaultHistory();693694 695 696 void loadPendingVouched();697 void loadPairedDevices();698 void loadSyncState();699 void loadGeneration();700 const onBroadcast = (msg: unknown) => {701 const m = msg as { type?: string; payload?: unknown } | null;702 if (m?.type === "VOUCHED_DEVICE_PENDING") {703 void loadPendingVouched();704 }705 if (m?.type === "SYNC_STATE_CHANGED") {706 const payload = (m.payload ?? {}) as SyncOutcomeSummary;707 setSyncSummary(payload);708 setSyncStatus(payload.status ?? "idle");709 }710 };711 chrome.runtime?.onMessage?.addListener(onBroadcast);712 onCleanup(() => {713 try { chrome.runtime?.onMessage?.removeListener(onBroadcast); } catch { }714 });715716 if ((licenseRes as { ok: boolean }).ok) {717 setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null);718 }719720 if ((passkeyRes as { ok: boolean }).ok) {721 setPasskeyState(722 (passkeyRes as { ok: boolean; data?: PasskeyProviderState }).data ?? null723 );724 }725726 if ((duressRes as { ok: boolean }).ok) {727 setDuressEnabled((duressRes as { ok: boolean; data?: boolean }).data ?? false);728 }729730 if ((settingsRes as { ok: boolean }).ok) {731 const sData = (settingsRes as { ok: boolean; data?: { declinedCardFingerprints?: string[]; pinEnabled?: boolean } }).data;732 setDeclinedCardCount(sData?.declinedCardFingerprints?.length ?? 0);733 setPinEnabled(sData?.pinEnabled === true);734 }735736 if ((keyRes as { ok: boolean }).ok) {737 setLicenseKey((keyRes as { ok: boolean; data?: string }).data ?? null);738 }739740 const entriesRes = await sendMessage({ type: "GET_ENTRIES" });741 if ((entriesRes as { ok: boolean }).ok) {742 const entries = (entriesRes as { ok: boolean; data?: Array<{id: string; title: string; canary?: Canary}> }).data ?? [];743 setCanaryEntries(744 entries745 .filter((e) => e.canary?.webhookUrl)746 .map((e) => ({ id: e.id, title: e.title, canary: e.canary! }))747 );748 }749750 });751752 function resetPinFlow() {753 setPinFlow(null);754 setPinCurrentPassword("");755 setPinValue("");756 setPinConfirm("");757 setPinError(null);758 }759760 async function handleSetupPin() {761 setPinError(null);762 if (!pinCurrentPassword()) { setPinError("enter your current password"); return; }763 if (!/^\d{4,12}$/.test(pinValue())) { setPinError("PIN must be 4–12 digits"); return; }764 if (pinValue() !== pinConfirm()) { setPinError("PINs do not match"); return; }765 setPinSaving(true);766 const res = await sendMessage({767 type: "SETUP_PIN",768 payload: { currentPassword: pinCurrentPassword(), pin: pinValue() },769 }) as { ok: boolean; error?: string };770 if (res.ok) {771 setPinEnabled(true);772 resetPinFlow();773 } else {774 setPinError(res.error === "incorrect_current_password" ? "incorrect current password" : "couldn't set up PIN");775 }776 setPinSaving(false);777 }778779 async function handleDisablePin() {780 setPinError(null);781 if (!pinCurrentPassword()) { setPinError("enter your current password"); return; }782 setPinSaving(true);783 const res = await sendMessage({784 type: "DISABLE_PIN",785 payload: { currentPassword: pinCurrentPassword() },786 }) as { ok: boolean; error?: string };787 if (res.ok) {788 setPinEnabled(false);789 resetPinFlow();790 } else {791 setPinError(res.error === "incorrect_current_password" ? "incorrect current password" : "couldn't remove PIN");792 }793 setPinSaving(false);794 }795796 async function handleChangePassword() {797 setChangePwError(null);798 if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; }799 if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; }800 if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; }801 if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; }802 setChangePwSaving(true);803 const res = await sendMessage({804 type: "CHANGE_PASSWORD",805 payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() },806 }) as { ok: boolean; error?: string };807 if (res.ok) {808 setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");809 setShowChangePw(false);810 811 812 813 await sendMessage({ type: "SET_RECOVERY_CONFIRM_PENDING" });814 setShowNewRecoveryWords(true);815 } else {816 setChangePwError(res.error ?? "failed to change password");817 }818 setChangePwSaving(false);819 }820821 const [copied, setCopied] = createSignal(false);822823 async function copyLicenseKey(key: string) {824 await navigator.clipboard.writeText(key);825 setCopied(true);826 setTimeout(() => setCopied(false), 2000);827 }828829 async function handleActivateLicense() {830 const key = licenseKeyInput().trim();831 if (!key) return;832 setActivatingLicense(true);833 setLicenseError(null);834 setLicenseSuccess(null);835 const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } });836 setActivatingLicense(false);837 if ((res as { ok: boolean }).ok) {838 const data = (res as { ok: boolean; data?: LicenseInfo }).data;839 if (data?.valid) {840 setLicenseInfo(data);841 setLicenseKey(key);842 setLicenseSuccess("License activated!");843 setLicenseKeyInput("");844 } else {845 setLicenseError("Invalid license key");846 }847 } else {848 setLicenseError("Activation failed");849 }850 }851852 async function handleTogglePasskeys() {853 const current = passkeyState();854 if (!current || !current.supported) return;855 setPasskeySaving(true);856 const res = await sendMessage({857 type: "SET_PASSKEY_PROVIDER_ENABLED",858 payload: { enabled: !current.enabled },859 });860 setPasskeySaving(false);861 if ((res as { ok: boolean }).ok) {862 setPasskeyState(863 (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null864 );865 }866 }867868 async function handleCheckAllBreaches() {869 setCheckingBreaches(true);870 setBreachResult(null);871 const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });872 setCheckingBreaches(false);873 if ((res as { ok: boolean }).ok) {874 const data = (res as { ok: boolean; data?: Record<string, number> }).data ?? {};875 const breached = Object.values(data).filter((count) => count > 0).length;876 setBreachResult(877 breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean"878 );879 props.onBreachCheckDone();880 }881 }882883884 async function handleSetupDuress() {885 const mainPw = duressMainPassword().trim();886 const duressPw = duressPassword().trim();887 const confirmPw = duressConfirm().trim();888889 if (!mainPw) { setDuressError("enter your main password to confirm"); return; }890 if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; }891 if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; }892 if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; }893894 setDuressSaving(true);895 setDuressError(null);896897 const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };898 if (!verifyRes.ok || !verifyRes.data) {899 setDuressError("incorrect main password");900 setDuressSaving(false);901 return;902 }903904 const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string };905 setDuressSaving(false);906 if (res.ok) {907 setDuressEnabled(true);908 setShowDuressSetup(false);909 setDuressPassword("");910 setDuressConfirm("");911 setDuressMainPassword("");912 setDuressSuccess("Duress password set. If entered, a decoy vault will be shown.");913 setTimeout(() => setDuressSuccess(null), 5000);914 } else {915 setDuressError(916 res.error === "session_expired"917 ? "Vault session expired — lock and unlock Peach, then try again"918 : res.error ?? "failed to set duress password"919 );920 }921 }922923 async function handleClearDeclinedCards() {924 await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } });925 setDeclinedCardCount(0);926 }927928 async function handleCountDuplicates() {929 setDedupRunning(true);930 setDedupDone(null);931 const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number };932 setDedupRunning(false);933 if (res.ok) setDedupCount(res.data ?? 0);934 }935936 async function handleDeduplicate() {937 setDedupRunning(true);938 const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number };939 setDedupRunning(false);940 if (res.ok) {941 setDedupDone(res.data ?? 0);942 setDedupCount(0);943 }944 }945946 async function handleDisableDuress() {947 const mainPw = duressMainPassword().trim();948 if (!mainPw) { setDuressError("enter your main password to confirm"); return; }949950 setDuressSaving(true);951 setDuressError(null);952953 const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };954 if (!verifyRes.ok || !verifyRes.data) {955 setDuressError("incorrect main password");956 setDuressSaving(false);957 return;958 }959960 const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string };961 setDuressSaving(false);962 if (res.ok) {963 setDuressEnabled(false);964 setShowDuressSetup(false);965 setDuressMainPassword("");966 setDuressSuccess("Duress password disabled.");967 setTimeout(() => setDuressSuccess(null), 4000);968 } else {969 setDuressError(res.error ?? "failed to disable duress password");970 }971 }972973 async function handleSubmitFeedback() {974 const message = sanitizeFeedbackMessage(fbMessage());975 if (!message) return;976977 setFeedbackState('sending');978979 try {980 const category = FEEDBACK_CATEGORIES.includes(fbCategory())981 ? fbCategory()982 : FEEDBACK_CATEGORIES[0];983 const res = await fetch('https://api.web3forms.com/submit', {984 method: 'POST',985 headers: { 'Content-Type': 'application/json' },986 body: JSON.stringify({987 access_key: WEB3FORMS_KEY,988 subject: `Peach Feedback - ${category}`,989 message,990 from_name: 'Peach User',991 992 botcheck: '',993 }),994 });995996 if (!res.ok) throw new Error(`HTTP ${res.status}`);997 const data = await res.json() as { success: boolean; message?: string };998 if (!data.success) throw new Error(data.message ?? 'unknown error');9991000 setFeedbackState('sent');1001 setFbMessage('');1002 setFbCategory(FEEDBACK_CATEGORIES[0]);1003 } catch (err) {1004 console.error('[feedback]', err);1005 setFeedbackState('error');1006 setTimeout(() => setFeedbackState('idle'), 4000);1007 }1008 }10091010 function renderSecurity() {1011 return (1012 <div class="peach-settings-body">1013 <div class="peach-settings-group">1014 <p class="peach-settings-sub-label">LOCKING</p>1015 <div class="peach-settings-row">1016 <span>Lock on close</span>1017 <button1018 class={`peach-toggle ${props.lockOnClose ? "is-on" : ""}`}1019 onClick={() => void props.onLockOnCloseChange(!props.lockOnClose)}1020 aria-label="Toggle lock on close"1021 >1022 <span class="peach-toggle-knob"></span>1023 </button>1024 </div>1025 <div class="peach-settings-row">1026 <span>Auto-lock after</span>1027 <select1028 class="peach-settings-select"1029 value={String(props.autoLockMinutes)}1030 onChange={(e) => void props.onAutoLockMinutesChange(Number(e.currentTarget.value))}1031 >1032 <option value="1">1 minute</option>1033 <option value="5">5 minutes</option>1034 <option value="15">15 minutes</option>1035 <option value="30">30 minutes</option>1036 <option value="0">Never</option>1037 </select>1038 </div>1039 <p class="peach-settings-sub-label">SYNC WHILE LOCKED</p>1040 <div class="peach-settings-row">1041 <span>Require unlock after restart</span>1042 <button1043 class={`peach-toggle ${props.lockedSyncRequiresUnlock ? "is-on" : ""}`}1044 onClick={() => void props.onLockedSyncRequiresUnlockChange(!props.lockedSyncRequiresUnlock)}1045 aria-label="Toggle unlock requirement for locked Sync after restart"1046 aria-pressed={props.lockedSyncRequiresUnlock}1047 >1048 <span class="peach-toggle-knob"></span>1049 </button>1050 </div>1051 <p class="peach-settings-sub-label">QUICK UNLOCK</p>1052 <div class="peach-settings-row peach-settings-row--meta">1053 <span>PIN unlock</span>1054 <button1055 class="peach-btn peach-btn--subtle peach-btn--sm"1056 onClick={() => { setPinFlow(pinEnabled() ? "disable" : "setup"); setPinError(null); }}1057 >1058 {pinEnabled() ? "remove" : "set up"}1059 </button>1060 </div>1061 <Show when={pinFlow() === "setup"}>1062 <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1063 <input1064 class="peach-settings-input"1065 type="password"1066 placeholder="current password"1067 value={pinCurrentPassword()}1068 onInput={(e) => setPinCurrentPassword((e.target as HTMLInputElement).value)}1069 autocomplete="current-password"1070 />1071 <input1072 class="peach-settings-input"1073 type="password"1074 inputmode="numeric"1075 maxlength="12"1076 placeholder="new 4–12 digit PIN"1077 value={pinValue()}1078 onInput={(e) => setPinValue((e.target as HTMLInputElement).value.replace(/\D/g, "").slice(0, 12))}1079 autocomplete="off"1080 />1081 <input1082 class="peach-settings-input"1083 type="password"1084 inputmode="numeric"1085 maxlength="12"1086 placeholder="confirm PIN"1087 value={pinConfirm()}1088 onInput={(e) => setPinConfirm((e.target as HTMLInputElement).value.replace(/\D/g, "").slice(0, 12))}1089 autocomplete="off"1090 onKeyDown={(e) => { if (e.key === "Enter") void handleSetupPin(); }}1091 />1092 <Show when={pinError()}>1093 <p class="peach-hint peach-hint-error" style="margin:0">{pinError()}</p>1094 </Show>1095 <button1096 class="peach-btn peach-btn--accent peach-btn--sm"1097 style="align-self:flex-start"1098 onClick={() => void handleSetupPin()}1099 disabled={pinSaving() || !pinCurrentPassword() || !/^\d{4,12}$/.test(pinValue()) || !/^\d{4,12}$/.test(pinConfirm())}1100 >1101 {pinSaving() ? "saving…" : "save PIN"}1102 </button>1103 </div>1104 </Show>1105 <Show when={pinFlow() === "disable"}>1106 <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1107 <input1108 class="peach-settings-input"1109 type="password"1110 placeholder="current password"1111 value={pinCurrentPassword()}1112 onInput={(e) => setPinCurrentPassword((e.target as HTMLInputElement).value)}1113 autocomplete="current-password"1114 onKeyDown={(e) => { if (e.key === "Enter") void handleDisablePin(); }}1115 />1116 <Show when={pinError()}>1117 <p class="peach-hint peach-hint-error" style="margin:0">{pinError()}</p>1118 </Show>1119 <button1120 class="peach-btn peach-btn--subtle peach-btn--sm"1121 style="align-self:flex-start"1122 onClick={() => void handleDisablePin()}1123 disabled={pinSaving() || !pinCurrentPassword()}1124 >1125 {pinSaving() ? "removing…" : "remove PIN"}1126 </button>1127 </div>1128 </Show>11291130 <p class="peach-settings-sub-label">BREACH PROTECTION</p>1131 <div class="peach-settings-row">1132 <span>Breach monitoring</span>1133 <button1134 class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}1135 onClick={() => void props.onBreachMonitoringChange(!props.breachMonitoring)}1136 aria-label="Toggle breach monitoring"1137 >1138 <span class="peach-toggle-knob"></span>1139 </button>1140 </div>1141 <div class="peach-settings-row peach-settings-row--meta">1142 <span>Check all passwords</span>1143 <button1144 class="peach-btn peach-btn--subtle peach-btn--sm"1145 onClick={() => void handleCheckAllBreaches()}1146 disabled={checkingBreaches()}1147 >1148 {checkingBreaches() ? "checking..." : "run"}1149 </button>1150 </div>1151 <Show when={breachResult()}>1152 <div class="peach-settings-row peach-settings-row--meta">1153 <span>Last check</span>1154 <span class="peach-settings-value">{breachResult()}</span>1155 </div>1156 </Show>11571158 <p class="peach-settings-sub-label">MASTER PASSWORD</p>1159 <div class="peach-settings-row peach-settings-row--meta">1160 <span>Master password</span>1161 <button1162 class="peach-btn peach-btn--subtle peach-btn--sm"1163 onClick={() => { setShowChangePw((v) => !v); setChangePwError(null); }}1164 >1165 {showChangePw() ? "cancel" : "change"}1166 </button>1167 </div>1168 <Show when={showChangePw()}>1169 <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1170 <input1171 class="peach-settings-input"1172 type="password"1173 placeholder="current password"1174 value={changePwCurrent()}1175 onInput={(e) => setChangePwCurrent((e.target as HTMLInputElement).value)}1176 autocomplete="current-password"1177 />1178 <input1179 class="peach-settings-input"1180 type="password"1181 placeholder="new password (min 12 chars)"1182 value={changePwNew()}1183 onInput={(e) => setChangePwNew((e.target as HTMLInputElement).value)}1184 autocomplete="new-password"1185 />1186 <input1187 class="peach-settings-input"1188 type="password"1189 placeholder="confirm new password"1190 value={changePwConfirm()}1191 onInput={(e) => setChangePwConfirm((e.target as HTMLInputElement).value)}1192 autocomplete="new-password"1193 onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }}1194 />1195 <Show when={changePwError()}>1196 <p class="peach-hint peach-hint-error" style="margin:0">{changePwError()}</p>1197 </Show>1198 <Show when={changePwSuccess()}>1199 <p class="peach-hint" style="margin:0;color:var(--peach-accent)">password changed ✓</p>1200 </Show>1201 <button1202 class="peach-btn peach-btn--accent peach-btn--sm"1203 style="align-self:flex-start"1204 onClick={() => void handleChangePassword()}1205 disabled={changePwSaving() || !changePwCurrent() || !changePwNew() || !changePwConfirm()}1206 >1207 {changePwSaving() ? "saving…" : "save new password"}1208 </button>1209 </div>1210 </Show>12111212 {}1213 <div class="peach-settings-row peach-settings-row--meta">1214 <span>Recovery words</span>1215 <button1216 class="peach-btn peach-btn--subtle peach-btn--sm"1217 onClick={() => {1218 if (showViewWords()) { closeViewWords(); } else { setShowViewWords(true); }1219 }}1220 >1221 {showViewWords() ? "cancel" : "view"}1222 </button>1223 </div>1224 <Show when={showViewWords()}>1225 <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1226 <Show1227 when={viewedWords()}1228 fallback={1229 <>1230 <p class="peach-hint" style="margin:0;color:var(--peach-t2)">1231 Enter your master password to reveal your recovery words.1232 </p>1233 <input1234 class="peach-settings-input"1235 type="password"1236 placeholder="master password"1237 value={viewWordsPassword()}1238 onInput={(e) => setViewWordsPassword((e.target as HTMLInputElement).value)}1239 autocomplete="current-password"1240 onKeyDown={(e) => { if (e.key === "Enter") void handleViewRecoveryWords(); }}1241 />1242 <Show when={viewWordsError()}>1243 <p class="peach-hint peach-hint-error" style="margin:0">{viewWordsError()}</p>1244 </Show>1245 <button1246 class="peach-btn peach-btn--accent peach-btn--sm"1247 style="align-self:flex-start"1248 onClick={() => void handleViewRecoveryWords()}1249 disabled={viewWordsBusy() || !viewWordsPassword()}1250 >1251 {viewWordsBusy() ? "checking…" : "reveal recovery words"}1252 </button>1253 </>1254 }1255 >1256 <div class="peach-recovery-words-grid">1257 <For each={viewedWords()!}>1258 {(word, i) => (1259 <span class="peach-recovery-word"><b>{i() + 1}</b> {word}</span>1260 )}1261 </For>1262 </div>1263 <button1264 class="peach-btn peach-btn--subtle peach-btn--sm"1265 style="align-self:flex-start"1266 onClick={closeViewWords}1267 >done</button>1268 </Show>1269 </div>1270 </Show>12711272 <p class="peach-settings-sub-label">1273 DURESS PASSWORD1274 <Show when={!isPro()}>1275 <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>1276 </Show>1277 </p>1278 <div class="peach-settings-row peach-settings-row--top">1279 <div class="peach-settings-row-main">1280 <span>1281 Duress password1282 <Show when={!isPro()}>1283 <span class="peach-pro-badge">Pro</span>1284 </Show>1285 </span>1286 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">1287 If entered under coercion, shows a decoy empty vault instead of your real one.1288 </span>1289 </div>1290 <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>1291 {duressEnabled() ? "on" : "off"}1292 </div>1293 </div>12941295 <Show when={duressEnabled() && !showDuressSetup()}>1296 <div class="peach-settings-row peach-settings-row--meta">1297 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1298 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.1299 </span>1300 </div>1301 </Show>13021303 <Show when={duressSuccess()}>1304 <div class="peach-settings-row peach-settings-row--meta">1305 <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>1306 </div>1307 </Show>13081309 <Show when={!showDuressSetup()}>1310 <div class="peach-settings-row peach-settings-row--meta">1311 <button1312 class="peach-btn peach-btn--subtle peach-btn--sm"1313 onClick={() => {1314 1315 1316 1317 1318 1319 if (!isPro() && !duressEnabled()) {1320 props.onProGate?.("duress_vault");1321 setActiveCard("pro");1322 return;1323 }1324 setShowDuressSetup(true);1325 setDuressError(null);1326 setDuressSuccess(null);1327 setDuressPassword("");1328 setDuressConfirm("");1329 setDuressMainPassword("");1330 }}1331 >1332 {duressEnabled() ? "Change / disable" : "Set up"}1333 </button>1334 </div>1335 </Show>13361337 <Show when={showDuressSetup()}>1338 <div class="peach-duress-form">1339 <label class="peach-label">main password (to confirm)</label>1340 <input1341 class="peach-settings-input"1342 type="password"1343 placeholder="your current master password"1344 autocomplete="current-password"1345 value={duressMainPassword()}1346 onInput={(e) => setDuressMainPassword(e.currentTarget.value)}1347 />1348 <Show when={!duressEnabled()}>1349 <label class="peach-label">new duress password</label>1350 <input1351 class="peach-settings-input"1352 type="password"1353 placeholder="duress password (min 8 chars)"1354 autocomplete="new-password"1355 value={duressPassword()}1356 onInput={(e) => setDuressPassword(e.currentTarget.value)}1357 />1358 <label class="peach-label">confirm duress password</label>1359 <input1360 class="peach-settings-input"1361 type="password"1362 placeholder="confirm duress password"1363 autocomplete="new-password"1364 value={duressConfirm()}1365 onInput={(e) => setDuressConfirm(e.currentTarget.value)}1366 />1367 </Show>13681369 <Show when={duressError()}>1370 <p class="peach-hint peach-hint-error">{duressError()}</p>1371 </Show>13721373 <div class="peach-duress-actions">1374 <button1375 class="peach-btn peach-btn--subtle peach-btn--sm"1376 onClick={() => { setShowDuressSetup(false); setDuressError(null); }}1377 >1378 cancel1379 </button>1380 <Show when={duressEnabled()}>1381 <button1382 class="peach-btn peach-btn--ghost peach-btn--sm"1383 style="color:var(--peach-breach)"1384 onClick={() => void handleDisableDuress()}1385 disabled={duressSaving()}1386 >1387 {duressSaving() ? "disabling..." : "disable duress"}1388 </button>1389 </Show>1390 <Show when={!duressEnabled()}>1391 <button1392 class="peach-btn peach-btn--accent peach-btn--sm"1393 onClick={() => void handleSetupDuress()}1394 disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}1395 >1396 {duressSaving() ? "saving..." : "save"}1397 </button>1398 </Show>1399 </div>1400 </div>1401 </Show>14021403 <p class="peach-settings-sub-label">1404 CANARY ENTRIES1405 <Show when={!isPro()}>1406 <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>1407 </Show>1408 </p>1409 <div class="peach-settings-row peach-settings-row--top">1410 <div class="peach-settings-row-main">1411 <span>1412 Canary entries1413 <Show when={!isPro()}>1414 <span class="peach-pro-badge">Pro</span>1415 </Show>1416 </span>1417 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1418 Silently pings a webhook when an entry is accessed. Configure canary on any entry in your vault.1419 </span>1420 </div>1421 <span class="peach-settings-value" style="flex-shrink:0">1422 {canaryEntries().length > 0 ? `${canaryEntries().length} active` : "none"}1423 </span>1424 </div>1425 <Show when={canaryEntries().length > 0}>1426 <For each={canaryEntries()}>1427 {(entry) => (1428 <div class="peach-settings-row peach-settings-row--meta">1429 <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0">{entry.title}</span>1430 <button1431 class="peach-btn peach-btn--subtle peach-btn--sm"1432 onClick={() => void handleTestCanary(entry)}1433 disabled={canaryTestingId() === entry.id}1434 style="flex-shrink:0"1435 >1436 {canaryTestingId() === entry.id1437 ? "sending…"1438 : canaryTestResult()?.id === entry.id1439 ? (canaryTestResult()!.ok ? "sent ✓" : "failed ✗")1440 : "test"}1441 </button>1442 </div>1443 )}1444 </For>1445 </Show>14461447 <p class="peach-settings-sub-label">PASSKEYS</p>1448 <div class="peach-settings-row peach-settings-row--top">1449 <div class="peach-settings-row-main">1450 <span>Intercept passkey requests</span>1451 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">1452 Peach can handle passkey sign-in and creation on supported Chromium browsers.1453 </span>1454 </div>1455 <button1456 class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}1457 onClick={() => void handleTogglePasskeys()}1458 aria-label="Toggle passkey interception"1459 disabled={!passkeyState()?.supported || passkeySaving()}1460 >1461 <span class="peach-toggle-knob"></span>1462 </button>1463 </div>1464 <div class="peach-settings-row peach-settings-row--meta">1465 <span>Status</span>1466 <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>1467 </div>1468 <div class="peach-settings-row peach-settings-row--meta">1469 <span>Browser</span>1470 <span class="peach-settings-value">1471 {passkeyState()?.browserLabel ?? "Unknown"}1472 {passkeyState()?.supported ? " ✓" : " (interception unavailable)"}1473 </span>1474 </div>1475 <div class="peach-info-notice" style="margin-top:8px">1476 Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.1477 </div>1478 <Show when={passkeyState()?.conflict}>1479 <div class="peach-settings-row peach-settings-row--meta">1480 <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>1481 </div>1482 </Show>1483 </div>1484 </div>1485 );1486 }14871488 function renderVault() {1489 return (1490 <div class="peach-settings-body">1491 <div class="peach-settings-group">1492 <p class="peach-settings-sub-label">ENTRIES</p>1493 <div class="peach-settings-row peach-settings-row--meta">1494 <span>Entries</span>1495 <span class="peach-settings-value">{props.entriesCount}</span>1496 </div>1497 <div class="peach-settings-row peach-settings-row--meta">1498 <span>Duplicates</span>1499 <Show when={dedupDone() !== null} fallback={1500 <button1501 class="peach-btn peach-btn--subtle peach-btn--sm"1502 onClick={() => void handleCountDuplicates()}1503 disabled={dedupRunning()}1504 >1505 {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}1506 </button>1507 }>1508 <span class="peach-settings-value" style="color:var(--peach-sync-ok)">1509 {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}1510 </span>1511 </Show>1512 </div>1513 <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>1514 <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">1515 <div class="peach-restore-warning">1516 {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.1517 </div>1518 <div style="display:flex;gap:6px">1519 <button1520 class="peach-btn peach-btn--subtle peach-btn--sm"1521 onClick={() => setDedupCount(null)}1522 >1523 cancel1524 </button>1525 <button1526 class="peach-btn peach-btn--accent peach-btn--sm"1527 onClick={() => void handleDeduplicate()}1528 disabled={dedupRunning()}1529 >1530 {dedupRunning() ? "removing..." : "remove duplicates"}1531 </button>1532 </div>1533 </div>1534 </Show>15351536 <p class="peach-settings-sub-label">MAINTENANCE</p>1537 <div class="peach-settings-row peach-settings-row--meta">1538 <span>Trash auto-delete</span>1539 <select1540 class="peach-settings-select"1541 value={String(props.trashRetentionDays)}1542 onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}1543 >1544 <option value="7">7 days</option>1545 <option value="30">30 days</option>1546 <option value="90">90 days</option>1547 <option value="0">Never</option>1548 </select>1549 </div>1550 <div class="peach-settings-row peach-settings-row--meta">1551 <span>Advanced Vault Health</span>1552 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>1553 open1554 </button>1555 </div>1556 <Show when={declinedCardCount() > 0}>1557 <div class="peach-settings-row peach-settings-row--meta">1558 <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>1559 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>1560 Clear1561 </button>1562 </div>1563 </Show>15641565 <p class="peach-settings-sub-label">DATA</p>1566 <div class="peach-settings-row peach-settings-row--meta">1567 <span>Import passwords</span>1568 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>1569 open1570 </button>1571 </div>1572 <div class="peach-settings-row peach-settings-row--meta">1573 <span>Export backup</span>1574 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onExport}>1575 open1576 </button>1577 </div>1578 <div class="peach-settings-row peach-settings-row--meta">1579 <span>1580 Peach Codex1581 <Show when={!isPro()}>1582 <span class="peach-pro-badge">Pro</span>1583 </Show>1584 </span>1585 <button1586 class="peach-btn peach-btn--subtle peach-btn--sm"1587 onClick={() => props.onOpenCodexGenerate?.()}1588 >1589 open1590 </button>1591 </div>1592 {1593}1594 <div class="peach-settings-row peach-settings-row--meta">1595 <span>Restore from Codex</span>1596 <button1597 class="peach-btn peach-btn--subtle peach-btn--sm"1598 onClick={() => props.onOpenCodexRestore?.()}1599 >1600 open1601 </button>1602 </div>16031604 <Show when={hasReleaseNotes()}>1605 <p class="peach-settings-sub-label">RELEASE NOTES</p>1606 <div class="peach-settings-row peach-settings-row--meta">1607 <span>v{PEACH_VERSION}</span>1608 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => setShowWhatsNew(true)}>1609 view1610 </button>1611 </div>1612 </Show>16131614 <span class="peach-settings-sub-label">VAULT HISTORY</span>1615 <Show when={historyLoading()}>1616 <p class="peach-settings-hint">Loading…</p>1617 </Show>1618 <Show when={!historyLoading() && historySnapshots().length === 0}>1619 <p class="peach-settings-hint">1620 No snapshots yet. Peach saves automatically before every change —1621 saves, syncs, and imports.1622 </p>1623 </Show>1624 <Show when={!historyLoading() && historySnapshots().length > 0}>1625 <p class="peach-settings-hint">1626 Restoring locks your vault. Unlock with the password that1627 encrypted that snapshot to confirm.1628 </p>1629 <div class="peach-history-list">1630 <For each={historySnapshots().slice(0, historyShowAll() ? undefined : 3)}>1631 {(snap) => (1632 <div class="peach-history-row">1633 <div class="peach-history-meta">1634 <span class="peach-history-time">1635 {formatSnapshotTime(snap.savedAt)}1636 </span>1637 <span class="peach-history-sub">1638 {[1639 snap.entryCount !== null1640 ? `up to ${snap.entryCount} ${snap.entryCount === 1 ? "entry" : "entries"}`1641 : null,1642 reasonLabel(snap.reason),1643 ]1644 .filter(Boolean)1645 .join(" · ")}1646 </span>1647 </div>1648 <button1649 class={`peach-btn peach-btn--subtle peach-btn--sm peach-history-btn${1650 historyRestoring() === snap.id ? " is-busy" : ""1651 }`}1652 onClick={() => void handleRestoreVaultSnapshot(snap.id)}1653 disabled={historyRestoring() !== null}1654 >1655 <Show when={historyRestoring() === snap.id} fallback="Restore">1656 …1657 </Show>1658 </button>1659 </div>1660 )}1661 </For>1662 </div>1663 <Show when={historySnapshots().length > 3}>1664 <button1665 class="peach-settings-advanced-toggle"1666 onClick={() => setHistoryShowAll((v) => !v)}1667 aria-expanded={historyShowAll()}1668 >1669 <span class="peach-settings-hint" style="margin:0">1670 {historyShowAll() ? "show less" : `${historySnapshots().length - 3} older`}1671 </span>1672 <span class="peach-settings-advanced-chevron">1673 {historyShowAll() ? "▲" : "▼"}1674 </span>1675 </button>1676 </Show>1677 <p class="peach-history-footer">1678 {historySnapshots().length} of 10 snapshots1679 </p>1680 </Show>16811682 <button1683 class="peach-settings-advanced-toggle"1684 onClick={() => setShowVaultAdvanced((v) => !v)}1685 aria-expanded={showVaultAdvanced()}1686 >1687 <span class="peach-settings-sub-label" style="margin:0">ADVANCED</span>1688 <span class="peach-settings-advanced-chevron">1689 {showVaultAdvanced() ? "▲" : "▼"}1690 </span>1691 </button>16921693 <Show when={showVaultAdvanced()}>1694 <div class="peach-danger-card">1695 <div class="peach-danger-card-head">1696 <span class="peach-danger-card-glyph" aria-hidden="true">⟳</span>1697 <h3 class="peach-danger-card-title">Replace vault from backup</h3>1698 </div>1699 <p class="peach-danger-card-copy">1700 Wipes the current vault and restores from a backup file.1701 Recoverable only if your backup is good.1702 </p>1703 <button1704 class="peach-danger-card-button"1705 onClick={() => props.onOpenFileImport?.()}1706 >1707 Choose backup file1708 </button>1709 </div>17101711 <Show when={props.onOpenPanic}>1712 <div class="peach-danger-card peach-danger-card--hazard">1713 <div class="peach-danger-card-head">1714 <span class="peach-danger-card-glyph" aria-hidden="true">⚠</span>1715 <h3 class="peach-danger-card-title">Destroy vault</h3>1716 </div>1717 <p class="peach-danger-card-copy">1718 Permanently wipes every entry, setting, and license on this1719 device. There is no recovery.1720 </p>1721 <button1722 class="peach-danger-card-button"1723 onClick={() => props.onOpenPanic?.()}1724 >1725 Begin destruction1726 </button>1727 </div>1728 </Show>1729 </Show>1730 </div>1731 </div>1732 );1733 }17341735 function renderSync() {1736 return (1737 <div class="peach-settings-body">1738 <div class="peach-settings-group">1739 <p class="peach-settings-sub-label" style="margin-top:0">SYNC STATUS</p>1740 <Show when={trustDataCorrupted()}>1741 <p class="peach-settings-value" role="alert" style="color:var(--peach-danger,#e5805c);line-height:1.5">1742 Device trust data is damaged — re-pair your devices after updating1743 </p>1744 </Show>1745 <div1746 class="peach-settings-row peach-settings-row--meta"1747 style="align-items:center;gap:8px"1748 aria-label={syncLightLabel()}1749 title={syncLightLabel()}1750 >1751 <span class={`stk-pip stk-pip--sync is-${syncLight()}`} aria-hidden="true" />1752 <span class="peach-settings-value" style="color:var(--peach-t2);line-height:1.5;flex:1;min-width:0;overflow-wrap:anywhere">1753 {syncFlash() ?? lastSyncLine()}1754 </span>1755 {17561757}1758 <Show when={pairedDevices().length > 0}>1759 <button1760 class="peach-btn peach-btn--subtle peach-btn--sm"1761 aria-label="Sync this vault now"1762 disabled={syncStatus() === "syncing"}1763 onClick={() => void syncNow()}1764 >{syncStatus() === "syncing" ? "syncing…" : "sync now"}</button>1765 </Show>1766 </div>17671768 {17691770}1771 <Show when={generation()?.state && generation()?.state !== "V2_ACTIVE"}>1772 <p class="peach-settings-sub-label">SECURE SYNC RESET</p>1773 <div class="peach-settings-row peach-settings-row--top">1774 <div class="peach-settings-row-main">1775 <span class="peach-settings-value" style="color:var(--peach-t1)">1776 {secureResetHeadline(generation()?.state)}1777 </span>1778 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1779 {secureResetDetail(generation()?.state)}1780 </span>1781 </div>1782 <Show1783 when={1784 generation()?.state === "LEGACY_ACTIVE" ||1785 generation()?.state === "RESET_REQUIRED" ||1786 generation()?.state === "RESET_CONFIRMED"1787 }1788 >1789 <button1790 class="peach-btn peach-btn--subtle peach-btn--sm"1791 onClick={() => {1792 setResetError(null);1793 setResetPassword("");1794 setResetOpen(true);1795 }}1796 >reset sync</button>1797 </Show>1798 </div>1799 <Show when={resetError()}>1800 <p1801 class="peach-settings-value"1802 role="alert"1803 style="color:var(--peach-danger,#e5805c);font-size:11px;line-height:1.5"1804 >{resetError()}</p>1805 </Show>1806 <Show when={resetOpen()}>1807 <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1808 <p class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1809 {SECURE_RESET_DISCLOSURE}1810 </p>1811 <input1812 class="peach-settings-input"1813 type="password"1814 placeholder="master password"1815 value={resetPassword()}1816 onInput={(e) => setResetPassword((e.target as HTMLInputElement).value)}1817 autocomplete="current-password"1818 />1819 <div style="display:flex;gap:4px">1820 <button1821 class="peach-btn peach-btn--subtle peach-btn--sm"1822 disabled={resetting()}1823 onClick={() => void runSecureReset()}1824 >{resetting() ? "resetting…" : "reset"}</button>1825 <button1826 class="peach-btn peach-btn--subtle peach-btn--sm"1827 disabled={resetting()}1828 onClick={() => setResetOpen(false)}1829 >cancel</button>1830 </div>1831 </div>1832 </Show>1833 </Show>18341835 <Show when={pairedDevices().length > 0}>1836 <p class="peach-settings-sub-label">PAIRED DEVICES</p>1837 <For each={pairedDevices()}>1838 {(d) => (1839 <div class="peach-settings-row peach-settings-row--top">1840 <div class="peach-settings-row-main">1841 <Show1842 when={renameDeviceId() === d.deviceId}1843 fallback={<span>{d.localAlias ?? d.deviceName ?? "Unnamed device"}</span>}1844 >1845 <input1846 class="peach-settings-input"1847 aria-label={`Rename ${d.localAlias ?? d.deviceName ?? "device"}`}1848 maxlength={64}1849 value={renameDraft()}1850 onInput={(event) => setRenameDraft(event.currentTarget.value)}1851 onKeyDown={(event) => {1852 if (event.key === "Enter") void renameDevice(d.deviceId);1853 if (event.key === "Escape") setRenameDeviceId(null);1854 }}1855 />1856 </Show>1857 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1858 {deviceTypeLabel(d.deviceType)} · {syncRelativeTime(d.lastSyncedAt)}{isSyncStale(d) ? " · not seen recently" : ""}1859 </span>1860 </div>1861 <Show1862 when={confirmUnpair() === d.deviceId}1863 fallback={1864 <div style="display:flex;gap:4px;flex-shrink:0">1865 <Show1866 when={renameDeviceId() === d.deviceId}1867 fallback={1868 <button1869 class="peach-btn peach-btn--subtle peach-btn--sm"1870 onClick={() => {1871 setRenameDraft(d.localAlias ?? d.deviceName ?? "");1872 setRenameDeviceId(d.deviceId);1873 }}1874 >rename</button>1875 }1876 >1877 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => setRenameDeviceId(null)}>cancel</button>1878 <button class="peach-btn peach-btn--subtle peach-btn--sm" disabled={!renameDraft().trim()} onClick={() => void renameDevice(d.deviceId)}>save</button>1879 </Show>1880 <button1881 class="peach-btn peach-btn--subtle peach-btn--sm"1882 onClick={() => {1883 setUnpairError(null);1884 setConfirmUnpair(d.deviceId);1885 }}1886 >remove</button>1887 </div>1888 }1889 >1890 {18911892189318941895}1896 <div class="peach-unpair-confirm">1897 <p class="peach-unpair-confirm-note">1898 Stops this device receiving future changes. It doesn't1899 lock it out of what it already has — if it's lost or1900 stolen, also change your master password and generate1901 new recovery words.1902 </p>1903 <div style="display:flex;gap:6px;justify-content:flex-end">1904 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => setConfirmUnpair(null)}>cancel</button>1905 <button1906 class="peach-btn peach-btn--sm"1907 style="background:var(--peach-breach);border-color:var(--peach-breach)"1908 onClick={() => void unpairDevice(d.deviceId)}1909 >remove</button>1910 </div>1911 </div>1912 <Show when={unpairError()}>1913 <p class="peach-settings-value" role="alert" style="color:var(--peach-danger,#e5805c);font-size:11px;line-height:1.5">1914 {unpairError()}1915 </p>1916 </Show>1917 </Show>1918 </div>1919 )}1920 </For>1921 </Show>19221923 <Show when={pendingVouched().length > 0}>1924 <p class="peach-settings-sub-label">1925 PENDING DEVICES1926 <span class="peach-pro-badge" style="background:#C8A840;color:#1a1a1a">{pendingVouched().length}</span>1927 </p>1928 <For each={pendingVouched()}>1929 {(dev) => (1930 <div class="peach-settings-row peach-settings-row--top">1931 <div class="peach-settings-row-main">1932 <span>{dev.deviceName}</span>1933 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1934 Key {dev.longTermPublicKeyHex.slice(0, 12).match(/.{4}/g)?.join(" ")} ·1935 requested {new Date(dev.receivedAt).toISOString().slice(0, 16).replace("T", " ")} UTC ·1936 vouched by device {dev.vouchedByDeviceId}. Allowing it can read1937 existing vault contents and all future changes.1938 </span>1939 </div>1940 <div style="display:flex;gap:6px;flex-shrink:0">1941 <button1942 class="peach-btn peach-btn--sm"1943 disabled={vouchedBusy() === dev.deviceId}1944 onClick={() => void confirmVouched(dev.deviceId)}1945 >Allow</button>1946 <button1947 class="peach-btn peach-btn--subtle peach-btn--sm"1948 disabled={vouchedBusy() === dev.deviceId}1949 onClick={() => void rejectVouched(dev.deviceId)}1950 >Reject</button>1951 </div>1952 </div>1953 )}1954 </For>1955 </Show>19561957 <p class="peach-settings-sub-label">ADD A DEVICE</p>1958 <button1959 class="peach-sync-pair-option"1960 onClick={() => props.onOpenSyncPairing?.("code")}1961 >1962 <span class="peach-sync-pair-option-icon" aria-hidden="true">1963 <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">1964 <rect x="6" y="2.5" width="8" height="15" rx="1.6" />1965 <path d="M9 15 L11 15" />1966 </svg>1967 </span>1968 <span class="peach-sync-pair-option-main">1969 <span class="peach-sync-pair-option-title">Show a code</span>1970 <span class="peach-sync-pair-option-sub">Scan it with Peach on your phone, then confirm one 8-digit code</span>1971 </span>1972 <span class="peach-sync-pair-option-chevron" aria-hidden="true">›</span>1973 </button>1974 <Show when={!isPro()}>1975 <p class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2);margin:8px 2px 0">1976 At least one device needs Peach Pro — pair with a Pro device and it carries over to here.1977 </p>1978 </Show>19791980 <button1981 class="peach-settings-advanced-toggle peach-sync-disclosure-toggle"1982 onClick={() => setShowSyncConnection((value) => !value)}1983 aria-expanded={showSyncConnection()}1984 aria-controls="peach-sync-connection"1985 >1986 <span>1987 <span class="peach-settings-sub-label" style="margin:0">CONNECTION</span>1988 <span class="peach-sync-disclosure-summary">Relay and privacy choices</span>1989 </span>1990 <span class="peach-settings-advanced-chevron">1991 {showSyncConnection() ? "▲" : "▼"}1992 </span>1993 </button>1994 <Show when={showSyncConnection()}>1995 <div id="peach-sync-connection" class="peach-sync-disclosure-content">1996 <SyncModeSelector />1997 </div>1998 </Show>19992000 <button2001 class="peach-settings-advanced-toggle peach-sync-disclosure-toggle"2002 onClick={() => setShowSyncRecovery((value) => !value)}2003 aria-expanded={showSyncRecovery()}2004 aria-controls="peach-sync-recovery"2005 >2006 <span>2007 <span class="peach-settings-sub-label" style="margin:0">RECOVERY & SECURITY</span>2008 <span class="peach-sync-disclosure-summary">For a lost or stolen device</span>2009 </span>2010 <span class="peach-settings-advanced-chevron">2011 {showSyncRecovery() ? "▲" : "▼"}2012 </span>2013 </button>2014 <Show when={showSyncRecovery()}>2015 <div id="peach-sync-recovery" class="peach-sync-disclosure-content">2016 <Show2017 when={rotateConfirmOpen()}2018 fallback={2019 <div class="peach-settings-row peach-settings-row--top">2020 <div class="peach-settings-row-main">2021 <span>Rotate sync identity</span>2022 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">2023 Disconnects every device and replaces the keys used for sync.2024 </span>2025 </div>2026 <button2027 class="peach-btn peach-btn--subtle peach-btn--sm"2028 style="color:var(--peach-breach);border-color:color-mix(in srgb,var(--peach-breach) 40%,transparent)"2029 onClick={() => { setRotateError(null); setRotateConfirmOpen(true); }}2030 >rotate</button>2031 </div>2032 }2033 >2034 <div class="peach-info-notice" style="margin-top:8px;border-color:color-mix(in srgb,var(--peach-breach) 40%,transparent)">2035 This disconnects every paired device and changes your 12 recovery words. You'll be shown the new words next.2036 <Show when={rotateError()}>2037 <div style="color:var(--peach-breach);margin-top:6px">{rotateError()}</div>2038 </Show>2039 <div style="display:flex;gap:6px;margin-top:10px">2040 <button2041 class="peach-btn peach-btn--subtle peach-btn--sm"2042 disabled={rotating()}2043 onClick={() => setRotateConfirmOpen(false)}2044 >Cancel</button>2045 <button2046 class="peach-btn peach-btn--sm"2047 style="background:var(--peach-breach);border-color:var(--peach-breach)"2048 disabled={rotating()}2049 onClick={() => void doRotateSyncIdentity()}2050 >{rotating() ? "Rotating…" : "Rotate sync identity"}</button>2051 </div>2052 </div>2053 </Show>2054 </div>2055 </Show>2056 </div>2057 </div>2058 );2059 }20602061 function renderAppearance() {2062 const sizeOptions: Array<{ id: "medium" | "large"; label: string }> = [2063 { id: "medium", label: "Default" },2064 { id: "large", label: "Large" },2065 ];2066 return (2067 <div class="peach-settings-body">2068 <div class="peach-settings-group">2069 <p class="peach-settings-sub-label">THEME</p>2070 <div class="peach-settings-row peach-settings-row--meta">2071 <span>Theme</span>2072 <button2073 class="peach-btn peach-btn--subtle peach-btn--sm"2074 onClick={() =>2075 props.onThemeChange(props.theme === "dark" ? "light" : "dark")2076 }2077 >2078 {props.theme === "dark" ? "Switch to light" : "Switch to dark"}2079 </button>2080 </div>20812082 <p class="peach-settings-sub-label">ACCENT</p>2083 <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">2084 <span>Accent</span>2085 <div class="peach-accent-picker">2086 <For each={ACCENT_COLORS}>2087 {(color) => (2088 <button2089 class="peach-accent-swatch peach-swatch"2090 classList={{ "is-active": props.accentColor === color.name }}2091 style={{ background: color.hex }}2092 onClick={() => void props.onAccentChange(color.name)}2093 aria-label={color.name}2094 title={color.name}2095 />2096 )}2097 </For>2098 </div>2099 </div>21002101 <p class="peach-settings-sub-label">TEXT SIZE</p>2102 <div class="peach-settings-row peach-settings-row--meta">2103 <span>Size</span>2104 <div class="peach-pill-group">2105 <For each={sizeOptions}>2106 {(opt) => (2107 <button2108 class="peach-btn peach-btn--ghost peach-btn--sm"2109 classList={{ "is-active": props.textSize === opt.id }}2110 onClick={() => void props.onTextSizeChange(opt.id)}2111 >2112 {opt.label}2113 </button>2114 )}2115 </For>2116 </div>2117 </div>21182119 <p class="peach-settings-sub-label">MOTION</p>2120 <div class="peach-settings-row peach-settings-row--meta">2121 <span>Reduce motion</span>2122 <button2123 class="peach-btn peach-btn--subtle peach-btn--sm"2124 onClick={() => void props.onReduceMotionChange(!props.reduceMotion)}2125 >2126 {props.reduceMotion ? "On" : "Off"}2127 </button>2128 </div>2129 </div>2130 </div>2131 );2132 }21332134 function renderShortcuts() {2135 const [capturingAction, setCapturingAction] = createSignal<ShortcutAction | null>(null);2136 const [conflictMsg, setConflictMsg] = createSignal<string | null>(null);21372138 function startCapture(action: ShortcutAction) {2139 setConflictMsg(null);2140 setCapturingAction(action);2141 }2142 function cancelCapture() {2143 setCapturingAction(null);2144 setConflictMsg(null);2145 }2146 function commitBinding(action: ShortcutAction, binding: Keybinding) {2147 const conflict = findConflict(props.shortcuts, binding, action);2148 if (conflict) {2149 const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict);2150 setConflictMsg(`Already bound to "${other?.label ?? conflict}"`);2151 return;2152 }2153 const next = { ...props.shortcuts, [action]: binding };2154 setCapturingAction(null);2155 setConflictMsg(null);2156 void props.onShortcutsChange(next);2157 }2158 function resetAction(action: ShortcutAction) {2159 const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] };2160 void props.onShortcutsChange(next);2161 }2162 function resetAll() {2163 void props.onShortcutsChange({ ...DEFAULT_BINDINGS });2164 }21652166 2167 2168 function handleCaptureKey(e: KeyboardEvent) {2169 e.preventDefault();2170 e.stopPropagation();2171 if (e.key === "Escape") { cancelCapture(); return; }2172 const binding = keybindingFromEvent(e);2173 if (!binding) return; 2174 const action = capturingAction();2175 if (action) commitBinding(action, binding);2176 }21772178 return (2179 <div class="peach-settings-body">2180 <div class="peach-settings-group peach-shortcuts-group">2181 <p class="peach-settings-sub-label">ENTRY VIEW</p>2182 <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry")}>2183 {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}2184 </For>2185 </div>21862187 <div class="peach-settings-group peach-shortcuts-group">2188 <p class="peach-settings-sub-label">ANYWHERE IN POPUP</p>2189 <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global")}>2190 {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}2191 </For>2192 </div>21932194 <Show when={conflictMsg()}>2195 <div class="peach-shortcuts-conflict">{conflictMsg()}</div>2196 </Show>21972198 <div class="peach-settings-group" style="display:flex;flex-direction:column;gap:8px">2199 <div class="peach-settings-row peach-settings-row--meta">2200 <span>Inline autofill on page</span>2201 <a2202 class="peach-settings-link"2203 href="chrome://extensions/shortcuts"2204 onClick={(e) => {2205 e.preventDefault();2206 chrome.tabs.create({ url: "chrome://extensions/shortcuts" });2207 }}2208 >2209 ⌘⇧P — customize ↗2210 </a>2211 </div>2212 <button2213 class="peach-btn peach-btn--subtle peach-btn--sm"2214 style="align-self:flex-start"2215 onClick={resetAll}2216 >2217 Reset all to defaults2218 </button>2219 </div>2220 </div>2221 );22222223 function renderRow(2224 desc: typeof SHORTCUT_DESCRIPTORS[number],2225 capturing: () => ShortcutAction | null,2226 onStart: (a: ShortcutAction) => void,2227 onCancel: () => void,2228 onReset: (a: ShortcutAction) => void,2229 onKey: (e: KeyboardEvent) => void,2230 ) {2231 const isActive = () => capturing() === desc.action;2232 const binding = () => props.shortcuts[desc.action];2233 const isCustom = () => {2234 const b = binding();2235 const d = DEFAULT_BINDINGS[desc.action];2236 return b.key !== d.key2237 || Boolean(b.ctrl) !== Boolean(d.ctrl)2238 || Boolean(b.meta) !== Boolean(d.meta)2239 || Boolean(b.shift) !== Boolean(d.shift)2240 || Boolean(b.alt) !== Boolean(d.alt);2241 };2242 return (2243 <div class="peach-shortcut-row">2244 <span class="peach-shortcut-row-label">{desc.label}</span>2245 <button2246 class="peach-shortcut-chip"2247 classList={{ "is-capturing": isActive(), "is-custom": isCustom() }}2248 onClick={() => isActive() ? onCancel() : onStart(desc.action)}2249 onKeyDown={(e) => { if (isActive()) onKey(e); }}2250 ref={(el) => {2251 2252 if (el) {2253 queueMicrotask(() => { if (isActive()) el.focus(); });2254 }2255 }}2256 >2257 {isActive() ? "Press a key…" : formatKeybinding(binding())}2258 </button>2259 <Show when={isCustom() && !isActive()}>2260 <button2261 class="peach-shortcut-reset"2262 title="Reset to default"2263 onClick={() => onReset(desc.action)}2264 >2265 ↺2266 </button>2267 </Show>2268 </div>2269 );2270 }2271 }22722273 const [licenseKey, setLicenseKey] = createSignal<string | null>(null);2274 const [showKey, setShowKey] = createSignal(false);22752276 const [deactivating, setDeactivating] = createSignal(false);22772278 async function handleDeactivateLicense() {2279 setDeactivating(true);2280 await sendMessage({ type: "DEACTIVATE_LICENSE" });2281 setLicenseInfo(null);2282 await props.onLicenseChange?.();2283 setDeactivating(false);2284 }22852286 async function handleTestCanary(entry: {id: string; title: string; canary: Canary}) {2287 setCanaryTestingId(entry.id);2288 setCanaryTestResult(null);2289 const res = await sendMessage({2290 type: "FIRE_CANARY_PING",2291 payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger: "test" },2292 }) as { ok: boolean };2293 setCanaryTestResult({ id: entry.id, ok: res.ok });2294 setCanaryTestingId(null);2295 setTimeout(() => setCanaryTestResult((r) => r?.id === entry.id ? null : r), 3000);2296 }22972298 function renderLicense() {2299 async function onActivate() {2300 await handleActivateLicense();2301 if ((licenseInfo()?.valid ?? false) && licenseInfo()?.plan === "pro") {2302 await props.onLicenseChange?.();2303 }2304 }23052306 return (2307 <div class="peach-settings-body">2308 <Show2309 when={isPro()}2310 fallback={2311 <div class="peach-pro-upgrade">2312 <p class="peach-pro-eyebrow">PEACH PRO · ONE-TIME</p>23132314 <h2 class="peach-pro-headline">2315 Every<em>thing,</em><br />every<em>where.</em>2316 </h2>23172318 <p class="peach-pro-subtitle">2319 One key unlocks every Pro feature — for the lifetime of the app.2320 </p>23212322 <div class="peach-pro-grid">2323 <For each={PRO_FEATURES}>2324 {(item, i) => (2325 <div2326 class="peach-pro-grid-item"2327 classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}2328 >2329 <span class="peach-pro-grid-num">{String(i() + 1).padStart(2, "0")}</span>2330 <span class="peach-pro-grid-name">{item.name}</span>2331 <span class="peach-pro-grid-desc">{item.desc}</span>2332 </div>2333 )}2334 </For>2335 </div>23362337 <p class="peach-pro-section-label">ADVANCED SECURITY</p>2338 <div class="peach-pro-grid">2339 <For each={PRO_SECURITY_FEATURES}>2340 {(item, i) => (2341 <div2342 class="peach-pro-grid-item"2343 classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}2344 >2345 <span class="peach-pro-grid-num">{String(PRO_FEATURES.length + i() + 1).padStart(2, "0")}</span>2346 <span class="peach-pro-grid-name">{item.name}</span>2347 <span class="peach-pro-grid-desc">{item.desc}</span>2348 </div>2349 )}2350 </For>2351 </div>23522353 <div class="peach-pro-upgrade-actions">2354 <p class="peach-pro-pricing">2355 $39 <span class="peach-pro-pricing-sub">once · yours forever</span>2356 </p>2357 <a2358 href="https://buy.polar.sh/polar_cl_IXSFIPojx67rcOTksn8LkH5mnU3osPHtYqBIl1eyuIz"2359 target="_blank"2360 rel="noopener noreferrer"2361 class="peach-pro-upgrade-cta"2362 >2363 Get Peach Pro2364 </a>2365 <div class="peach-pro-key-divider"><span>ALREADY HAVE A KEY</span></div>2366 <div class="peach-pro-key-inline">2367 <input2368 class="peach-pro-key-inline-input"2369 type="text"2370 placeholder="PEACH-XXXX-XXXX-XXXX"2371 value={licenseKeyInput()}2372 onInput={(e) => setLicenseKeyInput(e.currentTarget.value)}2373 onKeyDown={(e) => { if (e.key === "Enter") void onActivate(); }}2374 autocomplete="off"2375 spellcheck={false}2376 />2377 <button2378 class="peach-pro-key-apply"2379 onClick={() => void onActivate()}2380 disabled={activatingLicense() || !licenseKeyInput().trim()}2381 >2382 {activatingLicense() ? "…" : "APPLY"}2383 </button>2384 </div>2385 <Show when={licenseError()}>2386 <p class="peach-pro-key-error">{licenseError()}</p>2387 </Show>2388 </div>2389 </div>2390 }2391 >2392 <div class="peach-pro-active">2393 <div class="peach-pro-active-hero">2394 <button2395 class="peach-pro-active-mark-wrap"2396 type="button"2397 aria-label="Celebrate"2398 onClick={handleProCelebrationClick}2399 >2400 <PeachMark class="peach-pro-active-mark" />2401 <Show when={justActivated()}>2402 <div class="peach-pro-sparks" aria-hidden="true">2403 <span class="peach-pro-spark" style="--spark-x:0px;--spark-y:-54px;--spark-delay:0ms">✦</span>2404 <span class="peach-pro-spark" style="--spark-x:38px;--spark-y:-38px;--spark-delay:50ms">✦</span>2405 <span class="peach-pro-spark" style="--spark-x:54px;--spark-y:0px;--spark-delay:110ms">·</span>2406 <span class="peach-pro-spark" style="--spark-x:38px;--spark-y:38px;--spark-delay:30ms">✦</span>2407 <span class="peach-pro-spark" style="--spark-x:0px;--spark-y:54px;--spark-delay:80ms">·</span>2408 <span class="peach-pro-spark" style="--spark-x:-38px;--spark-y:38px;--spark-delay:130ms">✦</span>2409 <span class="peach-pro-spark" style="--spark-x:-54px;--spark-y:0px;--spark-delay:55ms">·</span>2410 <span class="peach-pro-spark" style="--spark-x:-38px;--spark-y:-38px;--spark-delay:95ms">✦</span>2411 </div>2412 </Show>2413 </button>2414 <h2 class="peach-pro-active-title">Peach Pro</h2>2415 <p class="peach-pro-active-since">{formatActivatedSince(licenseInfo()?.issued)}</p>2416 <Show when={justActivated()}>2417 <p class="peach-pro-thankyou-text" aria-live="polite">thank you ✦</p>2418 </Show>2419 </div>24202421 <Show when={licenseKey()}>2422 <div class="peach-settings-license">2423 <span class="peach-settings-label">LICENSE KEY</span>2424 <div class="peach-settings-license-row">2425 <Show2426 when={showKey()}2427 fallback={2428 <span class="peach-settings-license-key peach-settings-license-key--masked" aria-label="License key hidden">2429 ••••••••••••••••••••••••••••2430 </span>2431 }2432 >2433 <span class="peach-settings-license-key">{licenseKey()!}</span>2434 </Show>2435 <div class="peach-settings-license-actions">2436 <button2437 class="peach-settings-license-copy"2438 title="Copy license key"2439 onClick={() => copyLicenseKey(licenseKey()!)}2440 >2441 {copied() ? "✓" : "copy"}2442 </button>2443 <button2444 class="peach-settings-license-toggle"2445 aria-label={showKey() ? "Hide license key" : "Reveal license key"}2446 title={showKey() ? "Hide" : "Reveal"}2447 onClick={() => setShowKey(v => !v)}2448 >2449 <Show2450 when={showKey()}2451 fallback={2452 <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">2453 <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />2454 <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />2455 </svg>2456 }2457 >2458 <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">2459 <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />2460 <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />2461 <line x1="2" y1="2" x2="12" y2="12" />2462 </svg>2463 </Show>2464 </button>2465 </div>2466 </div>2467 </div>2468 </Show>24692470 <div class="peach-pro-active-features">2471 <For each={[...PRO_FEATURES, ...PRO_SECURITY_FEATURES]}>2472 {(item) => (2473 <div class="peach-pro-active-feature">2474 <span class="peach-pro-active-feature-mark">✦</span>2475 <span class="peach-pro-active-feature-name">{item.name}</span>2476 </div>2477 )}2478 </For>2479 </div>24802481 <button2482 class="peach-pro-deactivate"2483 onClick={() => void handleDeactivateLicense()}2484 disabled={deactivating()}2485 >2486 {deactivating() ? "Deactivating…" : "Deactivate"}2487 </button>2488 </div>2489 </Show>2490 </div>2491 );2492 }24932494 function renderFeedback() {2495 return (2496 <div class="peach-settings-body">2497 <div class="peach-settings-group peach-feedback-group">2498 <Show2499 when={feedbackState() === 'sent'}2500 fallback={2501 <>2502 <p class="peach-feedback-intro">2503 Help us improve Peach. Every report goes directly to our inbox.2504 </p>25052506 <p class="peach-settings-sub-label">TYPE</p>2507 <div class="peach-feedback-chips">2508 <For each={FEEDBACK_CATEGORIES}>2509 {(cat) => {2510 const selected = () => fbCategory() === cat;2511 return (2512 <button2513 type="button"2514 class={`peach-feedback-chip ${selected() ? "is-active" : ""}`}2515 aria-pressed={selected()}2516 onClick={() => setFbCategory(cat)}2517 >2518 {cat}2519 </button>2520 );2521 }}2522 </For>2523 </div>25242525 <p class="peach-settings-sub-label">MESSAGE</p>2526 <textarea2527 class="peach-feedback-textarea"2528 placeholder="Describe the issue or idea..."2529 maxLength={MAX_FEEDBACK_MESSAGE_LENGTH}2530 value={fbMessage()}2531 onInput={(e) => setFbMessage(e.currentTarget.value)}2532 />25332534 <p class="peach-feedback-meta">2535 Version {version} · will be included automatically2536 </p>25372538 <button2539 type="button"2540 class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"2541 disabled={!fbMessage().trim() || feedbackState() === 'sending'}2542 onClick={() => void handleSubmitFeedback()}2543 >2544 <Switch>2545 <Match when={feedbackState() === 'idle'}>Send</Match>2546 <Match when={feedbackState() === 'sending'}>Sending...</Match>2547 <Match when={feedbackState() === 'error'}>Failed - try again</Match>2548 </Switch>2549 </button>25502551 <p class="peach-feedback-anon">2552 Sent anonymously · no account required2553 </p>2554 </>2555 }2556 >2557 <div class="peach-feedback-thanks" role="status" aria-live="polite">2558 <p class="peach-feedback-thanks-title">Thank you</p>2559 <p>2560 Your note is in the queue. Someone will process it soon.2561 </p>2562 </div>2563 </Show>2564 </div>2565 </div>2566 );2567 }25682569 const bodies: StackBodies = {2570 protection: renderSecurity(),2571 vault: renderVault(),2572 sync: renderSync(),2573 appearance: renderAppearance(),2574 shortcuts: renderShortcuts(),2575 pro: renderLicense(),2576 feedback: renderFeedback(),2577 };25782579 return (2580 <Show2581 when={showWhatsNew()}2582 fallback={2583 <Show2584 when={showNewRecoveryWords()}2585 fallback={2586 <div2587 class="peach-overlay-view peach-settings-overlay"2588 classList={{ "ov-enter-right": !props.isExiting, "ov-exit-right": !!props.isExiting }}2589 style="display:flex;flex-direction:column"2590 >2591 <StackDeck2592 active={activeCard()}2593 onActiveChange={setActiveCard}2594 onBack={props.onBack}2595 cards={cards()}2596 bodies={bodies}2597 version={version}2598 isFanning={isFanning()}2599 celebratingCardId={justActivated() ? "pro" : null}2600 isPro={isPro()}2601 />2602 </div>2603 }2604 >2605 <RecoveryWordDisplayView onDone={() => setShowNewRecoveryWords(false)} />2606 </Show>2607 }2608 >2609 <div class="peach-overlay-view peach-settings-overlay">2610 <WhatsNewView mode="history" onContinue={() => setShowWhatsNew(false)} />2611 </div>2612 </Show>2613 );2614}