1import { createEffect, createMemo, createSignal, For, Match, onCleanup, onMount, Show, Switch } from "solid-js";2import { sendMessage } from "../App";3import { PeachMark } from "../components/PeachMark";4import { SyncModeSelector } from "../components/SyncModeSelector";5import { StackDeck } from "../components/settings/StackDeck";6import type { CardId, StackBodies, StackCardSpec } from "../components/settings/types";7import { RecoveryWordDisplayView } from "./RecoveryWordDisplay";8import { WhatsNewView } from "./WhatsNew";9import type { Canary } from "../../shared/types";10import { ACCENT_COLORS } from "../../shared/types";11import { PEACH_VERSION, hasReleaseNotes } from "../../shared/release-notes";12import type {13 AccentColorName,14 PasskeyProviderState,15 ProFeature,16 VaultSnapshot,17} from "../../shared/types";18import type { PairedDevice } from "../../shared/sync-types";19import {20 describeSyncOutcome,21 syncIndicator,22 syncIndicatorLabel,23 type SyncOutcomeSummary,24} from "../../shared/sync-outcome";25import type { SyncGeneration } from "../../background/sync-generation";26import {27 DEFAULT_BINDINGS,28 findConflict,29 formatKeybinding,30 keybindingFromEvent,31 SHORTCUT_DESCRIPTORS,32 type Keybinding,33 type ShortcutAction,34} from "../../shared/shortcuts";35import { WEB3FORMS_KEY } from "../../shared/config";3637interface LicenseInfo {38 valid: boolean;39 plan: "free" | "pro";40 issued?: string;41}4243interface SettingsProps {44 entriesCount: number;45 breachMonitoring: boolean;46 lockOnClose: boolean;47 autoLockMinutes: number;48 trashRetentionDays: number;49 theme: "dark" | "light";50 accentColor: AccentColorName;51 textSize: "small" | "medium" | "large";52 reduceMotion: boolean;53 isPro?: boolean;54 initialSection?: string | null;55 upgradeHighlight?: ProFeature | null;56 onBreachMonitoringChange: (enabled: boolean) => void | Promise<void>;57 onLockOnCloseChange: (enabled: boolean) => void | Promise<void>;58 onAutoLockMinutesChange: (minutes: number) => void | Promise<void>;59 onTrashRetentionChange: (days: number) => void | Promise<void>;60 onThemeChange: (theme: "dark" | "light") => void;61 onAccentChange: (name: AccentColorName) => void | Promise<void>;62 onTextSizeChange: (size: "small" | "medium" | "large") => void | Promise<void>;63 onReduceMotionChange: (enabled: boolean) => void | Promise<void>;64 shortcuts: Record<ShortcutAction, Keybinding>;65 onShortcutsChange: (active: Record<ShortcutAction, Keybinding>) => void | Promise<void>;66 onLicenseChange?: () => void | Promise<void>;67 isExiting?: boolean;68 onBack: () => void;69 onImport: () => void;70 onExport: () => void;71 onBreachCheckDone: () => void;72 onOpenHealth?: () => void;73 onOpenSyncPairing?: (method?: "code") => void;74 onOpenFileImport?: () => void;75 onOpenPanic?: () => void;76 onOpenCodexGenerate?: () => void;77 onOpenCodexRestore?: () => void;78 798081 onProGate?: (feature: ProFeature) => void;82 onCardChange?: (id: CardId) => void;83}8485const FEEDBACK_CATEGORIES = ["Bug report", "Feature request", "Question", "Other"];86const MAX_FEEDBACK_MESSAGE_LENGTH = 4000;87const PRO_CELEBRATION_CARD_STEP_MS = 150;88const PRO_CELEBRATION_THANK_YOU_MS = 3200;8990function sanitizeFeedbackMessage(value: string): string {91 return value92 .trim()93 .slice(0, MAX_FEEDBACK_MESSAGE_LENGTH)94 95 .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "")96 .replace(/&/g, "&")97 .replace(/</g, "<")98 .replace(/>/g, ">")99 .replace(/"/g, """)100 .replace(/'/g, "'");101}102103function formatActivatedSince(issued: string | undefined): string {104 if (!issued) return "Active";105 try {106 const d = new Date(issued);107 if (Number.isNaN(d.getTime())) return "Active";108 return `Active since ${d.toLocaleDateString(undefined, { month: "long", year: "numeric" })}`;109 } catch {110 return "Active";111 }112}113114function initialSectionToCard(value: string | null | undefined): CardId | null {115 if (!value) return null;116 if (value === "security") return "protection";117 if (value === "license") return "pro";118 if (119 value === "protection" ||120 value === "vault" ||121 value === "sync" ||122 value === "appearance" ||123 value === "shortcuts" ||124 value === "pro" ||125 value === "feedback"126 ) {127 return value;128 }129 return null;130}131132133134135136const SECURE_RESET_DISCLOSURE =137 "Peach will archive this device's old Sync trust and start the new Sync generation. " +138 "Every other device must be updated first, then re-paired with this one — they will " +139 "not continue on their own. Your vault and your recovery words are not changed.";140141function secureResetHeadline(state: string | undefined): string {142 switch (state) {143 case "LEGACY_ACTIVE":144 return "Legacy Sync generation";145 case "RESET_REQUIRED":146 return "Reset required";147 case "RESET_CONFIRMED":148 return "Reset not completed";149 case "V2_ANCHOR":150 case "REPAIRING":151 return "Not re-paired";152 case "V2_ACTIVE":153 return "New Sync generation";154 case "UNKNOWN":155 return "Sync generation unreadable";156 default:157 return "Sync generation";158 }159}160161function secureResetDetail(state: string | undefined): string {162 switch (state) {163 case "LEGACY_ACTIVE":164 return "This device still holds the old Sync trust. Resetting retires it for good: update Peach on every other device, then re-pair each one.";165 case "RESET_REQUIRED":166 return "Sync is stopped until the reset completes. Update Peach on every other device, then reset this one and re-pair each device.";167 case "RESET_CONFIRMED":168 return "The reset did not finish. Nothing was lost — the old trust is archived. Try again.";169 case "V2_ANCHOR":170 case "REPAIRING":171 return "This device anchors the new Sync generation and is not re-paired yet. Pair each device again; until then it keeps its own vault and cannot sync.";172 case "V2_ACTIVE":173 return "This device is on the new Sync generation. Each device is re-paired individually.";174 case "UNKNOWN":175 return "This device's Sync generation can't be read, so sync is stopped. Reinstall or contact support.";176 default:177 return "Sync state is unavailable.";178 }179}180181export function SettingsView(props: SettingsProps) {182 const manifest = chrome.runtime.getManifest();183 const version = manifest.version;184 const initial = props.initialSection;185 const [activeCard, setActiveCard] = createSignal<CardId>(186 props.upgradeHighlight ? "pro" : initialSectionToCard(initial) ?? "pro"187 );188189 createEffect(() => { props.onCardChange?.(activeCard()); });190191 const [justActivated, setJustActivated] = createSignal(false);192 const [isFanning, setIsFanning] = createSignal(false);193194 function triggerCelebration() {195 if (isFanning()) return;196 setJustActivated(false);197 setIsFanning(true);198 setActiveCard("pro");199 const fanOrder: CardId[] = ["protection", "vault", "sync", "appearance", "shortcuts", "feedback"];200 fanOrder.forEach((id, i) => {201 window.setTimeout(() => setActiveCard(id), (i + 1) * PRO_CELEBRATION_CARD_STEP_MS);202 });203 window.setTimeout(() => {204 setActiveCard("pro");205 setIsFanning(false);206 setJustActivated(true);207 window.setTimeout(() => setJustActivated(false), PRO_CELEBRATION_THANK_YOU_MS);208 }, (fanOrder.length + 1) * PRO_CELEBRATION_CARD_STEP_MS);209 }210211 function handleProCelebrationClick(e: MouseEvent) {212 e.stopPropagation();213 triggerCelebration();214 }215216 const [checkingBreaches, setCheckingBreaches] = createSignal(false);217 const [breachResult, setBreachResult] = createSignal<string | null>(null);218 const [licenseInfo, setLicenseInfo] = createSignal<LicenseInfo | null>(null);219 const [licenseKeyInput, setLicenseKeyInput] = createSignal("");220 const [licenseError, setLicenseError] = createSignal<string | null>(null);221 const [, setLicenseSuccess] = createSignal<string | null>(null);222 const [activatingLicense, setActivatingLicense] = createSignal(false);223224 const isPro = () => props.isPro ?? false;225 const cards = createMemo<StackCardSpec[]>(() => [226 {227 id: "pro",228 num: "I · FOREVER",229 title: "Peach Pro",230 lede: isPro() ? "active · paid once, yours forever" : "$39 once · yours forever",231 bgVar: "--stack-pro-bg",232 fgVar: "--stack-pro-fg",233 icon: "spark",234 onIconClick: isPro() ? triggerCelebration : undefined,235 },236 {237 id: "protection",238 num: "II · PROTECT",239 title: "Protection",240 lede: `${props.breachMonitoring ? "breaches watched" : "breach watch off"} · ${props.lockOnClose ? "locks on close" : "manual lock"}`,241 bgVar: "--stack-protect-bg",242 fgVar: "--stack-protect-fg",243 icon: "shield",244 },245 {246 id: "vault",247 num: "III · KEEP",248 title: "Vault",249 lede: `${props.entriesCount} ${props.entriesCount === 1 ? "entry" : "entries"} · backups, history, cleanup`,250 bgVar: "--stack-vault-bg",251 fgVar: "--stack-vault-fg",252 icon: "lock",253 },254 {255 id: "sync",256 num: "IV · ECHO",257 title: "Sync",258 lede: isPro() ? "your devices, one vault" : "Pro · encrypted device sync",259 bgVar: "--stack-sync-bg",260 fgVar: "--stack-sync-fg",261 icon: "sync",262 },263 {264 id: "appearance",265 num: "V · DRESS",266 title: "Appearance",267 lede: `${props.theme} · ${props.accentColor} · ${props.textSize}`,268 bgVar: "--stack-appear-bg",269 fgVar: "--stack-appear-fg",270 icon: "sun",271 },272 {273 id: "shortcuts",274 num: "VI · KEYS",275 title: "Shortcuts",276 lede: `${SHORTCUT_DESCRIPTORS.length} bindings · what your fingers know`,277 bgVar: "--stack-shortcut-bg",278 fgVar: "--stack-shortcut-fg",279 icon: "keys",280 },281 {282 id: "feedback",283 num: "VII · ECHO",284 title: "Feedback",285 lede: `version ${version} · tell us what would make it better`,286 bgVar: "--stack-feedback-bg",287 fgVar: "--stack-feedback-fg",288 icon: "bubble",289 },290 ]);291 const [passkeyState, setPasskeyState] = createSignal<PasskeyProviderState | null>(null);292 const [passkeySaving, setPasskeySaving] = createSignal(false);293294 295 296 interface PendingVouched {297 deviceId: string;298 deviceName: string;299 longTermPublicKeyHex: string;300 receivedAt: number;301 vouchedByDeviceId: string;302 }303 const [pendingVouched, setPendingVouched] = createSignal<PendingVouched[]>([]);304 const [vouchedBusy, setVouchedBusy] = createSignal<string | null>(null);305 306 const [pairedDevices, setPairedDevices] = createSignal<PairedDevice[]>([]);307 const [trustDataCorrupted, setTrustDataCorrupted] = createSignal(false);308309 const [confirmUnpair, setConfirmUnpair] = createSignal<string | null>(null);310 const [unpairError, setUnpairError] = createSignal<string | null>(null);311 const [renameDeviceId, setRenameDeviceId] = createSignal<string | null>(null);312 const [renameDraft, setRenameDraft] = createSignal("");313 const [syncStatus, setSyncStatus] = createSignal<"idle" | "syncing" | "error">("idle");314 const [syncFlash, setSyncFlash] = createSignal<string | null>(null);315 316 317 const [syncSummary, setSyncSummary] = createSignal<SyncOutcomeSummary>({});318 319 320 const [generation, setGeneration] = createSignal<SyncGeneration | null>(null);321 const [resetOpen, setResetOpen] = createSignal(false);322 const [resetPassword, setResetPassword] = createSignal("");323 const [resetError, setResetError] = createSignal<string | null>(null);324 const [resetting, setResetting] = createSignal(false);325 const syncLight = () =>326 syncStatus() === "syncing"327 ? "busy"328 : syncIndicator(syncSummary(), pairedDevices().length);329 const syncLightLabel = () => syncIndicatorLabel(syncLight(), syncSummary());330 const lastSyncLine = () => {331 const s = syncSummary();332 if (!s.outcome) return "No sync attempted yet";333 return `${syncRelativeTime(s.finishedAt)} · ${describeSyncOutcome(s)}`;334 };335 async function loadSyncState() {336 const r = (await sendMessage({ type: "GET_SYNC_STATE" })) as {337 ok: boolean;338 data?: SyncOutcomeSummary;339 };340 if (r.ok && r.data) {341 setSyncSummary(r.data);342 setSyncStatus(r.data.status ?? "idle");343 }344 }345346 async function loadGeneration() {347 const r = (await sendMessage({ type: "GET_SYNC_GENERATION" })) as {348 ok: boolean;349 data?: SyncGeneration | null;350 };351 if (r.ok) setGeneration(r.data ?? null);352 }353354 async function runSecureReset() {355 setResetting(true);356 const r = (await sendMessage({357 type: "SECURE_RESET",358 payload: { password: resetPassword() },359 })) as { ok: boolean; data?: SyncGeneration | null; error?: string };360 setResetting(false);361 setResetPassword("");362 if (!r.ok) {363 setResetError(r.error ?? "The reset did not complete — nothing was lost, try again");364 await loadGeneration();365 return;366 }367 setResetError(null);368 setResetOpen(false);369 if (r.data) setGeneration(r.data);370 }371372 const STALE_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000;373 function syncRelativeTime(when: number | undefined): string {374 if (!when) return "Not synced yet";375 const elapsed = Date.now() - when;376 if (elapsed < 60_000) return "Just now";377 const minutes = Math.floor(elapsed / 60_000);378 if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"} ago`;379 const hours = Math.floor(minutes / 60);380 if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;381 const days = Math.floor(hours / 24);382 return days < 7 ? `${days} day${days === 1 ? "" : "s"} ago` : "Not seen recently";383 }384 function isSyncStale(d: PairedDevice): boolean {385 return d.lastSyncedAt !== undefined && Date.now() - d.lastSyncedAt > STALE_THRESHOLD_MS;386 }387 function deviceTypeLabel(t: PairedDevice["deviceType"]): string {388 switch (t) {389 case "android": return "Android";390 case "ios": return "iPhone";391 case "extension": return "Browser";392 default: return "Device";393 }394 }395396 async function loadPairedDevices() {397 if (!isPro()) return;398 const r = (await sendMessage({ type: "LIST_PAIRED_DEVICES" })) as {399 ok: boolean;400 error?: string;401 data?: { devices: PairedDevice[] };402 };403 if (r.ok) {404 setTrustDataCorrupted(false);405 setPairedDevices(r.data?.devices ?? []);406 } else if (r.error === "trust_data_corrupted") {407 setPairedDevices([]);408 setTrustDataCorrupted(true);409 }410 }411 async function unpairDevice(deviceId: string) {412 const result = (await sendMessage({ type: "UNPAIR_DEVICE", payload: { deviceId } })) as {413 ok: boolean;414 error?: string;415 };416 if (!result.ok) {417 setUnpairError(result.error ?? "Could not remove this device");418 return;419 }420 setUnpairError(null);421 setConfirmUnpair(null);422 await loadPairedDevices();423 }424 async function renameDevice(deviceId: string) {425 const name = renameDraft().trim();426 if (!name) return;427 const result = (await sendMessage({428 type: "RENAME_PAIRED_DEVICE",429 payload: { deviceId, name },430 })) as { ok: boolean };431 if (!result.ok) return;432 setRenameDeviceId(null);433 await loadPairedDevices();434 }435 async function syncNow() {436 setSyncStatus("syncing");437 setSyncFlash(null);438 const res = (await sendMessage({439 type: "TRIGGER_SYNC",440 payload: { reason: "manual", force: true },441 })) as { ok: boolean; error?: string };442 if (!res.ok) {443 setSyncStatus("error");444 setSyncSummary({ status: "error", outcome: "run_failed", detail: res.error });445 return;446 }447 448 449 450 let s: SyncOutcomeSummary = { status: "syncing" };451 for (let i = 0; i < 40 && s.status === "syncing"; i++) {452 await new Promise((r) => setTimeout(r, 250));453 const state = (await sendMessage({ type: "GET_SYNC_STATE" })) as {454 ok: boolean;455 data?: SyncOutcomeSummary;456 };457 s = state.ok ? state.data ?? { status: "idle" } : { status: "error", outcome: "run_failed" };458 }459 setSyncSummary(s);460 setSyncStatus(s.status ?? "idle");461 462 463 setSyncFlash(describeSyncOutcome(s));464 setTimeout(() => setSyncFlash(null), 4000);465 await loadPairedDevices();466 }467 468 const [rotateConfirmOpen, setRotateConfirmOpen] = createSignal(false);469 const [rotating, setRotating] = createSignal(false);470 const [rotateError, setRotateError] = createSignal<string | null>(null);471 const [showSyncConnection, setShowSyncConnection] = createSignal(false);472 const [showSyncRecovery, setShowSyncRecovery] = createSignal(false);473474 async function loadPendingVouched() {475 const r = (await sendMessage({ type: "GET_PENDING_VOUCHED_DEVICES" })) as {476 ok: boolean;477 data?: PendingVouched[];478 };479 if (r.ok) setPendingVouched(r.data ?? []);480 }481 async function confirmVouched(deviceId: string) {482 setVouchedBusy(deviceId);483 try {484 await sendMessage({ type: "CONFIRM_VOUCHED_DEVICE", payload: { deviceId } });485 await loadPendingVouched();486 } finally {487 setVouchedBusy(null);488 }489 }490 async function rejectVouched(deviceId: string) {491 setVouchedBusy(deviceId);492 try {493 await sendMessage({ type: "REJECT_VOUCHED_DEVICE", payload: { deviceId } });494 await loadPendingVouched();495 } finally {496 setVouchedBusy(null);497 }498 }499 async function doRotateSyncIdentity() {500 setRotateError(null);501 setRotating(true);502 try {503 const r = (await sendMessage({ type: "ROTATE_SYNC_IDENTITY" })) as {504 ok: boolean;505 error?: string;506 };507 if (!r.ok) {508 setRotateError(r.error ?? "Rotation failed.");509 return;510 }511 setRotateConfirmOpen(false);512 513 514 setShowNewRecoveryWords(true);515 } finally {516 setRotating(false);517 }518 }519520 const [dedupCount, setDedupCount] = createSignal<number | null>(null);521 const [dedupRunning, setDedupRunning] = createSignal(false);522 const [dedupDone, setDedupDone] = createSignal<number | null>(null);523524 const [declinedCardCount, setDeclinedCardCount] = createSignal(0);525526 527 528 529 const [historySnapshots, setHistorySnapshots] = createSignal<VaultSnapshot[]>([]);530 const [historyLoading, setHistoryLoading] = createSignal(false);531 const [historyRestoring, setHistoryRestoring] = createSignal<string | null>(null);532 const [historyShowAll, setHistoryShowAll] = createSignal(false);533534 async function loadVaultHistory() {535 setHistoryLoading(true);536 try {537 const res = await sendMessage({ type: "GET_VAULT_HISTORY" });538 const list = (res as { ok: boolean; data?: VaultSnapshot[] }).data ?? [];539 setHistorySnapshots(list);540 } finally {541 setHistoryLoading(false);542 }543 }544545 async function handleRestoreVaultSnapshot(id: string) {546 if (historyRestoring()) return;547 setHistoryRestoring(id);548 const res = await sendMessage({549 type: "RESTORE_VAULT_SNAPSHOT",550 payload: { snapshotId: id },551 });552 setHistoryRestoring(null);553 if ((res as { ok: boolean }).ok) {554 555 556 props.onBack();557 }558 }559560 function formatSnapshotTime(savedAt: number): string {561 const diff = Date.now() - savedAt;562 if (diff < 60_000) return "Just now";563 if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;564 const d = new Date(savedAt);565 const isToday = new Date().toDateString() === d.toDateString();566 const isYest =567 new Date(Date.now() - 86_400_000).toDateString() === d.toDateString();568 const t = d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });569 if (isToday) return `Today ${t}`;570 if (isYest) return `Yesterday ${t}`;571 return `${d.toLocaleDateString([], { month: "short", day: "numeric" })} ${t}`;572 }573574 function reasonLabel(reason: VaultSnapshot["reason"]): string {575 return (576 {577 local_save: "saved",578 sync_received: "synced",579 import: "imported",580 restore: "restored",581 } as const582 )[reason] ?? reason;583 }584585 const [showVaultAdvanced, setShowVaultAdvanced] = createSignal(false);586 const [showWhatsNew, setShowWhatsNew] = createSignal(false);587588 const [showChangePw, setShowChangePw] = createSignal(false);589 const [changePwCurrent, setChangePwCurrent] = createSignal("");590 const [changePwNew, setChangePwNew] = createSignal("");591 const [changePwConfirm, setChangePwConfirm] = createSignal("");592 const [changePwSaving, setChangePwSaving] = createSignal(false);593 const [changePwError, setChangePwError] = createSignal<string | null>(null);594 const [changePwSuccess] = createSignal(false);595 const [showNewRecoveryWords, setShowNewRecoveryWords] = createSignal(false);596597 const [pinEnabled, setPinEnabled] = createSignal(false);598 const [pinFlow, setPinFlow] = createSignal<"setup" | "disable" | null>(null);599 const [pinCurrentPassword, setPinCurrentPassword] = createSignal("");600 const [pinValue, setPinValue] = createSignal("");601 const [pinConfirm, setPinConfirm] = createSignal("");602 const [pinSaving, setPinSaving] = createSignal(false);603 const [pinError, setPinError] = createSignal<string | null>(null);604605 606 const [showViewWords, setShowViewWords] = createSignal(false);607 const [viewWordsPassword, setViewWordsPassword] = createSignal("");608 const [viewWordsError, setViewWordsError] = createSignal<string | null>(null);609 const [viewedWords, setViewedWords] = createSignal<string[] | null>(null);610 const [viewWordsBusy, setViewWordsBusy] = createSignal(false);611612 async function handleViewRecoveryWords() {613 setViewWordsError(null);614 if (!viewWordsPassword()) {615 setViewWordsError("Enter your master password.");616 return;617 }618 setViewWordsBusy(true);619 try {620 const r = (await sendMessage({621 type: "GET_RECOVERY_WORDS",622 payload: { password: viewWordsPassword() },623 })) as { ok: boolean; data?: string[]; error?: string };624 if (r.ok) {625 setViewedWords(r.data ?? []);626 setViewWordsPassword("");627 return;628 }629 switch (r.error) {630 case "wrong_password":631 setViewWordsError("Incorrect password");632 break;633 case "password_required":634 setViewWordsError("Enter your master password.");635 break;636 case "recovery_not_setup":637 setViewWordsError("Recovery words aren't set up for this vault yet.");638 break;639 default:640 setViewWordsError("Couldn't retrieve recovery words.");641 }642 } finally {643 setViewWordsBusy(false);644 }645 }646 function closeViewWords() {647 setShowViewWords(false);648 setViewWordsPassword("");649 setViewWordsError(null);650 setViewedWords(null);651 }652653 const [duressEnabled, setDuressEnabled] = createSignal(false);654 const [showDuressSetup, setShowDuressSetup] = createSignal(false);655 const [duressPassword, setDuressPassword] = createSignal("");656 const [duressConfirm, setDuressConfirm] = createSignal("");657 const [duressMainPassword, setDuressMainPassword] = createSignal("");658 const [duressSaving, setDuressSaving] = createSignal(false);659 const [duressError, setDuressError] = createSignal<string | null>(null);660 const [duressSuccess, setDuressSuccess] = createSignal<string | null>(null);661662 const [canaryEntries, setCanaryEntries] = createSignal<Array<{id: string; title: string; canary: Canary}>>([]);663664 const [canaryTestingId, setCanaryTestingId] = createSignal<string | null>(null);665 const [canaryTestResult, setCanaryTestResult] = createSignal<{id: string; ok: boolean} | null>(null);666667 const [fbCategory, setFbCategory] = createSignal("Bug report");668 const [fbMessage, setFbMessage] = createSignal("");669 type FeedbackState = 'idle' | 'sending' | 'sent' | 'error';670 const [feedbackState, setFeedbackState] = createSignal<FeedbackState>('idle');671672 createEffect(() => {673 if (props.upgradeHighlight) {674 setActiveCard("pro");675 }676 });677678 onMount(async () => {679 const [licenseRes, passkeyRes, duressRes, settingsRes, keyRes] = await Promise.all([680 sendMessage({ type: "GET_LICENSE" }),681 sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }),682 sendMessage({ type: "IS_DURESS_ENABLED" }),683 sendMessage({ type: "GET_SETTINGS" }),684 sendMessage({ type: "GET_LICENSE_KEY" }),685 ]);686687 688 689 void loadVaultHistory();690691 692 693 void loadPendingVouched();694 void loadPairedDevices();695 void loadSyncState();696 void loadGeneration();697 const onBroadcast = (msg: unknown) => {698 const m = msg as { type?: string; payload?: unknown } | null;699 if (m?.type === "VOUCHED_DEVICE_PENDING") {700 void loadPendingVouched();701 }702 if (m?.type === "SYNC_STATE_CHANGED") {703 const payload = (m.payload ?? {}) as SyncOutcomeSummary;704 setSyncSummary(payload);705 setSyncStatus(payload.status ?? "idle");706 }707 };708 chrome.runtime?.onMessage?.addListener(onBroadcast);709 onCleanup(() => {710 try { chrome.runtime?.onMessage?.removeListener(onBroadcast); } catch { }711 });712713 if ((licenseRes as { ok: boolean }).ok) {714 setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null);715 }716717 if ((passkeyRes as { ok: boolean }).ok) {718 setPasskeyState(719 (passkeyRes as { ok: boolean; data?: PasskeyProviderState }).data ?? null720 );721 }722723 if ((duressRes as { ok: boolean }).ok) {724 setDuressEnabled((duressRes as { ok: boolean; data?: boolean }).data ?? false);725 }726727 if ((settingsRes as { ok: boolean }).ok) {728 const sData = (settingsRes as { ok: boolean; data?: { declinedCardFingerprints?: string[]; pinEnabled?: boolean } }).data;729 setDeclinedCardCount(sData?.declinedCardFingerprints?.length ?? 0);730 setPinEnabled(sData?.pinEnabled === true);731 }732733 if ((keyRes as { ok: boolean }).ok) {734 setLicenseKey((keyRes as { ok: boolean; data?: string }).data ?? null);735 }736737 const entriesRes = await sendMessage({ type: "GET_ENTRIES" });738 if ((entriesRes as { ok: boolean }).ok) {739 const entries = (entriesRes as { ok: boolean; data?: Array<{id: string; title: string; canary?: Canary}> }).data ?? [];740 setCanaryEntries(741 entries742 .filter((e) => e.canary?.webhookUrl)743 .map((e) => ({ id: e.id, title: e.title, canary: e.canary! }))744 );745 }746747 });748749 function resetPinFlow() {750 setPinFlow(null);751 setPinCurrentPassword("");752 setPinValue("");753 setPinConfirm("");754 setPinError(null);755 }756757 async function handleSetupPin() {758 setPinError(null);759 if (!pinCurrentPassword()) { setPinError("enter your current password"); return; }760 if (!/^\d{4,12}$/.test(pinValue())) { setPinError("PIN must be 4–12 digits"); return; }761 if (pinValue() !== pinConfirm()) { setPinError("PINs do not match"); return; }762 setPinSaving(true);763 const res = await sendMessage({764 type: "SETUP_PIN",765 payload: { currentPassword: pinCurrentPassword(), pin: pinValue() },766 }) as { ok: boolean; error?: string };767 if (res.ok) {768 setPinEnabled(true);769 resetPinFlow();770 } else {771 setPinError(res.error === "incorrect_current_password" ? "incorrect current password" : "couldn't set up PIN");772 }773 setPinSaving(false);774 }775776 async function handleDisablePin() {777 setPinError(null);778 if (!pinCurrentPassword()) { setPinError("enter your current password"); return; }779 setPinSaving(true);780 const res = await sendMessage({781 type: "DISABLE_PIN",782 payload: { currentPassword: pinCurrentPassword() },783 }) as { ok: boolean; error?: string };784 if (res.ok) {785 setPinEnabled(false);786 resetPinFlow();787 } else {788 setPinError(res.error === "incorrect_current_password" ? "incorrect current password" : "couldn't remove PIN");789 }790 setPinSaving(false);791 }792793 async function handleChangePassword() {794 setChangePwError(null);795 if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; }796 if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; }797 if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; }798 if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; }799 setChangePwSaving(true);800 const res = await sendMessage({801 type: "CHANGE_PASSWORD",802 payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() },803 }) as { ok: boolean; error?: string };804 if (res.ok) {805 setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");806 setShowChangePw(false);807 808 809 810 await sendMessage({ type: "SET_RECOVERY_CONFIRM_PENDING" });811 setShowNewRecoveryWords(true);812 } else {813 setChangePwError(res.error ?? "failed to change password");814 }815 setChangePwSaving(false);816 }817818 const [copied, setCopied] = createSignal(false);819820 async function copyLicenseKey(key: string) {821 await navigator.clipboard.writeText(key);822 setCopied(true);823 setTimeout(() => setCopied(false), 2000);824 }825826 async function handleActivateLicense() {827 const key = licenseKeyInput().trim();828 if (!key) return;829 setActivatingLicense(true);830 setLicenseError(null);831 setLicenseSuccess(null);832 const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } });833 setActivatingLicense(false);834 if ((res as { ok: boolean }).ok) {835 const data = (res as { ok: boolean; data?: LicenseInfo }).data;836 if (data?.valid) {837 setLicenseInfo(data);838 setLicenseKey(key);839 setLicenseSuccess("License activated!");840 setLicenseKeyInput("");841 } else {842 setLicenseError("Invalid license key");843 }844 } else {845 setLicenseError("Activation failed");846 }847 }848849 async function handleTogglePasskeys() {850 const current = passkeyState();851 if (!current || !current.supported) return;852 setPasskeySaving(true);853 const res = await sendMessage({854 type: "SET_PASSKEY_PROVIDER_ENABLED",855 payload: { enabled: !current.enabled },856 });857 setPasskeySaving(false);858 if ((res as { ok: boolean }).ok) {859 setPasskeyState(860 (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null861 );862 }863 }864865 async function handleCheckAllBreaches() {866 setCheckingBreaches(true);867 setBreachResult(null);868 const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });869 setCheckingBreaches(false);870 if ((res as { ok: boolean }).ok) {871 const data = (res as { ok: boolean; data?: Record<string, number> }).data ?? {};872 const breached = Object.values(data).filter((count) => count > 0).length;873 setBreachResult(874 breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean"875 );876 props.onBreachCheckDone();877 }878 }879880881 async function handleSetupDuress() {882 const mainPw = duressMainPassword().trim();883 const duressPw = duressPassword().trim();884 const confirmPw = duressConfirm().trim();885886 if (!mainPw) { setDuressError("enter your main password to confirm"); return; }887 if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; }888 if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; }889 if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; }890891 setDuressSaving(true);892 setDuressError(null);893894 const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };895 if (!verifyRes.ok || !verifyRes.data) {896 setDuressError("incorrect main password");897 setDuressSaving(false);898 return;899 }900901 const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string };902 setDuressSaving(false);903 if (res.ok) {904 setDuressEnabled(true);905 setShowDuressSetup(false);906 setDuressPassword("");907 setDuressConfirm("");908 setDuressMainPassword("");909 setDuressSuccess("Duress password set. If entered, a decoy vault will be shown.");910 setTimeout(() => setDuressSuccess(null), 5000);911 } else {912 setDuressError(913 res.error === "session_expired"914 ? "Vault session expired — lock and unlock Peach, then try again"915 : res.error ?? "failed to set duress password"916 );917 }918 }919920 async function handleClearDeclinedCards() {921 await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } });922 setDeclinedCardCount(0);923 }924925 async function handleCountDuplicates() {926 setDedupRunning(true);927 setDedupDone(null);928 const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number };929 setDedupRunning(false);930 if (res.ok) setDedupCount(res.data ?? 0);931 }932933 async function handleDeduplicate() {934 setDedupRunning(true);935 const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number };936 setDedupRunning(false);937 if (res.ok) {938 setDedupDone(res.data ?? 0);939 setDedupCount(0);940 }941 }942943 async function handleDisableDuress() {944 const mainPw = duressMainPassword().trim();945 if (!mainPw) { setDuressError("enter your main password to confirm"); return; }946947 setDuressSaving(true);948 setDuressError(null);949950 const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };951 if (!verifyRes.ok || !verifyRes.data) {952 setDuressError("incorrect main password");953 setDuressSaving(false);954 return;955 }956957 const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string };958 setDuressSaving(false);959 if (res.ok) {960 setDuressEnabled(false);961 setShowDuressSetup(false);962 setDuressMainPassword("");963 setDuressSuccess("Duress password disabled.");964 setTimeout(() => setDuressSuccess(null), 4000);965 } else {966 setDuressError(res.error ?? "failed to disable duress password");967 }968 }969970 async function handleSubmitFeedback() {971 const message = sanitizeFeedbackMessage(fbMessage());972 if (!message) return;973974 setFeedbackState('sending');975976 try {977 const category = FEEDBACK_CATEGORIES.includes(fbCategory())978 ? fbCategory()979 : FEEDBACK_CATEGORIES[0];980 const res = await fetch('https://api.web3forms.com/submit', {981 method: 'POST',982 headers: { 'Content-Type': 'application/json' },983 body: JSON.stringify({984 access_key: WEB3FORMS_KEY,985 subject: `Peach Feedback - ${category}`,986 message,987 from_name: 'Peach User',988 989 botcheck: '',990 }),991 });992993 if (!res.ok) throw new Error(`HTTP ${res.status}`);994 const data = await res.json() as { success: boolean; message?: string };995 if (!data.success) throw new Error(data.message ?? 'unknown error');996997 setFeedbackState('sent');998 setFbMessage('');999 setFbCategory(FEEDBACK_CATEGORIES[0]);1000 } catch (err) {1001 console.error('[feedback]', err);1002 setFeedbackState('error');1003 setTimeout(() => setFeedbackState('idle'), 4000);1004 }1005 }10061007 function renderSecurity() {1008 return (1009 <div class="peach-settings-body">1010 <div class="peach-settings-group">1011 <p class="peach-settings-sub-label">LOCKING</p>1012 <div class="peach-settings-row">1013 <span>Lock on close</span>1014 <button1015 class={`peach-toggle ${props.lockOnClose ? "is-on" : ""}`}1016 onClick={() => void props.onLockOnCloseChange(!props.lockOnClose)}1017 aria-label="Toggle lock on close"1018 >1019 <span class="peach-toggle-knob"></span>1020 </button>1021 </div>1022 <div class="peach-settings-row">1023 <span>Auto-lock after</span>1024 <select1025 class="peach-settings-select"1026 value={String(props.autoLockMinutes)}1027 onChange={(e) => void props.onAutoLockMinutesChange(Number(e.currentTarget.value))}1028 >1029 <option value="1">1 minute</option>1030 <option value="5">5 minutes</option>1031 <option value="15">15 minutes</option>1032 <option value="30">30 minutes</option>1033 <option value="0">Never</option>1034 </select>1035 </div>10361037 <p class="peach-settings-sub-label">QUICK UNLOCK</p>1038 <div class="peach-settings-row peach-settings-row--meta">1039 <span>PIN unlock</span>1040 <button1041 class="peach-btn peach-btn--subtle peach-btn--sm"1042 onClick={() => { setPinFlow(pinEnabled() ? "disable" : "setup"); setPinError(null); }}1043 >1044 {pinEnabled() ? "remove" : "set up"}1045 </button>1046 </div>1047 <Show when={pinFlow() === "setup"}>1048 <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1049 <input1050 class="peach-settings-input"1051 type="password"1052 placeholder="current password"1053 value={pinCurrentPassword()}1054 onInput={(e) => setPinCurrentPassword((e.target as HTMLInputElement).value)}1055 autocomplete="current-password"1056 />1057 <input1058 class="peach-settings-input"1059 type="password"1060 inputmode="numeric"1061 maxlength="12"1062 placeholder="new 4–12 digit PIN"1063 value={pinValue()}1064 onInput={(e) => setPinValue((e.target as HTMLInputElement).value.replace(/\D/g, "").slice(0, 12))}1065 autocomplete="off"1066 />1067 <input1068 class="peach-settings-input"1069 type="password"1070 inputmode="numeric"1071 maxlength="12"1072 placeholder="confirm PIN"1073 value={pinConfirm()}1074 onInput={(e) => setPinConfirm((e.target as HTMLInputElement).value.replace(/\D/g, "").slice(0, 12))}1075 autocomplete="off"1076 onKeyDown={(e) => { if (e.key === "Enter") void handleSetupPin(); }}1077 />1078 <Show when={pinError()}>1079 <p class="peach-hint peach-hint-error" style="margin:0">{pinError()}</p>1080 </Show>1081 <button1082 class="peach-btn peach-btn--accent peach-btn--sm"1083 style="align-self:flex-start"1084 onClick={() => void handleSetupPin()}1085 disabled={pinSaving() || !pinCurrentPassword() || !/^\d{4,12}$/.test(pinValue()) || !/^\d{4,12}$/.test(pinConfirm())}1086 >1087 {pinSaving() ? "saving…" : "save PIN"}1088 </button>1089 </div>1090 </Show>1091 <Show when={pinFlow() === "disable"}>1092 <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1093 <input1094 class="peach-settings-input"1095 type="password"1096 placeholder="current password"1097 value={pinCurrentPassword()}1098 onInput={(e) => setPinCurrentPassword((e.target as HTMLInputElement).value)}1099 autocomplete="current-password"1100 onKeyDown={(e) => { if (e.key === "Enter") void handleDisablePin(); }}1101 />1102 <Show when={pinError()}>1103 <p class="peach-hint peach-hint-error" style="margin:0">{pinError()}</p>1104 </Show>1105 <button1106 class="peach-btn peach-btn--subtle peach-btn--sm"1107 style="align-self:flex-start"1108 onClick={() => void handleDisablePin()}1109 disabled={pinSaving() || !pinCurrentPassword()}1110 >1111 {pinSaving() ? "removing…" : "remove PIN"}1112 </button>1113 </div>1114 </Show>11151116 <p class="peach-settings-sub-label">BREACH PROTECTION</p>1117 <div class="peach-settings-row">1118 <span>Breach monitoring</span>1119 <button1120 class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}1121 onClick={() => void props.onBreachMonitoringChange(!props.breachMonitoring)}1122 aria-label="Toggle breach monitoring"1123 >1124 <span class="peach-toggle-knob"></span>1125 </button>1126 </div>1127 <div class="peach-settings-row peach-settings-row--meta">1128 <span>Check all passwords</span>1129 <button1130 class="peach-btn peach-btn--subtle peach-btn--sm"1131 onClick={() => void handleCheckAllBreaches()}1132 disabled={checkingBreaches()}1133 >1134 {checkingBreaches() ? "checking..." : "run"}1135 </button>1136 </div>1137 <Show when={breachResult()}>1138 <div class="peach-settings-row peach-settings-row--meta">1139 <span>Last check</span>1140 <span class="peach-settings-value">{breachResult()}</span>1141 </div>1142 </Show>11431144 <p class="peach-settings-sub-label">MASTER PASSWORD</p>1145 <div class="peach-settings-row peach-settings-row--meta">1146 <span>Master password</span>1147 <button1148 class="peach-btn peach-btn--subtle peach-btn--sm"1149 onClick={() => { setShowChangePw((v) => !v); setChangePwError(null); }}1150 >1151 {showChangePw() ? "cancel" : "change"}1152 </button>1153 </div>1154 <Show when={showChangePw()}>1155 <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1156 <input1157 class="peach-settings-input"1158 type="password"1159 placeholder="current password"1160 value={changePwCurrent()}1161 onInput={(e) => setChangePwCurrent((e.target as HTMLInputElement).value)}1162 autocomplete="current-password"1163 />1164 <input1165 class="peach-settings-input"1166 type="password"1167 placeholder="new password (min 12 chars)"1168 value={changePwNew()}1169 onInput={(e) => setChangePwNew((e.target as HTMLInputElement).value)}1170 autocomplete="new-password"1171 />1172 <input1173 class="peach-settings-input"1174 type="password"1175 placeholder="confirm new password"1176 value={changePwConfirm()}1177 onInput={(e) => setChangePwConfirm((e.target as HTMLInputElement).value)}1178 autocomplete="new-password"1179 onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }}1180 />1181 <Show when={changePwError()}>1182 <p class="peach-hint peach-hint-error" style="margin:0">{changePwError()}</p>1183 </Show>1184 <Show when={changePwSuccess()}>1185 <p class="peach-hint" style="margin:0;color:var(--peach-accent)">password changed ✓</p>1186 </Show>1187 <button1188 class="peach-btn peach-btn--accent peach-btn--sm"1189 style="align-self:flex-start"1190 onClick={() => void handleChangePassword()}1191 disabled={changePwSaving() || !changePwCurrent() || !changePwNew() || !changePwConfirm()}1192 >1193 {changePwSaving() ? "saving…" : "save new password"}1194 </button>1195 </div>1196 </Show>11971198 {}1199 <div class="peach-settings-row peach-settings-row--meta">1200 <span>Recovery words</span>1201 <button1202 class="peach-btn peach-btn--subtle peach-btn--sm"1203 onClick={() => {1204 if (showViewWords()) { closeViewWords(); } else { setShowViewWords(true); }1205 }}1206 >1207 {showViewWords() ? "cancel" : "view"}1208 </button>1209 </div>1210 <Show when={showViewWords()}>1211 <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1212 <Show1213 when={viewedWords()}1214 fallback={1215 <>1216 <p class="peach-hint" style="margin:0;color:var(--peach-t2)">1217 Enter your master password to reveal your recovery words.1218 </p>1219 <input1220 class="peach-settings-input"1221 type="password"1222 placeholder="master password"1223 value={viewWordsPassword()}1224 onInput={(e) => setViewWordsPassword((e.target as HTMLInputElement).value)}1225 autocomplete="current-password"1226 onKeyDown={(e) => { if (e.key === "Enter") void handleViewRecoveryWords(); }}1227 />1228 <Show when={viewWordsError()}>1229 <p class="peach-hint peach-hint-error" style="margin:0">{viewWordsError()}</p>1230 </Show>1231 <button1232 class="peach-btn peach-btn--accent peach-btn--sm"1233 style="align-self:flex-start"1234 onClick={() => void handleViewRecoveryWords()}1235 disabled={viewWordsBusy() || !viewWordsPassword()}1236 >1237 {viewWordsBusy() ? "checking…" : "reveal recovery words"}1238 </button>1239 </>1240 }1241 >1242 <div class="peach-recovery-words-grid">1243 <For each={viewedWords()!}>1244 {(word, i) => (1245 <span class="peach-recovery-word"><b>{i() + 1}</b> {word}</span>1246 )}1247 </For>1248 </div>1249 <button1250 class="peach-btn peach-btn--subtle peach-btn--sm"1251 style="align-self:flex-start"1252 onClick={closeViewWords}1253 >done</button>1254 </Show>1255 </div>1256 </Show>12571258 <p class="peach-settings-sub-label">1259 DURESS PASSWORD1260 <Show when={!isPro()}>1261 <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>1262 </Show>1263 </p>1264 <div class="peach-settings-row peach-settings-row--top">1265 <div class="peach-settings-row-main">1266 <span>1267 Duress password1268 <Show when={!isPro()}>1269 <span class="peach-pro-badge">Pro</span>1270 </Show>1271 </span>1272 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">1273 If entered under coercion, shows a decoy empty vault instead of your real one.1274 </span>1275 </div>1276 <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>1277 {duressEnabled() ? "on" : "off"}1278 </div>1279 </div>12801281 <Show when={duressEnabled() && !showDuressSetup()}>1282 <div class="peach-settings-row peach-settings-row--meta">1283 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1284 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.1285 </span>1286 </div>1287 </Show>12881289 <Show when={duressSuccess()}>1290 <div class="peach-settings-row peach-settings-row--meta">1291 <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>1292 </div>1293 </Show>12941295 <Show when={!showDuressSetup()}>1296 <div class="peach-settings-row peach-settings-row--meta">1297 <button1298 class="peach-btn peach-btn--subtle peach-btn--sm"1299 onClick={() => {1300 1301 1302 1303 1304 1305 if (!isPro() && !duressEnabled()) {1306 props.onProGate?.("duress_vault");1307 setActiveCard("pro");1308 return;1309 }1310 setShowDuressSetup(true);1311 setDuressError(null);1312 setDuressSuccess(null);1313 setDuressPassword("");1314 setDuressConfirm("");1315 setDuressMainPassword("");1316 }}1317 >1318 {duressEnabled() ? "Change / disable" : "Set up"}1319 </button>1320 </div>1321 </Show>13221323 <Show when={showDuressSetup()}>1324 <div class="peach-duress-form">1325 <label class="peach-label">main password (to confirm)</label>1326 <input1327 class="peach-settings-input"1328 type="password"1329 placeholder="your current master password"1330 autocomplete="current-password"1331 value={duressMainPassword()}1332 onInput={(e) => setDuressMainPassword(e.currentTarget.value)}1333 />1334 <Show when={!duressEnabled()}>1335 <label class="peach-label">new duress password</label>1336 <input1337 class="peach-settings-input"1338 type="password"1339 placeholder="duress password (min 8 chars)"1340 autocomplete="new-password"1341 value={duressPassword()}1342 onInput={(e) => setDuressPassword(e.currentTarget.value)}1343 />1344 <label class="peach-label">confirm duress password</label>1345 <input1346 class="peach-settings-input"1347 type="password"1348 placeholder="confirm duress password"1349 autocomplete="new-password"1350 value={duressConfirm()}1351 onInput={(e) => setDuressConfirm(e.currentTarget.value)}1352 />1353 </Show>13541355 <Show when={duressError()}>1356 <p class="peach-hint peach-hint-error">{duressError()}</p>1357 </Show>13581359 <div class="peach-duress-actions">1360 <button1361 class="peach-btn peach-btn--subtle peach-btn--sm"1362 onClick={() => { setShowDuressSetup(false); setDuressError(null); }}1363 >1364 cancel1365 </button>1366 <Show when={duressEnabled()}>1367 <button1368 class="peach-btn peach-btn--ghost peach-btn--sm"1369 style="color:var(--peach-breach)"1370 onClick={() => void handleDisableDuress()}1371 disabled={duressSaving()}1372 >1373 {duressSaving() ? "disabling..." : "disable duress"}1374 </button>1375 </Show>1376 <Show when={!duressEnabled()}>1377 <button1378 class="peach-btn peach-btn--accent peach-btn--sm"1379 onClick={() => void handleSetupDuress()}1380 disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}1381 >1382 {duressSaving() ? "saving..." : "save"}1383 </button>1384 </Show>1385 </div>1386 </div>1387 </Show>13881389 <p class="peach-settings-sub-label">1390 CANARY ENTRIES1391 <Show when={!isPro()}>1392 <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>1393 </Show>1394 </p>1395 <div class="peach-settings-row peach-settings-row--top">1396 <div class="peach-settings-row-main">1397 <span>1398 Canary entries1399 <Show when={!isPro()}>1400 <span class="peach-pro-badge">Pro</span>1401 </Show>1402 </span>1403 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1404 Silently pings a webhook when an entry is accessed. Configure canary on any entry in your vault.1405 </span>1406 </div>1407 <span class="peach-settings-value" style="flex-shrink:0">1408 {canaryEntries().length > 0 ? `${canaryEntries().length} active` : "none"}1409 </span>1410 </div>1411 <Show when={canaryEntries().length > 0}>1412 <For each={canaryEntries()}>1413 {(entry) => (1414 <div class="peach-settings-row peach-settings-row--meta">1415 <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0">{entry.title}</span>1416 <button1417 class="peach-btn peach-btn--subtle peach-btn--sm"1418 onClick={() => void handleTestCanary(entry)}1419 disabled={canaryTestingId() === entry.id}1420 style="flex-shrink:0"1421 >1422 {canaryTestingId() === entry.id1423 ? "sending…"1424 : canaryTestResult()?.id === entry.id1425 ? (canaryTestResult()!.ok ? "sent ✓" : "failed ✗")1426 : "test"}1427 </button>1428 </div>1429 )}1430 </For>1431 </Show>14321433 <p class="peach-settings-sub-label">PASSKEYS</p>1434 <div class="peach-settings-row peach-settings-row--top">1435 <div class="peach-settings-row-main">1436 <span>Intercept passkey requests</span>1437 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">1438 Peach can handle passkey sign-in and creation on supported Chromium browsers.1439 </span>1440 </div>1441 <button1442 class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}1443 onClick={() => void handleTogglePasskeys()}1444 aria-label="Toggle passkey interception"1445 disabled={!passkeyState()?.supported || passkeySaving()}1446 >1447 <span class="peach-toggle-knob"></span>1448 </button>1449 </div>1450 <div class="peach-settings-row peach-settings-row--meta">1451 <span>Status</span>1452 <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>1453 </div>1454 <div class="peach-settings-row peach-settings-row--meta">1455 <span>Browser</span>1456 <span class="peach-settings-value">1457 {passkeyState()?.browserLabel ?? "Unknown"}1458 {passkeyState()?.supported ? " ✓" : " (interception unavailable)"}1459 </span>1460 </div>1461 <div class="peach-info-notice" style="margin-top:8px">1462 Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.1463 </div>1464 <Show when={passkeyState()?.conflict}>1465 <div class="peach-settings-row peach-settings-row--meta">1466 <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>1467 </div>1468 </Show>1469 </div>1470 </div>1471 );1472 }14731474 function renderVault() {1475 return (1476 <div class="peach-settings-body">1477 <div class="peach-settings-group">1478 <p class="peach-settings-sub-label">ENTRIES</p>1479 <div class="peach-settings-row peach-settings-row--meta">1480 <span>Entries</span>1481 <span class="peach-settings-value">{props.entriesCount}</span>1482 </div>1483 <div class="peach-settings-row peach-settings-row--meta">1484 <span>Duplicates</span>1485 <Show when={dedupDone() !== null} fallback={1486 <button1487 class="peach-btn peach-btn--subtle peach-btn--sm"1488 onClick={() => void handleCountDuplicates()}1489 disabled={dedupRunning()}1490 >1491 {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}1492 </button>1493 }>1494 <span class="peach-settings-value" style="color:var(--peach-sync-ok)">1495 {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}1496 </span>1497 </Show>1498 </div>1499 <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>1500 <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">1501 <div class="peach-restore-warning">1502 {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.1503 </div>1504 <div style="display:flex;gap:6px">1505 <button1506 class="peach-btn peach-btn--subtle peach-btn--sm"1507 onClick={() => setDedupCount(null)}1508 >1509 cancel1510 </button>1511 <button1512 class="peach-btn peach-btn--accent peach-btn--sm"1513 onClick={() => void handleDeduplicate()}1514 disabled={dedupRunning()}1515 >1516 {dedupRunning() ? "removing..." : "remove duplicates"}1517 </button>1518 </div>1519 </div>1520 </Show>15211522 <p class="peach-settings-sub-label">MAINTENANCE</p>1523 <div class="peach-settings-row peach-settings-row--meta">1524 <span>Trash auto-delete</span>1525 <select1526 class="peach-settings-select"1527 value={String(props.trashRetentionDays)}1528 onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}1529 >1530 <option value="7">7 days</option>1531 <option value="30">30 days</option>1532 <option value="90">90 days</option>1533 <option value="0">Never</option>1534 </select>1535 </div>1536 <div class="peach-settings-row peach-settings-row--meta">1537 <span>Advanced Vault Health</span>1538 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>1539 open1540 </button>1541 </div>1542 <Show when={declinedCardCount() > 0}>1543 <div class="peach-settings-row peach-settings-row--meta">1544 <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>1545 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>1546 Clear1547 </button>1548 </div>1549 </Show>15501551 <p class="peach-settings-sub-label">DATA</p>1552 <div class="peach-settings-row peach-settings-row--meta">1553 <span>Import passwords</span>1554 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>1555 open1556 </button>1557 </div>1558 <div class="peach-settings-row peach-settings-row--meta">1559 <span>Export backup</span>1560 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onExport}>1561 open1562 </button>1563 </div>1564 <div class="peach-settings-row peach-settings-row--meta">1565 <span>1566 Peach Codex1567 <Show when={!isPro()}>1568 <span class="peach-pro-badge">Pro</span>1569 </Show>1570 </span>1571 <button1572 class="peach-btn peach-btn--subtle peach-btn--sm"1573 onClick={() => props.onOpenCodexGenerate?.()}1574 >1575 open1576 </button>1577 </div>1578 {1579}1580 <div class="peach-settings-row peach-settings-row--meta">1581 <span>Restore from Codex</span>1582 <button1583 class="peach-btn peach-btn--subtle peach-btn--sm"1584 onClick={() => props.onOpenCodexRestore?.()}1585 >1586 open1587 </button>1588 </div>15891590 <Show when={hasReleaseNotes()}>1591 <p class="peach-settings-sub-label">RELEASE NOTES</p>1592 <div class="peach-settings-row peach-settings-row--meta">1593 <span>v{PEACH_VERSION}</span>1594 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => setShowWhatsNew(true)}>1595 view1596 </button>1597 </div>1598 </Show>15991600 <span class="peach-settings-sub-label">VAULT HISTORY</span>1601 <Show when={historyLoading()}>1602 <p class="peach-settings-hint">Loading…</p>1603 </Show>1604 <Show when={!historyLoading() && historySnapshots().length === 0}>1605 <p class="peach-settings-hint">1606 No snapshots yet. Peach saves automatically before every change —1607 saves, syncs, and imports.1608 </p>1609 </Show>1610 <Show when={!historyLoading() && historySnapshots().length > 0}>1611 <p class="peach-settings-hint">1612 Restoring locks your vault. Unlock with the password that1613 encrypted that snapshot to confirm.1614 </p>1615 <div class="peach-history-list">1616 <For each={historySnapshots().slice(0, historyShowAll() ? undefined : 3)}>1617 {(snap) => (1618 <div class="peach-history-row">1619 <div class="peach-history-meta">1620 <span class="peach-history-time">1621 {formatSnapshotTime(snap.savedAt)}1622 </span>1623 <span class="peach-history-sub">1624 {[1625 snap.entryCount !== null1626 ? `up to ${snap.entryCount} ${snap.entryCount === 1 ? "entry" : "entries"}`1627 : null,1628 reasonLabel(snap.reason),1629 ]1630 .filter(Boolean)1631 .join(" · ")}1632 </span>1633 </div>1634 <button1635 class={`peach-btn peach-btn--subtle peach-btn--sm peach-history-btn${1636 historyRestoring() === snap.id ? " is-busy" : ""1637 }`}1638 onClick={() => void handleRestoreVaultSnapshot(snap.id)}1639 disabled={historyRestoring() !== null}1640 >1641 <Show when={historyRestoring() === snap.id} fallback="Restore">1642 …1643 </Show>1644 </button>1645 </div>1646 )}1647 </For>1648 </div>1649 <Show when={historySnapshots().length > 3}>1650 <button1651 class="peach-settings-advanced-toggle"1652 onClick={() => setHistoryShowAll((v) => !v)}1653 aria-expanded={historyShowAll()}1654 >1655 <span class="peach-settings-hint" style="margin:0">1656 {historyShowAll() ? "show less" : `${historySnapshots().length - 3} older`}1657 </span>1658 <span class="peach-settings-advanced-chevron">1659 {historyShowAll() ? "▲" : "▼"}1660 </span>1661 </button>1662 </Show>1663 <p class="peach-history-footer">1664 {historySnapshots().length} of 10 snapshots1665 </p>1666 </Show>16671668 <button1669 class="peach-settings-advanced-toggle"1670 onClick={() => setShowVaultAdvanced((v) => !v)}1671 aria-expanded={showVaultAdvanced()}1672 >1673 <span class="peach-settings-sub-label" style="margin:0">ADVANCED</span>1674 <span class="peach-settings-advanced-chevron">1675 {showVaultAdvanced() ? "▲" : "▼"}1676 </span>1677 </button>16781679 <Show when={showVaultAdvanced()}>1680 <div class="peach-danger-card">1681 <div class="peach-danger-card-head">1682 <span class="peach-danger-card-glyph" aria-hidden="true">⟳</span>1683 <h3 class="peach-danger-card-title">Replace vault from backup</h3>1684 </div>1685 <p class="peach-danger-card-copy">1686 Wipes the current vault and restores from a backup file.1687 Recoverable only if your backup is good.1688 </p>1689 <button1690 class="peach-danger-card-button"1691 onClick={() => props.onOpenFileImport?.()}1692 >1693 Choose backup file1694 </button>1695 </div>16961697 <Show when={props.onOpenPanic}>1698 <div class="peach-danger-card peach-danger-card--hazard">1699 <div class="peach-danger-card-head">1700 <span class="peach-danger-card-glyph" aria-hidden="true">⚠</span>1701 <h3 class="peach-danger-card-title">Destroy vault</h3>1702 </div>1703 <p class="peach-danger-card-copy">1704 Permanently wipes every entry, setting, and license on this1705 device. There is no recovery.1706 </p>1707 <button1708 class="peach-danger-card-button"1709 onClick={() => props.onOpenPanic?.()}1710 >1711 Begin destruction1712 </button>1713 </div>1714 </Show>1715 </Show>1716 </div>1717 </div>1718 );1719 }17201721 function renderSync() {1722 return (1723 <div class="peach-settings-body">1724 <div class="peach-settings-group">1725 <p class="peach-settings-sub-label" style="margin-top:0">SYNC STATUS</p>1726 <Show when={trustDataCorrupted()}>1727 <p class="peach-settings-value" role="alert" style="color:var(--peach-danger,#e5805c);line-height:1.5">1728 Device trust data is damaged — re-pair your devices after updating1729 </p>1730 </Show>1731 <div1732 class="peach-settings-row peach-settings-row--meta"1733 style="align-items:center;gap:8px"1734 aria-label={syncLightLabel()}1735 title={syncLightLabel()}1736 >1737 <span class={`stk-pip stk-pip--sync is-${syncLight()}`} aria-hidden="true" />1738 <span class="peach-settings-value" style="color:var(--peach-t2);line-height:1.5;flex:1;min-width:0;overflow-wrap:anywhere">1739 {syncFlash() ?? lastSyncLine()}1740 </span>1741 </div>1742 <Show when={pairedDevices().length > 0}>1743 <div class="peach-settings-row peach-settings-row--meta">1744 <span class="peach-settings-value" style="color:var(--peach-t2)">1745 {(syncSummary().entriesSent ?? 0) + (syncSummary().entriesReceived ?? 0) > 01746 ? `${syncSummary().entriesSent ?? 0} sent · ${syncSummary().entriesReceived ?? 0} received`1747 : ""}1748 </span>1749 <button1750 class="peach-btn peach-btn--subtle peach-btn--sm"1751 aria-label="Sync this vault now"1752 disabled={syncStatus() === "syncing"}1753 onClick={() => void syncNow()}1754 >{syncStatus() === "syncing" ? "syncing…" : "sync now"}</button>1755 </div>1756 </Show>17571758 {17591760}1761 <Show when={generation()?.state && generation()?.state !== "V2_ACTIVE"}>1762 <p class="peach-settings-sub-label">SECURE SYNC RESET</p>1763 <div class="peach-settings-row peach-settings-row--top">1764 <div class="peach-settings-row-main">1765 <span class="peach-settings-value" style="color:var(--peach-t1)">1766 {secureResetHeadline(generation()?.state)}1767 </span>1768 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1769 {secureResetDetail(generation()?.state)}1770 </span>1771 </div>1772 <Show1773 when={1774 generation()?.state === "LEGACY_ACTIVE" ||1775 generation()?.state === "RESET_REQUIRED" ||1776 generation()?.state === "RESET_CONFIRMED"1777 }1778 >1779 <button1780 class="peach-btn peach-btn--subtle peach-btn--sm"1781 onClick={() => {1782 setResetError(null);1783 setResetPassword("");1784 setResetOpen(true);1785 }}1786 >reset sync</button>1787 </Show>1788 </div>1789 <Show when={resetError()}>1790 <p1791 class="peach-settings-value"1792 role="alert"1793 style="color:var(--peach-danger,#e5805c);font-size:11px;line-height:1.5"1794 >{resetError()}</p>1795 </Show>1796 <Show when={resetOpen()}>1797 <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1798 <p class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1799 {SECURE_RESET_DISCLOSURE}1800 </p>1801 <input1802 class="peach-settings-input"1803 type="password"1804 placeholder="master password"1805 value={resetPassword()}1806 onInput={(e) => setResetPassword((e.target as HTMLInputElement).value)}1807 autocomplete="current-password"1808 />1809 <div style="display:flex;gap:4px">1810 <button1811 class="peach-btn peach-btn--subtle peach-btn--sm"1812 disabled={resetting()}1813 onClick={() => void runSecureReset()}1814 >{resetting() ? "resetting…" : "reset"}</button>1815 <button1816 class="peach-btn peach-btn--subtle peach-btn--sm"1817 disabled={resetting()}1818 onClick={() => setResetOpen(false)}1819 >cancel</button>1820 </div>1821 </div>1822 </Show>1823 </Show>18241825 <p class="peach-settings-sub-label">ADD A DEVICE</p>1826 <button1827 class="peach-sync-pair-option"1828 onClick={() => props.onOpenSyncPairing?.("code")}1829 >1830 <span class="peach-sync-pair-option-icon" aria-hidden="true">1831 <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">1832 <rect x="6" y="2.5" width="8" height="15" rx="1.6" />1833 <path d="M9 15 L11 15" />1834 </svg>1835 </span>1836 <span class="peach-sync-pair-option-main">1837 <span class="peach-sync-pair-option-title">Show a code</span>1838 <span class="peach-sync-pair-option-sub">Scan it with Peach on your phone, then confirm one 8-digit code</span>1839 </span>1840 <span class="peach-sync-pair-option-chevron" aria-hidden="true">›</span>1841 </button>1842 <Show when={!isPro()}>1843 <p class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2);margin:8px 2px 0">1844 At least one device needs Peach Pro — pair with a Pro device and it carries over to here.1845 </p>1846 </Show>18471848 <Show when={pairedDevices().length > 0}>1849 <p class="peach-settings-sub-label">PAIRED DEVICES</p>1850 <For each={pairedDevices()}>1851 {(d) => (1852 <div class="peach-settings-row peach-settings-row--top">1853 <div class="peach-settings-row-main">1854 <Show1855 when={renameDeviceId() === d.deviceId}1856 fallback={<span>{d.localAlias ?? d.deviceName ?? "Unnamed device"}</span>}1857 >1858 <input1859 class="peach-settings-input"1860 aria-label={`Rename ${d.localAlias ?? d.deviceName ?? "device"}`}1861 maxlength={64}1862 value={renameDraft()}1863 onInput={(event) => setRenameDraft(event.currentTarget.value)}1864 onKeyDown={(event) => {1865 if (event.key === "Enter") void renameDevice(d.deviceId);1866 if (event.key === "Escape") setRenameDeviceId(null);1867 }}1868 />1869 </Show>1870 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1871 {deviceTypeLabel(d.deviceType)} · {syncRelativeTime(d.lastSyncedAt)}{isSyncStale(d) ? " · not seen recently" : ""}1872 </span>1873 </div>1874 <Show1875 when={confirmUnpair() === d.deviceId}1876 fallback={1877 <div style="display:flex;gap:4px;flex-shrink:0">1878 <Show1879 when={renameDeviceId() === d.deviceId}1880 fallback={1881 <button1882 class="peach-btn peach-btn--subtle peach-btn--sm"1883 onClick={() => {1884 setRenameDraft(d.localAlias ?? d.deviceName ?? "");1885 setRenameDeviceId(d.deviceId);1886 }}1887 >rename</button>1888 }1889 >1890 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => setRenameDeviceId(null)}>cancel</button>1891 <button class="peach-btn peach-btn--subtle peach-btn--sm" disabled={!renameDraft().trim()} onClick={() => void renameDevice(d.deviceId)}>save</button>1892 </Show>1893 <button1894 class="peach-btn peach-btn--subtle peach-btn--sm"1895 onClick={() => {1896 setUnpairError(null);1897 setConfirmUnpair(d.deviceId);1898 }}1899 >remove</button>1900 </div>1901 }1902 >1903 {19041905190619071908}1909 <div class="peach-unpair-confirm">1910 <p class="peach-unpair-confirm-note">1911 Stops this device receiving future changes. It doesn't1912 lock it out of what it already has — if it's lost or1913 stolen, also change your master password and generate1914 new recovery words.1915 </p>1916 <div style="display:flex;gap:6px;justify-content:flex-end">1917 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => setConfirmUnpair(null)}>cancel</button>1918 <button1919 class="peach-btn peach-btn--sm"1920 style="background:var(--peach-breach);border-color:var(--peach-breach)"1921 onClick={() => void unpairDevice(d.deviceId)}1922 >remove</button>1923 </div>1924 </div>1925 <Show when={unpairError()}>1926 <p class="peach-settings-value" role="alert" style="color:var(--peach-danger,#e5805c);font-size:11px;line-height:1.5">1927 {unpairError()}1928 </p>1929 </Show>1930 </Show>1931 </div>1932 )}1933 </For>1934 </Show>19351936 <Show when={pendingVouched().length > 0}>1937 <p class="peach-settings-sub-label">1938 PENDING DEVICES1939 <span class="peach-pro-badge" style="background:#C8A840;color:#1a1a1a">{pendingVouched().length}</span>1940 </p>1941 <For each={pendingVouched()}>1942 {(dev) => (1943 <div class="peach-settings-row peach-settings-row--top">1944 <div class="peach-settings-row-main">1945 <span>{dev.deviceName}</span>1946 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1947 Key {dev.longTermPublicKeyHex.slice(0, 12).match(/.{4}/g)?.join(" ")} ·1948 requested {new Date(dev.receivedAt).toISOString().slice(0, 16).replace("T", " ")} UTC ·1949 vouched by device {dev.vouchedByDeviceId}. Allowing it can read1950 existing vault contents and all future changes.1951 </span>1952 </div>1953 <div style="display:flex;gap:6px;flex-shrink:0">1954 <button1955 class="peach-btn peach-btn--sm"1956 disabled={vouchedBusy() === dev.deviceId}1957 onClick={() => void confirmVouched(dev.deviceId)}1958 >Allow</button>1959 <button1960 class="peach-btn peach-btn--subtle peach-btn--sm"1961 disabled={vouchedBusy() === dev.deviceId}1962 onClick={() => void rejectVouched(dev.deviceId)}1963 >Reject</button>1964 </div>1965 </div>1966 )}1967 </For>1968 </Show>19691970 <button1971 class="peach-settings-advanced-toggle peach-sync-disclosure-toggle"1972 onClick={() => setShowSyncConnection((value) => !value)}1973 aria-expanded={showSyncConnection()}1974 aria-controls="peach-sync-connection"1975 >1976 <span>1977 <span class="peach-settings-sub-label" style="margin:0">CONNECTION</span>1978 <span class="peach-sync-disclosure-summary">Relay and privacy choices</span>1979 </span>1980 <span class="peach-settings-advanced-chevron">1981 {showSyncConnection() ? "▲" : "▼"}1982 </span>1983 </button>1984 <Show when={showSyncConnection()}>1985 <div id="peach-sync-connection" class="peach-sync-disclosure-content">1986 <SyncModeSelector />1987 </div>1988 </Show>19891990 <button1991 class="peach-settings-advanced-toggle peach-sync-disclosure-toggle"1992 onClick={() => setShowSyncRecovery((value) => !value)}1993 aria-expanded={showSyncRecovery()}1994 aria-controls="peach-sync-recovery"1995 >1996 <span>1997 <span class="peach-settings-sub-label" style="margin:0">RECOVERY & SECURITY</span>1998 <span class="peach-sync-disclosure-summary">For a lost or stolen device</span>1999 </span>2000 <span class="peach-settings-advanced-chevron">2001 {showSyncRecovery() ? "▲" : "▼"}2002 </span>2003 </button>2004 <Show when={showSyncRecovery()}>2005 <div id="peach-sync-recovery" class="peach-sync-disclosure-content">2006 <Show2007 when={rotateConfirmOpen()}2008 fallback={2009 <div class="peach-settings-row peach-settings-row--top">2010 <div class="peach-settings-row-main">2011 <span>Rotate sync identity</span>2012 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">2013 Disconnects every device and replaces the keys used for sync.2014 </span>2015 </div>2016 <button2017 class="peach-btn peach-btn--subtle peach-btn--sm"2018 style="color:var(--peach-breach);border-color:color-mix(in srgb,var(--peach-breach) 40%,transparent)"2019 onClick={() => { setRotateError(null); setRotateConfirmOpen(true); }}2020 >rotate</button>2021 </div>2022 }2023 >2024 <div class="peach-info-notice" style="margin-top:8px;border-color:color-mix(in srgb,var(--peach-breach) 40%,transparent)">2025 This disconnects every paired device and changes your 12 recovery words. You'll be shown the new words next.2026 <Show when={rotateError()}>2027 <div style="color:var(--peach-breach);margin-top:6px">{rotateError()}</div>2028 </Show>2029 <div style="display:flex;gap:6px;margin-top:10px">2030 <button2031 class="peach-btn peach-btn--subtle peach-btn--sm"2032 disabled={rotating()}2033 onClick={() => setRotateConfirmOpen(false)}2034 >Cancel</button>2035 <button2036 class="peach-btn peach-btn--sm"2037 style="background:var(--peach-breach);border-color:var(--peach-breach)"2038 disabled={rotating()}2039 onClick={() => void doRotateSyncIdentity()}2040 >{rotating() ? "Rotating…" : "Rotate sync identity"}</button>2041 </div>2042 </div>2043 </Show>2044 </div>2045 </Show>2046 </div>2047 </div>2048 );2049 }20502051 function renderAppearance() {2052 const sizeOptions: Array<{ id: "medium" | "large"; label: string }> = [2053 { id: "medium", label: "Default" },2054 { id: "large", label: "Large" },2055 ];2056 return (2057 <div class="peach-settings-body">2058 <div class="peach-settings-group">2059 <p class="peach-settings-sub-label">THEME</p>2060 <div class="peach-settings-row peach-settings-row--meta">2061 <span>Theme</span>2062 <button2063 class="peach-btn peach-btn--subtle peach-btn--sm"2064 onClick={() =>2065 props.onThemeChange(props.theme === "dark" ? "light" : "dark")2066 }2067 >2068 {props.theme === "dark" ? "Switch to light" : "Switch to dark"}2069 </button>2070 </div>20712072 <p class="peach-settings-sub-label">ACCENT</p>2073 <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">2074 <span>Accent</span>2075 <div class="peach-accent-picker">2076 <For each={ACCENT_COLORS}>2077 {(color) => (2078 <button2079 class="peach-accent-swatch peach-swatch"2080 classList={{ "is-active": props.accentColor === color.name }}2081 style={{ background: color.hex }}2082 onClick={() => void props.onAccentChange(color.name)}2083 aria-label={color.name}2084 title={color.name}2085 />2086 )}2087 </For>2088 </div>2089 </div>20902091 <p class="peach-settings-sub-label">TEXT SIZE</p>2092 <div class="peach-settings-row peach-settings-row--meta">2093 <span>Size</span>2094 <div class="peach-pill-group">2095 <For each={sizeOptions}>2096 {(opt) => (2097 <button2098 class="peach-btn peach-btn--ghost peach-btn--sm"2099 classList={{ "is-active": props.textSize === opt.id }}2100 onClick={() => void props.onTextSizeChange(opt.id)}2101 >2102 {opt.label}2103 </button>2104 )}2105 </For>2106 </div>2107 </div>21082109 <p class="peach-settings-sub-label">MOTION</p>2110 <div class="peach-settings-row peach-settings-row--meta">2111 <span>Reduce motion</span>2112 <button2113 class="peach-btn peach-btn--subtle peach-btn--sm"2114 onClick={() => void props.onReduceMotionChange(!props.reduceMotion)}2115 >2116 {props.reduceMotion ? "On" : "Off"}2117 </button>2118 </div>2119 </div>2120 </div>2121 );2122 }21232124 function renderShortcuts() {2125 const [capturingAction, setCapturingAction] = createSignal<ShortcutAction | null>(null);2126 const [conflictMsg, setConflictMsg] = createSignal<string | null>(null);21272128 function startCapture(action: ShortcutAction) {2129 setConflictMsg(null);2130 setCapturingAction(action);2131 }2132 function cancelCapture() {2133 setCapturingAction(null);2134 setConflictMsg(null);2135 }2136 function commitBinding(action: ShortcutAction, binding: Keybinding) {2137 const conflict = findConflict(props.shortcuts, binding, action);2138 if (conflict) {2139 const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict);2140 setConflictMsg(`Already bound to "${other?.label ?? conflict}"`);2141 return;2142 }2143 const next = { ...props.shortcuts, [action]: binding };2144 setCapturingAction(null);2145 setConflictMsg(null);2146 void props.onShortcutsChange(next);2147 }2148 function resetAction(action: ShortcutAction) {2149 const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] };2150 void props.onShortcutsChange(next);2151 }2152 function resetAll() {2153 void props.onShortcutsChange({ ...DEFAULT_BINDINGS });2154 }21552156 2157 2158 function handleCaptureKey(e: KeyboardEvent) {2159 e.preventDefault();2160 e.stopPropagation();2161 if (e.key === "Escape") { cancelCapture(); return; }2162 const binding = keybindingFromEvent(e);2163 if (!binding) return; 2164 const action = capturingAction();2165 if (action) commitBinding(action, binding);2166 }21672168 return (2169 <div class="peach-settings-body">2170 <div class="peach-settings-group peach-shortcuts-group">2171 <p class="peach-settings-sub-label">ENTRY VIEW</p>2172 <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry")}>2173 {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}2174 </For>2175 </div>21762177 <div class="peach-settings-group peach-shortcuts-group">2178 <p class="peach-settings-sub-label">ANYWHERE IN POPUP</p>2179 <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global")}>2180 {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}2181 </For>2182 </div>21832184 <Show when={conflictMsg()}>2185 <div class="peach-shortcuts-conflict">{conflictMsg()}</div>2186 </Show>21872188 <div class="peach-settings-group" style="display:flex;flex-direction:column;gap:8px">2189 <div class="peach-settings-row peach-settings-row--meta">2190 <span>Inline autofill on page</span>2191 <a2192 class="peach-settings-link"2193 href="chrome://extensions/shortcuts"2194 onClick={(e) => {2195 e.preventDefault();2196 chrome.tabs.create({ url: "chrome://extensions/shortcuts" });2197 }}2198 >2199 ⌘⇧P — customize ↗2200 </a>2201 </div>2202 <button2203 class="peach-btn peach-btn--subtle peach-btn--sm"2204 style="align-self:flex-start"2205 onClick={resetAll}2206 >2207 Reset all to defaults2208 </button>2209 </div>2210 </div>2211 );22122213 function renderRow(2214 desc: typeof SHORTCUT_DESCRIPTORS[number],2215 capturing: () => ShortcutAction | null,2216 onStart: (a: ShortcutAction) => void,2217 onCancel: () => void,2218 onReset: (a: ShortcutAction) => void,2219 onKey: (e: KeyboardEvent) => void,2220 ) {2221 const isActive = () => capturing() === desc.action;2222 const binding = () => props.shortcuts[desc.action];2223 const isCustom = () => {2224 const b = binding();2225 const d = DEFAULT_BINDINGS[desc.action];2226 return b.key !== d.key2227 || Boolean(b.ctrl) !== Boolean(d.ctrl)2228 || Boolean(b.meta) !== Boolean(d.meta)2229 || Boolean(b.shift) !== Boolean(d.shift)2230 || Boolean(b.alt) !== Boolean(d.alt);2231 };2232 return (2233 <div class="peach-shortcut-row">2234 <span class="peach-shortcut-row-label">{desc.label}</span>2235 <button2236 class="peach-shortcut-chip"2237 classList={{ "is-capturing": isActive(), "is-custom": isCustom() }}2238 onClick={() => isActive() ? onCancel() : onStart(desc.action)}2239 onKeyDown={(e) => { if (isActive()) onKey(e); }}2240 ref={(el) => {2241 2242 if (el) {2243 queueMicrotask(() => { if (isActive()) el.focus(); });2244 }2245 }}2246 >2247 {isActive() ? "Press a key…" : formatKeybinding(binding())}2248 </button>2249 <Show when={isCustom() && !isActive()}>2250 <button2251 class="peach-shortcut-reset"2252 title="Reset to default"2253 onClick={() => onReset(desc.action)}2254 >2255 ↺2256 </button>2257 </Show>2258 </div>2259 );2260 }2261 }22622263 const PRO_FEATURES: Array<{2264 feature: ProFeature;2265 name: string;2266 desc: string;2267 }> = [2268 { feature: "sync", name: "Peach Sync", desc: "Encrypted · peer-to-peer" },2269 { feature: "codex", name: "Peach Codex", desc: "Restore from paper" },2270 { feature: "passkeys", name: "Passkeys", desc: "Zero-knowledge WebAuthn" },2271 { feature: "advanced_health", name: "Advanced Vault Health", desc: "Reuse · age · exposure" },2272 ];22732274 const PRO_SECURITY_FEATURES: Array<{2275 feature: ProFeature;2276 name: string;2277 desc: string;2278 }> = [2279 { feature: "canary_entry", name: "Canary entries", desc: "Detects vault compromise" },2280 { feature: "duress_vault", name: "Duress vault", desc: "Decoy under coercion" },2281 ];22822283 const [licenseKey, setLicenseKey] = createSignal<string | null>(null);2284 const [showKey, setShowKey] = createSignal(false);22852286 const [deactivating, setDeactivating] = createSignal(false);22872288 async function handleDeactivateLicense() {2289 setDeactivating(true);2290 await sendMessage({ type: "DEACTIVATE_LICENSE" });2291 setLicenseInfo(null);2292 await props.onLicenseChange?.();2293 setDeactivating(false);2294 }22952296 async function handleTestCanary(entry: {id: string; title: string; canary: Canary}) {2297 setCanaryTestingId(entry.id);2298 setCanaryTestResult(null);2299 const res = await sendMessage({2300 type: "FIRE_CANARY_PING",2301 payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger: "test" },2302 }) as { ok: boolean };2303 setCanaryTestResult({ id: entry.id, ok: res.ok });2304 setCanaryTestingId(null);2305 setTimeout(() => setCanaryTestResult((r) => r?.id === entry.id ? null : r), 3000);2306 }23072308 function renderLicense() {2309 async function onActivate() {2310 await handleActivateLicense();2311 if ((licenseInfo()?.valid ?? false) && licenseInfo()?.plan === "pro") {2312 await props.onLicenseChange?.();2313 }2314 }23152316 return (2317 <div class="peach-settings-body">2318 <Show2319 when={isPro()}2320 fallback={2321 <div class="peach-pro-upgrade">2322 <p class="peach-pro-eyebrow">PEACH PRO · ONE-TIME</p>23232324 <h2 class="peach-pro-headline">2325 Every<em>thing,</em><br />every<em>where.</em>2326 </h2>23272328 <p class="peach-pro-subtitle">2329 One key unlocks every Pro feature — for the lifetime of the app.2330 </p>23312332 <div class="peach-pro-grid">2333 <For each={PRO_FEATURES}>2334 {(item, i) => (2335 <div2336 class="peach-pro-grid-item"2337 classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}2338 >2339 <span class="peach-pro-grid-num">{String(i() + 1).padStart(2, "0")}</span>2340 <span class="peach-pro-grid-name">{item.name}</span>2341 <span class="peach-pro-grid-desc">{item.desc}</span>2342 </div>2343 )}2344 </For>2345 </div>23462347 <p class="peach-pro-section-label">ADVANCED SECURITY</p>2348 <div class="peach-pro-grid">2349 <For each={PRO_SECURITY_FEATURES}>2350 {(item, i) => (2351 <div2352 class="peach-pro-grid-item"2353 classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}2354 >2355 <span class="peach-pro-grid-num">{String(PRO_FEATURES.length + i() + 1).padStart(2, "0")}</span>2356 <span class="peach-pro-grid-name">{item.name}</span>2357 <span class="peach-pro-grid-desc">{item.desc}</span>2358 </div>2359 )}2360 </For>2361 </div>23622363 <div class="peach-pro-upgrade-actions">2364 <p class="peach-pro-pricing">2365 $39 <span class="peach-pro-pricing-sub">once · yours forever</span>2366 </p>2367 <a2368 href="https://buy.polar.sh/polar_cl_IXSFIPojx67rcOTksn8LkH5mnU3osPHtYqBIl1eyuIz"2369 target="_blank"2370 rel="noopener noreferrer"2371 class="peach-pro-upgrade-cta"2372 >2373 Get Peach Pro2374 </a>2375 <div class="peach-pro-key-divider"><span>ALREADY HAVE A KEY</span></div>2376 <div class="peach-pro-key-inline">2377 <input2378 class="peach-pro-key-inline-input"2379 type="text"2380 placeholder="PEACH-XXXX-XXXX-XXXX"2381 value={licenseKeyInput()}2382 onInput={(e) => setLicenseKeyInput(e.currentTarget.value)}2383 onKeyDown={(e) => { if (e.key === "Enter") void onActivate(); }}2384 autocomplete="off"2385 spellcheck={false}2386 />2387 <button2388 class="peach-pro-key-apply"2389 onClick={() => void onActivate()}2390 disabled={activatingLicense() || !licenseKeyInput().trim()}2391 >2392 {activatingLicense() ? "…" : "APPLY"}2393 </button>2394 </div>2395 <Show when={licenseError()}>2396 <p class="peach-pro-key-error">{licenseError()}</p>2397 </Show>2398 </div>2399 </div>2400 }2401 >2402 <div class="peach-pro-active">2403 <div class="peach-pro-active-hero">2404 <button2405 class="peach-pro-active-mark-wrap"2406 type="button"2407 aria-label="Celebrate"2408 onClick={handleProCelebrationClick}2409 >2410 <PeachMark class="peach-pro-active-mark" />2411 <Show when={justActivated()}>2412 <div class="peach-pro-sparks" aria-hidden="true">2413 <span class="peach-pro-spark" style="--spark-x:0px;--spark-y:-54px;--spark-delay:0ms">✦</span>2414 <span class="peach-pro-spark" style="--spark-x:38px;--spark-y:-38px;--spark-delay:50ms">✦</span>2415 <span class="peach-pro-spark" style="--spark-x:54px;--spark-y:0px;--spark-delay:110ms">·</span>2416 <span class="peach-pro-spark" style="--spark-x:38px;--spark-y:38px;--spark-delay:30ms">✦</span>2417 <span class="peach-pro-spark" style="--spark-x:0px;--spark-y:54px;--spark-delay:80ms">·</span>2418 <span class="peach-pro-spark" style="--spark-x:-38px;--spark-y:38px;--spark-delay:130ms">✦</span>2419 <span class="peach-pro-spark" style="--spark-x:-54px;--spark-y:0px;--spark-delay:55ms">·</span>2420 <span class="peach-pro-spark" style="--spark-x:-38px;--spark-y:-38px;--spark-delay:95ms">✦</span>2421 </div>2422 </Show>2423 </button>2424 <h2 class="peach-pro-active-title">Peach Pro</h2>2425 <p class="peach-pro-active-since">{formatActivatedSince(licenseInfo()?.issued)}</p>2426 <Show when={justActivated()}>2427 <p class="peach-pro-thankyou-text" aria-live="polite">thank you ✦</p>2428 </Show>2429 </div>24302431 <Show when={licenseKey()}>2432 <div class="peach-settings-license">2433 <span class="peach-settings-label">LICENSE KEY</span>2434 <div class="peach-settings-license-row">2435 <Show2436 when={showKey()}2437 fallback={2438 <span class="peach-settings-license-key peach-settings-license-key--masked" aria-label="License key hidden">2439 ••••••••••••••••••••••••••••2440 </span>2441 }2442 >2443 <span class="peach-settings-license-key">{licenseKey()!}</span>2444 </Show>2445 <div class="peach-settings-license-actions">2446 <button2447 class="peach-settings-license-copy"2448 title="Copy license key"2449 onClick={() => copyLicenseKey(licenseKey()!)}2450 >2451 {copied() ? "✓" : "copy"}2452 </button>2453 <button2454 class="peach-settings-license-toggle"2455 aria-label={showKey() ? "Hide license key" : "Reveal license key"}2456 title={showKey() ? "Hide" : "Reveal"}2457 onClick={() => setShowKey(v => !v)}2458 >2459 <Show2460 when={showKey()}2461 fallback={2462 <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">2463 <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />2464 <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />2465 </svg>2466 }2467 >2468 <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">2469 <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />2470 <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />2471 <line x1="2" y1="2" x2="12" y2="12" />2472 </svg>2473 </Show>2474 </button>2475 </div>2476 </div>2477 </div>2478 </Show>24792480 <div class="peach-pro-active-features">2481 <For each={[...PRO_FEATURES, ...PRO_SECURITY_FEATURES]}>2482 {(item) => (2483 <div class="peach-pro-active-feature">2484 <span class="peach-pro-active-feature-mark">✦</span>2485 <span class="peach-pro-active-feature-name">{item.name}</span>2486 </div>2487 )}2488 </For>2489 </div>24902491 <button2492 class="peach-pro-deactivate"2493 onClick={() => void handleDeactivateLicense()}2494 disabled={deactivating()}2495 >2496 {deactivating() ? "Deactivating…" : "Deactivate"}2497 </button>2498 </div>2499 </Show>2500 </div>2501 );2502 }25032504 function renderFeedback() {2505 return (2506 <div class="peach-settings-body">2507 <div class="peach-settings-group peach-feedback-group">2508 <Show2509 when={feedbackState() === 'sent'}2510 fallback={2511 <>2512 <p class="peach-feedback-intro">2513 Help us improve Peach. Every report goes directly to our inbox.2514 </p>25152516 <p class="peach-settings-sub-label">TYPE</p>2517 <div class="peach-feedback-chips">2518 <For each={FEEDBACK_CATEGORIES}>2519 {(cat) => {2520 const selected = () => fbCategory() === cat;2521 return (2522 <button2523 type="button"2524 class={`peach-feedback-chip ${selected() ? "is-active" : ""}`}2525 aria-pressed={selected()}2526 onClick={() => setFbCategory(cat)}2527 >2528 {cat}2529 </button>2530 );2531 }}2532 </For>2533 </div>25342535 <p class="peach-settings-sub-label">MESSAGE</p>2536 <textarea2537 class="peach-feedback-textarea"2538 placeholder="Describe the issue or idea..."2539 maxLength={MAX_FEEDBACK_MESSAGE_LENGTH}2540 value={fbMessage()}2541 onInput={(e) => setFbMessage(e.currentTarget.value)}2542 />25432544 <p class="peach-feedback-meta">2545 Version {version} · will be included automatically2546 </p>25472548 <button2549 type="button"2550 class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"2551 disabled={!fbMessage().trim() || feedbackState() === 'sending'}2552 onClick={() => void handleSubmitFeedback()}2553 >2554 <Switch>2555 <Match when={feedbackState() === 'idle'}>Send</Match>2556 <Match when={feedbackState() === 'sending'}>Sending...</Match>2557 <Match when={feedbackState() === 'error'}>Failed - try again</Match>2558 </Switch>2559 </button>25602561 <p class="peach-feedback-anon">2562 Sent anonymously · no account required2563 </p>2564 </>2565 }2566 >2567 <div class="peach-feedback-thanks" role="status" aria-live="polite">2568 <p class="peach-feedback-thanks-title">Thank you</p>2569 <p>2570 Your note is in the queue. Someone will process it soon.2571 </p>2572 </div>2573 </Show>2574 </div>2575 </div>2576 );2577 }25782579 const bodies: StackBodies = {2580 protection: renderSecurity(),2581 vault: renderVault(),2582 sync: renderSync(),2583 appearance: renderAppearance(),2584 shortcuts: renderShortcuts(),2585 pro: renderLicense(),2586 feedback: renderFeedback(),2587 };25882589 return (2590 <Show2591 when={showWhatsNew()}2592 fallback={2593 <Show2594 when={showNewRecoveryWords()}2595 fallback={2596 <div2597 class="peach-overlay-view peach-settings-overlay"2598 classList={{ "ov-enter-right": !props.isExiting, "ov-exit-right": !!props.isExiting }}2599 style="display:flex;flex-direction:column"2600 >2601 <StackDeck2602 active={activeCard()}2603 onActiveChange={setActiveCard}2604 onBack={props.onBack}2605 cards={cards()}2606 bodies={bodies}2607 version={version}2608 isFanning={isFanning()}2609 celebratingCardId={justActivated() ? "pro" : null}2610 isPro={isPro()}2611 />2612 </div>2613 }2614 >2615 <RecoveryWordDisplayView onDone={() => setShowNewRecoveryWords(false)} />2616 </Show>2617 }2618 >2619 <div class="peach-overlay-view peach-settings-overlay">2620 <WhatsNewView mode="history" onContinue={() => setShowWhatsNew(false)} />2621 </div>2622 </Show>2623 );2624}