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 [renameDeviceId, setRenameDeviceId] = createSignal<string | null>(null);311 const [renameDraft, setRenameDraft] = createSignal("");312 const [syncStatus, setSyncStatus] = createSignal<"idle" | "syncing" | "error">("idle");313 const [syncFlash, setSyncFlash] = createSignal<string | null>(null);314 315 316 const [syncSummary, setSyncSummary] = createSignal<SyncOutcomeSummary>({});317 318 319 const [generation, setGeneration] = createSignal<SyncGeneration | null>(null);320 const [resetOpen, setResetOpen] = createSignal(false);321 const [resetPassword, setResetPassword] = createSignal("");322 const [resetError, setResetError] = createSignal<string | null>(null);323 const [resetting, setResetting] = createSignal(false);324 const syncLight = () =>325 syncStatus() === "syncing"326 ? "busy"327 : syncIndicator(syncSummary(), pairedDevices().length);328 const syncLightLabel = () => syncIndicatorLabel(syncLight(), syncSummary());329 const lastSyncLine = () => {330 const s = syncSummary();331 if (!s.outcome) return "No sync attempted yet";332 return `${syncRelativeTime(s.finishedAt)} · ${describeSyncOutcome(s)}`;333 };334 async function loadSyncState() {335 const r = (await sendMessage({ type: "GET_SYNC_STATE" })) as {336 ok: boolean;337 data?: SyncOutcomeSummary;338 };339 if (r.ok && r.data) {340 setSyncSummary(r.data);341 setSyncStatus(r.data.status ?? "idle");342 }343 }344345 async function loadGeneration() {346 const r = (await sendMessage({ type: "GET_SYNC_GENERATION" })) as {347 ok: boolean;348 data?: SyncGeneration | null;349 };350 if (r.ok) setGeneration(r.data ?? null);351 }352353 async function runSecureReset() {354 setResetting(true);355 const r = (await sendMessage({356 type: "SECURE_RESET",357 payload: { password: resetPassword() },358 })) as { ok: boolean; data?: SyncGeneration | null; error?: string };359 setResetting(false);360 setResetPassword("");361 if (!r.ok) {362 setResetError(r.error ?? "The reset did not complete — nothing was lost, try again");363 await loadGeneration();364 return;365 }366 setResetError(null);367 setResetOpen(false);368 if (r.data) setGeneration(r.data);369 }370371 const STALE_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000;372 function syncRelativeTime(when: number | undefined): string {373 if (!when) return "Not synced yet";374 const elapsed = Date.now() - when;375 if (elapsed < 60_000) return "Just now";376 const minutes = Math.floor(elapsed / 60_000);377 if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"} ago`;378 const hours = Math.floor(minutes / 60);379 if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;380 const days = Math.floor(hours / 24);381 return days < 7 ? `${days} day${days === 1 ? "" : "s"} ago` : "Not seen recently";382 }383 function isSyncStale(d: PairedDevice): boolean {384 return d.lastSyncedAt !== undefined && Date.now() - d.lastSyncedAt > STALE_THRESHOLD_MS;385 }386 function deviceTypeLabel(t: PairedDevice["deviceType"]): string {387 switch (t) {388 case "android": return "Android";389 case "ios": return "iPhone";390 case "extension": return "Browser";391 default: return "Device";392 }393 }394395 async function loadPairedDevices() {396 if (!isPro()) return;397 const r = (await sendMessage({ type: "LIST_PAIRED_DEVICES" })) as {398 ok: boolean;399 error?: string;400 data?: { devices: PairedDevice[] };401 };402 if (r.ok) {403 setTrustDataCorrupted(false);404 setPairedDevices(r.data?.devices ?? []);405 } else if (r.error === "trust_data_corrupted") {406 setPairedDevices([]);407 setTrustDataCorrupted(true);408 }409 }410 async function unpairDevice(deviceId: string) {411 await sendMessage({ type: "UNPAIR_DEVICE", payload: { deviceId } });412 setConfirmUnpair(null);413 await loadPairedDevices();414 }415 async function renameDevice(deviceId: string) {416 const name = renameDraft().trim();417 if (!name) return;418 const result = (await sendMessage({419 type: "RENAME_PAIRED_DEVICE",420 payload: { deviceId, name },421 })) as { ok: boolean };422 if (!result.ok) return;423 setRenameDeviceId(null);424 await loadPairedDevices();425 }426 async function syncNow() {427 setSyncStatus("syncing");428 setSyncFlash(null);429 const res = (await sendMessage({430 type: "TRIGGER_SYNC",431 payload: { reason: "manual", force: true },432 })) as { ok: boolean; error?: string };433 if (!res.ok) {434 setSyncStatus("error");435 setSyncSummary({ status: "error", outcome: "run_failed", detail: res.error });436 return;437 }438 439 440 441 let s: SyncOutcomeSummary = { status: "syncing" };442 for (let i = 0; i < 40 && s.status === "syncing"; i++) {443 await new Promise((r) => setTimeout(r, 250));444 const state = (await sendMessage({ type: "GET_SYNC_STATE" })) as {445 ok: boolean;446 data?: SyncOutcomeSummary;447 };448 s = state.ok ? state.data ?? { status: "idle" } : { status: "error", outcome: "run_failed" };449 }450 setSyncSummary(s);451 setSyncStatus(s.status ?? "idle");452 453 454 setSyncFlash(describeSyncOutcome(s));455 setTimeout(() => setSyncFlash(null), 4000);456 await loadPairedDevices();457 }458 459 const [rotateConfirmOpen, setRotateConfirmOpen] = createSignal(false);460 const [rotating, setRotating] = createSignal(false);461 const [rotateError, setRotateError] = createSignal<string | null>(null);462 const [showSyncConnection, setShowSyncConnection] = createSignal(false);463 const [showSyncRecovery, setShowSyncRecovery] = createSignal(false);464465 async function loadPendingVouched() {466 const r = (await sendMessage({ type: "GET_PENDING_VOUCHED_DEVICES" })) as {467 ok: boolean;468 data?: PendingVouched[];469 };470 if (r.ok) setPendingVouched(r.data ?? []);471 }472 async function confirmVouched(deviceId: string) {473 setVouchedBusy(deviceId);474 try {475 await sendMessage({ type: "CONFIRM_VOUCHED_DEVICE", payload: { deviceId } });476 await loadPendingVouched();477 } finally {478 setVouchedBusy(null);479 }480 }481 async function rejectVouched(deviceId: string) {482 setVouchedBusy(deviceId);483 try {484 await sendMessage({ type: "REJECT_VOUCHED_DEVICE", payload: { deviceId } });485 await loadPendingVouched();486 } finally {487 setVouchedBusy(null);488 }489 }490 async function doRotateSyncIdentity() {491 setRotateError(null);492 setRotating(true);493 try {494 const r = (await sendMessage({ type: "ROTATE_SYNC_IDENTITY" })) as {495 ok: boolean;496 error?: string;497 };498 if (!r.ok) {499 setRotateError(r.error ?? "Rotation failed.");500 return;501 }502 setRotateConfirmOpen(false);503 504 505 setShowNewRecoveryWords(true);506 } finally {507 setRotating(false);508 }509 }510511 const [dedupCount, setDedupCount] = createSignal<number | null>(null);512 const [dedupRunning, setDedupRunning] = createSignal(false);513 const [dedupDone, setDedupDone] = createSignal<number | null>(null);514515 const [declinedCardCount, setDeclinedCardCount] = createSignal(0);516517 518 519 520 const [historySnapshots, setHistorySnapshots] = createSignal<VaultSnapshot[]>([]);521 const [historyLoading, setHistoryLoading] = createSignal(false);522 const [historyRestoring, setHistoryRestoring] = createSignal<string | null>(null);523 const [historyShowAll, setHistoryShowAll] = createSignal(false);524525 async function loadVaultHistory() {526 setHistoryLoading(true);527 try {528 const res = await sendMessage({ type: "GET_VAULT_HISTORY" });529 const list = (res as { ok: boolean; data?: VaultSnapshot[] }).data ?? [];530 setHistorySnapshots(list);531 } finally {532 setHistoryLoading(false);533 }534 }535536 async function handleRestoreVaultSnapshot(id: string) {537 if (historyRestoring()) return;538 setHistoryRestoring(id);539 const res = await sendMessage({540 type: "RESTORE_VAULT_SNAPSHOT",541 payload: { snapshotId: id },542 });543 setHistoryRestoring(null);544 if ((res as { ok: boolean }).ok) {545 546 547 props.onBack();548 }549 }550551 function formatSnapshotTime(savedAt: number): string {552 const diff = Date.now() - savedAt;553 if (diff < 60_000) return "Just now";554 if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;555 const d = new Date(savedAt);556 const isToday = new Date().toDateString() === d.toDateString();557 const isYest =558 new Date(Date.now() - 86_400_000).toDateString() === d.toDateString();559 const t = d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });560 if (isToday) return `Today ${t}`;561 if (isYest) return `Yesterday ${t}`;562 return `${d.toLocaleDateString([], { month: "short", day: "numeric" })} ${t}`;563 }564565 function reasonLabel(reason: VaultSnapshot["reason"]): string {566 return (567 {568 local_save: "saved",569 sync_received: "synced",570 import: "imported",571 restore: "restored",572 } as const573 )[reason] ?? reason;574 }575576 const [showVaultAdvanced, setShowVaultAdvanced] = createSignal(false);577 const [showWhatsNew, setShowWhatsNew] = createSignal(false);578579 const [showChangePw, setShowChangePw] = createSignal(false);580 const [changePwCurrent, setChangePwCurrent] = createSignal("");581 const [changePwNew, setChangePwNew] = createSignal("");582 const [changePwConfirm, setChangePwConfirm] = createSignal("");583 const [changePwSaving, setChangePwSaving] = createSignal(false);584 const [changePwError, setChangePwError] = createSignal<string | null>(null);585 const [changePwSuccess] = createSignal(false);586 const [showNewRecoveryWords, setShowNewRecoveryWords] = createSignal(false);587588 const [pinEnabled, setPinEnabled] = createSignal(false);589 const [pinFlow, setPinFlow] = createSignal<"setup" | "disable" | null>(null);590 const [pinCurrentPassword, setPinCurrentPassword] = createSignal("");591 const [pinValue, setPinValue] = createSignal("");592 const [pinConfirm, setPinConfirm] = createSignal("");593 const [pinSaving, setPinSaving] = createSignal(false);594 const [pinError, setPinError] = createSignal<string | null>(null);595596 597 const [showViewWords, setShowViewWords] = createSignal(false);598 const [viewWordsPassword, setViewWordsPassword] = createSignal("");599 const [viewWordsError, setViewWordsError] = createSignal<string | null>(null);600 const [viewedWords, setViewedWords] = createSignal<string[] | null>(null);601 const [viewWordsBusy, setViewWordsBusy] = createSignal(false);602603 async function handleViewRecoveryWords() {604 setViewWordsError(null);605 if (!viewWordsPassword()) {606 setViewWordsError("Enter your master password.");607 return;608 }609 setViewWordsBusy(true);610 try {611 const r = (await sendMessage({612 type: "GET_RECOVERY_WORDS",613 payload: { password: viewWordsPassword() },614 })) as { ok: boolean; data?: string[]; error?: string };615 if (r.ok) {616 setViewedWords(r.data ?? []);617 setViewWordsPassword("");618 return;619 }620 switch (r.error) {621 case "wrong_password":622 setViewWordsError("Incorrect password");623 break;624 case "password_required":625 setViewWordsError("Enter your master password.");626 break;627 case "recovery_not_setup":628 setViewWordsError("Recovery words aren't set up for this vault yet.");629 break;630 default:631 setViewWordsError("Couldn't retrieve recovery words.");632 }633 } finally {634 setViewWordsBusy(false);635 }636 }637 function closeViewWords() {638 setShowViewWords(false);639 setViewWordsPassword("");640 setViewWordsError(null);641 setViewedWords(null);642 }643644 const [duressEnabled, setDuressEnabled] = createSignal(false);645 const [showDuressSetup, setShowDuressSetup] = createSignal(false);646 const [duressPassword, setDuressPassword] = createSignal("");647 const [duressConfirm, setDuressConfirm] = createSignal("");648 const [duressMainPassword, setDuressMainPassword] = createSignal("");649 const [duressSaving, setDuressSaving] = createSignal(false);650 const [duressError, setDuressError] = createSignal<string | null>(null);651 const [duressSuccess, setDuressSuccess] = createSignal<string | null>(null);652653 const [canaryEntries, setCanaryEntries] = createSignal<Array<{id: string; title: string; canary: Canary}>>([]);654655 const [canaryTestingId, setCanaryTestingId] = createSignal<string | null>(null);656 const [canaryTestResult, setCanaryTestResult] = createSignal<{id: string; ok: boolean} | null>(null);657658 const [fbCategory, setFbCategory] = createSignal("Bug report");659 const [fbMessage, setFbMessage] = createSignal("");660 type FeedbackState = 'idle' | 'sending' | 'sent' | 'error';661 const [feedbackState, setFeedbackState] = createSignal<FeedbackState>('idle');662663 createEffect(() => {664 if (props.upgradeHighlight) {665 setActiveCard("pro");666 }667 });668669 onMount(async () => {670 const [licenseRes, passkeyRes, duressRes, settingsRes, keyRes] = await Promise.all([671 sendMessage({ type: "GET_LICENSE" }),672 sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }),673 sendMessage({ type: "IS_DURESS_ENABLED" }),674 sendMessage({ type: "GET_SETTINGS" }),675 sendMessage({ type: "GET_LICENSE_KEY" }),676 ]);677678 679 680 void loadVaultHistory();681682 683 684 void loadPendingVouched();685 void loadPairedDevices();686 void loadSyncState();687 void loadGeneration();688 const onBroadcast = (msg: unknown) => {689 const m = msg as { type?: string; payload?: unknown } | null;690 if (m?.type === "VOUCHED_DEVICE_PENDING") {691 void loadPendingVouched();692 }693 if (m?.type === "SYNC_STATE_CHANGED") {694 const payload = (m.payload ?? {}) as SyncOutcomeSummary;695 setSyncSummary(payload);696 setSyncStatus(payload.status ?? "idle");697 }698 };699 chrome.runtime?.onMessage?.addListener(onBroadcast);700 onCleanup(() => {701 try { chrome.runtime?.onMessage?.removeListener(onBroadcast); } catch { }702 });703704 if ((licenseRes as { ok: boolean }).ok) {705 setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null);706 }707708 if ((passkeyRes as { ok: boolean }).ok) {709 setPasskeyState(710 (passkeyRes as { ok: boolean; data?: PasskeyProviderState }).data ?? null711 );712 }713714 if ((duressRes as { ok: boolean }).ok) {715 setDuressEnabled((duressRes as { ok: boolean; data?: boolean }).data ?? false);716 }717718 if ((settingsRes as { ok: boolean }).ok) {719 const sData = (settingsRes as { ok: boolean; data?: { declinedCardFingerprints?: string[]; pinEnabled?: boolean } }).data;720 setDeclinedCardCount(sData?.declinedCardFingerprints?.length ?? 0);721 setPinEnabled(sData?.pinEnabled === true);722 }723724 if ((keyRes as { ok: boolean }).ok) {725 setLicenseKey((keyRes as { ok: boolean; data?: string }).data ?? null);726 }727728 const entriesRes = await sendMessage({ type: "GET_ENTRIES" });729 if ((entriesRes as { ok: boolean }).ok) {730 const entries = (entriesRes as { ok: boolean; data?: Array<{id: string; title: string; canary?: Canary}> }).data ?? [];731 setCanaryEntries(732 entries733 .filter((e) => e.canary?.webhookUrl)734 .map((e) => ({ id: e.id, title: e.title, canary: e.canary! }))735 );736 }737738 });739740 function resetPinFlow() {741 setPinFlow(null);742 setPinCurrentPassword("");743 setPinValue("");744 setPinConfirm("");745 setPinError(null);746 }747748 async function handleSetupPin() {749 setPinError(null);750 if (!pinCurrentPassword()) { setPinError("enter your current password"); return; }751 if (!/^\d{4,12}$/.test(pinValue())) { setPinError("PIN must be 4–12 digits"); return; }752 if (pinValue() !== pinConfirm()) { setPinError("PINs do not match"); return; }753 setPinSaving(true);754 const res = await sendMessage({755 type: "SETUP_PIN",756 payload: { currentPassword: pinCurrentPassword(), pin: pinValue() },757 }) as { ok: boolean; error?: string };758 if (res.ok) {759 setPinEnabled(true);760 resetPinFlow();761 } else {762 setPinError(res.error === "incorrect_current_password" ? "incorrect current password" : "couldn't set up PIN");763 }764 setPinSaving(false);765 }766767 async function handleDisablePin() {768 setPinError(null);769 if (!pinCurrentPassword()) { setPinError("enter your current password"); return; }770 setPinSaving(true);771 const res = await sendMessage({772 type: "DISABLE_PIN",773 payload: { currentPassword: pinCurrentPassword() },774 }) as { ok: boolean; error?: string };775 if (res.ok) {776 setPinEnabled(false);777 resetPinFlow();778 } else {779 setPinError(res.error === "incorrect_current_password" ? "incorrect current password" : "couldn't remove PIN");780 }781 setPinSaving(false);782 }783784 async function handleChangePassword() {785 setChangePwError(null);786 if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; }787 if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; }788 if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; }789 if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; }790 setChangePwSaving(true);791 const res = await sendMessage({792 type: "CHANGE_PASSWORD",793 payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() },794 }) as { ok: boolean; error?: string };795 if (res.ok) {796 setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");797 setShowChangePw(false);798 799 800 801 await sendMessage({ type: "SET_RECOVERY_CONFIRM_PENDING" });802 setShowNewRecoveryWords(true);803 } else {804 setChangePwError(res.error ?? "failed to change password");805 }806 setChangePwSaving(false);807 }808809 const [copied, setCopied] = createSignal(false);810811 async function copyLicenseKey(key: string) {812 await navigator.clipboard.writeText(key);813 setCopied(true);814 setTimeout(() => setCopied(false), 2000);815 }816817 async function handleActivateLicense() {818 const key = licenseKeyInput().trim();819 if (!key) return;820 setActivatingLicense(true);821 setLicenseError(null);822 setLicenseSuccess(null);823 const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } });824 setActivatingLicense(false);825 if ((res as { ok: boolean }).ok) {826 const data = (res as { ok: boolean; data?: LicenseInfo }).data;827 if (data?.valid) {828 setLicenseInfo(data);829 setLicenseKey(key);830 setLicenseSuccess("License activated!");831 setLicenseKeyInput("");832 } else {833 setLicenseError("Invalid license key");834 }835 } else {836 setLicenseError("Activation failed");837 }838 }839840 async function handleTogglePasskeys() {841 const current = passkeyState();842 if (!current || !current.supported) return;843 setPasskeySaving(true);844 const res = await sendMessage({845 type: "SET_PASSKEY_PROVIDER_ENABLED",846 payload: { enabled: !current.enabled },847 });848 setPasskeySaving(false);849 if ((res as { ok: boolean }).ok) {850 setPasskeyState(851 (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null852 );853 }854 }855856 async function handleCheckAllBreaches() {857 setCheckingBreaches(true);858 setBreachResult(null);859 const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });860 setCheckingBreaches(false);861 if ((res as { ok: boolean }).ok) {862 const data = (res as { ok: boolean; data?: Record<string, number> }).data ?? {};863 const breached = Object.values(data).filter((count) => count > 0).length;864 setBreachResult(865 breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean"866 );867 props.onBreachCheckDone();868 }869 }870871872 async function handleSetupDuress() {873 const mainPw = duressMainPassword().trim();874 const duressPw = duressPassword().trim();875 const confirmPw = duressConfirm().trim();876877 if (!mainPw) { setDuressError("enter your main password to confirm"); return; }878 if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; }879 if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; }880 if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; }881882 setDuressSaving(true);883 setDuressError(null);884885 const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };886 if (!verifyRes.ok || !verifyRes.data) {887 setDuressError("incorrect main password");888 setDuressSaving(false);889 return;890 }891892 const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string };893 setDuressSaving(false);894 if (res.ok) {895 setDuressEnabled(true);896 setShowDuressSetup(false);897 setDuressPassword("");898 setDuressConfirm("");899 setDuressMainPassword("");900 setDuressSuccess("Duress password set. If entered, a decoy vault will be shown.");901 setTimeout(() => setDuressSuccess(null), 5000);902 } else {903 setDuressError(904 res.error === "session_expired"905 ? "Vault session expired — lock and unlock Peach, then try again"906 : res.error ?? "failed to set duress password"907 );908 }909 }910911 async function handleClearDeclinedCards() {912 await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } });913 setDeclinedCardCount(0);914 }915916 async function handleCountDuplicates() {917 setDedupRunning(true);918 setDedupDone(null);919 const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number };920 setDedupRunning(false);921 if (res.ok) setDedupCount(res.data ?? 0);922 }923924 async function handleDeduplicate() {925 setDedupRunning(true);926 const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number };927 setDedupRunning(false);928 if (res.ok) {929 setDedupDone(res.data ?? 0);930 setDedupCount(0);931 }932 }933934 async function handleDisableDuress() {935 const mainPw = duressMainPassword().trim();936 if (!mainPw) { setDuressError("enter your main password to confirm"); return; }937938 setDuressSaving(true);939 setDuressError(null);940941 const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };942 if (!verifyRes.ok || !verifyRes.data) {943 setDuressError("incorrect main password");944 setDuressSaving(false);945 return;946 }947948 const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string };949 setDuressSaving(false);950 if (res.ok) {951 setDuressEnabled(false);952 setShowDuressSetup(false);953 setDuressMainPassword("");954 setDuressSuccess("Duress password disabled.");955 setTimeout(() => setDuressSuccess(null), 4000);956 } else {957 setDuressError(res.error ?? "failed to disable duress password");958 }959 }960961 async function handleSubmitFeedback() {962 const message = sanitizeFeedbackMessage(fbMessage());963 if (!message) return;964965 setFeedbackState('sending');966967 try {968 const category = FEEDBACK_CATEGORIES.includes(fbCategory())969 ? fbCategory()970 : FEEDBACK_CATEGORIES[0];971 const res = await fetch('https://api.web3forms.com/submit', {972 method: 'POST',973 headers: { 'Content-Type': 'application/json' },974 body: JSON.stringify({975 access_key: WEB3FORMS_KEY,976 subject: `Peach Feedback - ${category}`,977 message,978 from_name: 'Peach User',979 980 botcheck: '',981 }),982 });983984 if (!res.ok) throw new Error(`HTTP ${res.status}`);985 const data = await res.json() as { success: boolean; message?: string };986 if (!data.success) throw new Error(data.message ?? 'unknown error');987988 setFeedbackState('sent');989 setFbMessage('');990 setFbCategory(FEEDBACK_CATEGORIES[0]);991 } catch (err) {992 console.error('[feedback]', err);993 setFeedbackState('error');994 setTimeout(() => setFeedbackState('idle'), 4000);995 }996 }997998 function renderSecurity() {999 return (1000 <div class="peach-settings-body">1001 <div class="peach-settings-group">1002 <p class="peach-settings-sub-label">LOCKING</p>1003 <div class="peach-settings-row">1004 <span>Lock on close</span>1005 <button1006 class={`peach-toggle ${props.lockOnClose ? "is-on" : ""}`}1007 onClick={() => void props.onLockOnCloseChange(!props.lockOnClose)}1008 aria-label="Toggle lock on close"1009 >1010 <span class="peach-toggle-knob"></span>1011 </button>1012 </div>1013 <div class="peach-settings-row">1014 <span>Auto-lock after</span>1015 <select1016 class="peach-settings-select"1017 value={String(props.autoLockMinutes)}1018 onChange={(e) => void props.onAutoLockMinutesChange(Number(e.currentTarget.value))}1019 >1020 <option value="1">1 minute</option>1021 <option value="5">5 minutes</option>1022 <option value="15">15 minutes</option>1023 <option value="30">30 minutes</option>1024 <option value="0">Never</option>1025 </select>1026 </div>10271028 <p class="peach-settings-sub-label">QUICK UNLOCK</p>1029 <div class="peach-settings-row peach-settings-row--meta">1030 <span>PIN unlock</span>1031 <button1032 class="peach-btn peach-btn--subtle peach-btn--sm"1033 onClick={() => { setPinFlow(pinEnabled() ? "disable" : "setup"); setPinError(null); }}1034 >1035 {pinEnabled() ? "remove" : "set up"}1036 </button>1037 </div>1038 <Show when={pinFlow() === "setup"}>1039 <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1040 <input1041 class="peach-settings-input"1042 type="password"1043 placeholder="current password"1044 value={pinCurrentPassword()}1045 onInput={(e) => setPinCurrentPassword((e.target as HTMLInputElement).value)}1046 autocomplete="current-password"1047 />1048 <input1049 class="peach-settings-input"1050 type="password"1051 inputmode="numeric"1052 maxlength="12"1053 placeholder="new 4–12 digit PIN"1054 value={pinValue()}1055 onInput={(e) => setPinValue((e.target as HTMLInputElement).value.replace(/\D/g, "").slice(0, 12))}1056 autocomplete="off"1057 />1058 <input1059 class="peach-settings-input"1060 type="password"1061 inputmode="numeric"1062 maxlength="12"1063 placeholder="confirm PIN"1064 value={pinConfirm()}1065 onInput={(e) => setPinConfirm((e.target as HTMLInputElement).value.replace(/\D/g, "").slice(0, 12))}1066 autocomplete="off"1067 onKeyDown={(e) => { if (e.key === "Enter") void handleSetupPin(); }}1068 />1069 <Show when={pinError()}>1070 <p class="peach-hint peach-hint-error" style="margin:0">{pinError()}</p>1071 </Show>1072 <button1073 class="peach-btn peach-btn--accent peach-btn--sm"1074 style="align-self:flex-start"1075 onClick={() => void handleSetupPin()}1076 disabled={pinSaving() || !pinCurrentPassword() || !/^\d{4,12}$/.test(pinValue()) || !/^\d{4,12}$/.test(pinConfirm())}1077 >1078 {pinSaving() ? "saving…" : "save PIN"}1079 </button>1080 </div>1081 </Show>1082 <Show when={pinFlow() === "disable"}>1083 <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1084 <input1085 class="peach-settings-input"1086 type="password"1087 placeholder="current password"1088 value={pinCurrentPassword()}1089 onInput={(e) => setPinCurrentPassword((e.target as HTMLInputElement).value)}1090 autocomplete="current-password"1091 onKeyDown={(e) => { if (e.key === "Enter") void handleDisablePin(); }}1092 />1093 <Show when={pinError()}>1094 <p class="peach-hint peach-hint-error" style="margin:0">{pinError()}</p>1095 </Show>1096 <button1097 class="peach-btn peach-btn--subtle peach-btn--sm"1098 style="align-self:flex-start"1099 onClick={() => void handleDisablePin()}1100 disabled={pinSaving() || !pinCurrentPassword()}1101 >1102 {pinSaving() ? "removing…" : "remove PIN"}1103 </button>1104 </div>1105 </Show>11061107 <p class="peach-settings-sub-label">BREACH PROTECTION</p>1108 <div class="peach-settings-row">1109 <span>Breach monitoring</span>1110 <button1111 class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}1112 onClick={() => void props.onBreachMonitoringChange(!props.breachMonitoring)}1113 aria-label="Toggle breach monitoring"1114 >1115 <span class="peach-toggle-knob"></span>1116 </button>1117 </div>1118 <div class="peach-settings-row peach-settings-row--meta">1119 <span>Check all passwords</span>1120 <button1121 class="peach-btn peach-btn--subtle peach-btn--sm"1122 onClick={() => void handleCheckAllBreaches()}1123 disabled={checkingBreaches()}1124 >1125 {checkingBreaches() ? "checking..." : "run"}1126 </button>1127 </div>1128 <Show when={breachResult()}>1129 <div class="peach-settings-row peach-settings-row--meta">1130 <span>Last check</span>1131 <span class="peach-settings-value">{breachResult()}</span>1132 </div>1133 </Show>11341135 <p class="peach-settings-sub-label">MASTER PASSWORD</p>1136 <div class="peach-settings-row peach-settings-row--meta">1137 <span>Master password</span>1138 <button1139 class="peach-btn peach-btn--subtle peach-btn--sm"1140 onClick={() => { setShowChangePw((v) => !v); setChangePwError(null); }}1141 >1142 {showChangePw() ? "cancel" : "change"}1143 </button>1144 </div>1145 <Show when={showChangePw()}>1146 <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1147 <input1148 class="peach-settings-input"1149 type="password"1150 placeholder="current password"1151 value={changePwCurrent()}1152 onInput={(e) => setChangePwCurrent((e.target as HTMLInputElement).value)}1153 autocomplete="current-password"1154 />1155 <input1156 class="peach-settings-input"1157 type="password"1158 placeholder="new password (min 12 chars)"1159 value={changePwNew()}1160 onInput={(e) => setChangePwNew((e.target as HTMLInputElement).value)}1161 autocomplete="new-password"1162 />1163 <input1164 class="peach-settings-input"1165 type="password"1166 placeholder="confirm new password"1167 value={changePwConfirm()}1168 onInput={(e) => setChangePwConfirm((e.target as HTMLInputElement).value)}1169 autocomplete="new-password"1170 onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }}1171 />1172 <Show when={changePwError()}>1173 <p class="peach-hint peach-hint-error" style="margin:0">{changePwError()}</p>1174 </Show>1175 <Show when={changePwSuccess()}>1176 <p class="peach-hint" style="margin:0;color:var(--peach-accent)">password changed ✓</p>1177 </Show>1178 <button1179 class="peach-btn peach-btn--accent peach-btn--sm"1180 style="align-self:flex-start"1181 onClick={() => void handleChangePassword()}1182 disabled={changePwSaving() || !changePwCurrent() || !changePwNew() || !changePwConfirm()}1183 >1184 {changePwSaving() ? "saving…" : "save new password"}1185 </button>1186 </div>1187 </Show>11881189 {}1190 <div class="peach-settings-row peach-settings-row--meta">1191 <span>Recovery words</span>1192 <button1193 class="peach-btn peach-btn--subtle peach-btn--sm"1194 onClick={() => {1195 if (showViewWords()) { closeViewWords(); } else { setShowViewWords(true); }1196 }}1197 >1198 {showViewWords() ? "cancel" : "view"}1199 </button>1200 </div>1201 <Show when={showViewWords()}>1202 <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1203 <Show1204 when={viewedWords()}1205 fallback={1206 <>1207 <p class="peach-hint" style="margin:0;color:var(--peach-t2)">1208 Enter your master password to reveal your recovery words.1209 </p>1210 <input1211 class="peach-settings-input"1212 type="password"1213 placeholder="master password"1214 value={viewWordsPassword()}1215 onInput={(e) => setViewWordsPassword((e.target as HTMLInputElement).value)}1216 autocomplete="current-password"1217 onKeyDown={(e) => { if (e.key === "Enter") void handleViewRecoveryWords(); }}1218 />1219 <Show when={viewWordsError()}>1220 <p class="peach-hint peach-hint-error" style="margin:0">{viewWordsError()}</p>1221 </Show>1222 <button1223 class="peach-btn peach-btn--accent peach-btn--sm"1224 style="align-self:flex-start"1225 onClick={() => void handleViewRecoveryWords()}1226 disabled={viewWordsBusy() || !viewWordsPassword()}1227 >1228 {viewWordsBusy() ? "checking…" : "reveal recovery words"}1229 </button>1230 </>1231 }1232 >1233 <div class="peach-recovery-words-grid">1234 <For each={viewedWords()!}>1235 {(word, i) => (1236 <span class="peach-recovery-word"><b>{i() + 1}</b> {word}</span>1237 )}1238 </For>1239 </div>1240 <button1241 class="peach-btn peach-btn--subtle peach-btn--sm"1242 style="align-self:flex-start"1243 onClick={closeViewWords}1244 >done</button>1245 </Show>1246 </div>1247 </Show>12481249 <p class="peach-settings-sub-label">1250 DURESS PASSWORD1251 <Show when={!isPro()}>1252 <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>1253 </Show>1254 </p>1255 <div class="peach-settings-row peach-settings-row--top">1256 <div class="peach-settings-row-main">1257 <span>1258 Duress password1259 <Show when={!isPro()}>1260 <span class="peach-pro-badge">Pro</span>1261 </Show>1262 </span>1263 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">1264 If entered under coercion, shows a decoy empty vault instead of your real one.1265 </span>1266 </div>1267 <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>1268 {duressEnabled() ? "on" : "off"}1269 </div>1270 </div>12711272 <Show when={duressEnabled() && !showDuressSetup()}>1273 <div class="peach-settings-row peach-settings-row--meta">1274 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1275 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.1276 </span>1277 </div>1278 </Show>12791280 <Show when={duressSuccess()}>1281 <div class="peach-settings-row peach-settings-row--meta">1282 <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>1283 </div>1284 </Show>12851286 <Show when={!showDuressSetup()}>1287 <div class="peach-settings-row peach-settings-row--meta">1288 <button1289 class="peach-btn peach-btn--subtle peach-btn--sm"1290 onClick={() => {1291 1292 1293 1294 1295 1296 if (!isPro() && !duressEnabled()) {1297 props.onProGate?.("duress_vault");1298 setActiveCard("pro");1299 return;1300 }1301 setShowDuressSetup(true);1302 setDuressError(null);1303 setDuressSuccess(null);1304 setDuressPassword("");1305 setDuressConfirm("");1306 setDuressMainPassword("");1307 }}1308 >1309 {duressEnabled() ? "Change / disable" : "Set up"}1310 </button>1311 </div>1312 </Show>13131314 <Show when={showDuressSetup()}>1315 <div class="peach-duress-form">1316 <label class="peach-label">main password (to confirm)</label>1317 <input1318 class="peach-settings-input"1319 type="password"1320 placeholder="your current master password"1321 autocomplete="current-password"1322 value={duressMainPassword()}1323 onInput={(e) => setDuressMainPassword(e.currentTarget.value)}1324 />1325 <Show when={!duressEnabled()}>1326 <label class="peach-label">new duress password</label>1327 <input1328 class="peach-settings-input"1329 type="password"1330 placeholder="duress password (min 8 chars)"1331 autocomplete="new-password"1332 value={duressPassword()}1333 onInput={(e) => setDuressPassword(e.currentTarget.value)}1334 />1335 <label class="peach-label">confirm duress password</label>1336 <input1337 class="peach-settings-input"1338 type="password"1339 placeholder="confirm duress password"1340 autocomplete="new-password"1341 value={duressConfirm()}1342 onInput={(e) => setDuressConfirm(e.currentTarget.value)}1343 />1344 </Show>13451346 <Show when={duressError()}>1347 <p class="peach-hint peach-hint-error">{duressError()}</p>1348 </Show>13491350 <div class="peach-duress-actions">1351 <button1352 class="peach-btn peach-btn--subtle peach-btn--sm"1353 onClick={() => { setShowDuressSetup(false); setDuressError(null); }}1354 >1355 cancel1356 </button>1357 <Show when={duressEnabled()}>1358 <button1359 class="peach-btn peach-btn--ghost peach-btn--sm"1360 style="color:var(--peach-breach)"1361 onClick={() => void handleDisableDuress()}1362 disabled={duressSaving()}1363 >1364 {duressSaving() ? "disabling..." : "disable duress"}1365 </button>1366 </Show>1367 <Show when={!duressEnabled()}>1368 <button1369 class="peach-btn peach-btn--accent peach-btn--sm"1370 onClick={() => void handleSetupDuress()}1371 disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}1372 >1373 {duressSaving() ? "saving..." : "save"}1374 </button>1375 </Show>1376 </div>1377 </div>1378 </Show>13791380 <p class="peach-settings-sub-label">1381 CANARY ENTRIES1382 <Show when={!isPro()}>1383 <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>1384 </Show>1385 </p>1386 <div class="peach-settings-row peach-settings-row--top">1387 <div class="peach-settings-row-main">1388 <span>1389 Canary entries1390 <Show when={!isPro()}>1391 <span class="peach-pro-badge">Pro</span>1392 </Show>1393 </span>1394 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1395 Silently pings a webhook when an entry is accessed. Configure canary on any entry in your vault.1396 </span>1397 </div>1398 <span class="peach-settings-value" style="flex-shrink:0">1399 {canaryEntries().length > 0 ? `${canaryEntries().length} active` : "none"}1400 </span>1401 </div>1402 <Show when={canaryEntries().length > 0}>1403 <For each={canaryEntries()}>1404 {(entry) => (1405 <div class="peach-settings-row peach-settings-row--meta">1406 <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0">{entry.title}</span>1407 <button1408 class="peach-btn peach-btn--subtle peach-btn--sm"1409 onClick={() => void handleTestCanary(entry)}1410 disabled={canaryTestingId() === entry.id}1411 style="flex-shrink:0"1412 >1413 {canaryTestingId() === entry.id1414 ? "sending…"1415 : canaryTestResult()?.id === entry.id1416 ? (canaryTestResult()!.ok ? "sent ✓" : "failed ✗")1417 : "test"}1418 </button>1419 </div>1420 )}1421 </For>1422 </Show>14231424 <p class="peach-settings-sub-label">PASSKEYS</p>1425 <div class="peach-settings-row peach-settings-row--top">1426 <div class="peach-settings-row-main">1427 <span>Intercept passkey requests</span>1428 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">1429 Peach can handle passkey sign-in and creation on supported Chromium browsers.1430 </span>1431 </div>1432 <button1433 class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}1434 onClick={() => void handleTogglePasskeys()}1435 aria-label="Toggle passkey interception"1436 disabled={!passkeyState()?.supported || passkeySaving()}1437 >1438 <span class="peach-toggle-knob"></span>1439 </button>1440 </div>1441 <div class="peach-settings-row peach-settings-row--meta">1442 <span>Status</span>1443 <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>1444 </div>1445 <div class="peach-settings-row peach-settings-row--meta">1446 <span>Browser</span>1447 <span class="peach-settings-value">1448 {passkeyState()?.browserLabel ?? "Unknown"}1449 {passkeyState()?.supported ? " ✓" : " (interception unavailable)"}1450 </span>1451 </div>1452 <div class="peach-info-notice" style="margin-top:8px">1453 Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.1454 </div>1455 <Show when={passkeyState()?.conflict}>1456 <div class="peach-settings-row peach-settings-row--meta">1457 <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>1458 </div>1459 </Show>1460 </div>1461 </div>1462 );1463 }14641465 function renderVault() {1466 return (1467 <div class="peach-settings-body">1468 <div class="peach-settings-group">1469 <p class="peach-settings-sub-label">ENTRIES</p>1470 <div class="peach-settings-row peach-settings-row--meta">1471 <span>Entries</span>1472 <span class="peach-settings-value">{props.entriesCount}</span>1473 </div>1474 <div class="peach-settings-row peach-settings-row--meta">1475 <span>Duplicates</span>1476 <Show when={dedupDone() !== null} fallback={1477 <button1478 class="peach-btn peach-btn--subtle peach-btn--sm"1479 onClick={() => void handleCountDuplicates()}1480 disabled={dedupRunning()}1481 >1482 {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}1483 </button>1484 }>1485 <span class="peach-settings-value" style="color:var(--peach-sync-ok)">1486 {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}1487 </span>1488 </Show>1489 </div>1490 <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>1491 <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">1492 <div class="peach-restore-warning">1493 {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.1494 </div>1495 <div style="display:flex;gap:6px">1496 <button1497 class="peach-btn peach-btn--subtle peach-btn--sm"1498 onClick={() => setDedupCount(null)}1499 >1500 cancel1501 </button>1502 <button1503 class="peach-btn peach-btn--accent peach-btn--sm"1504 onClick={() => void handleDeduplicate()}1505 disabled={dedupRunning()}1506 >1507 {dedupRunning() ? "removing..." : "remove duplicates"}1508 </button>1509 </div>1510 </div>1511 </Show>15121513 <p class="peach-settings-sub-label">MAINTENANCE</p>1514 <div class="peach-settings-row peach-settings-row--meta">1515 <span>Trash auto-delete</span>1516 <select1517 class="peach-settings-select"1518 value={String(props.trashRetentionDays)}1519 onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}1520 >1521 <option value="7">7 days</option>1522 <option value="30">30 days</option>1523 <option value="90">90 days</option>1524 <option value="0">Never</option>1525 </select>1526 </div>1527 <div class="peach-settings-row peach-settings-row--meta">1528 <span>Advanced Vault Health</span>1529 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>1530 open1531 </button>1532 </div>1533 <Show when={declinedCardCount() > 0}>1534 <div class="peach-settings-row peach-settings-row--meta">1535 <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>1536 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>1537 Clear1538 </button>1539 </div>1540 </Show>15411542 <p class="peach-settings-sub-label">DATA</p>1543 <div class="peach-settings-row peach-settings-row--meta">1544 <span>Import passwords</span>1545 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>1546 open1547 </button>1548 </div>1549 <div class="peach-settings-row peach-settings-row--meta">1550 <span>Export backup</span>1551 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onExport}>1552 open1553 </button>1554 </div>1555 <div class="peach-settings-row peach-settings-row--meta">1556 <span>1557 Peach Codex1558 <Show when={!isPro()}>1559 <span class="peach-pro-badge">Pro</span>1560 </Show>1561 </span>1562 <button1563 class="peach-btn peach-btn--subtle peach-btn--sm"1564 onClick={() => props.onOpenCodexGenerate?.()}1565 >1566 open1567 </button>1568 </div>1569 {1570}1571 <div class="peach-settings-row peach-settings-row--meta">1572 <span>Restore from Codex</span>1573 <button1574 class="peach-btn peach-btn--subtle peach-btn--sm"1575 onClick={() => props.onOpenCodexRestore?.()}1576 >1577 open1578 </button>1579 </div>15801581 <Show when={hasReleaseNotes()}>1582 <p class="peach-settings-sub-label">RELEASE NOTES</p>1583 <div class="peach-settings-row peach-settings-row--meta">1584 <span>v{PEACH_VERSION}</span>1585 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => setShowWhatsNew(true)}>1586 view1587 </button>1588 </div>1589 </Show>15901591 <span class="peach-settings-sub-label">VAULT HISTORY</span>1592 <Show when={historyLoading()}>1593 <p class="peach-settings-hint">Loading…</p>1594 </Show>1595 <Show when={!historyLoading() && historySnapshots().length === 0}>1596 <p class="peach-settings-hint">1597 No snapshots yet. Peach saves automatically before every change —1598 saves, syncs, and imports.1599 </p>1600 </Show>1601 <Show when={!historyLoading() && historySnapshots().length > 0}>1602 <p class="peach-settings-hint">1603 Restoring locks your vault. Unlock with the password that1604 encrypted that snapshot to confirm.1605 </p>1606 <div class="peach-history-list">1607 <For each={historySnapshots().slice(0, historyShowAll() ? undefined : 3)}>1608 {(snap) => (1609 <div class="peach-history-row">1610 <div class="peach-history-meta">1611 <span class="peach-history-time">1612 {formatSnapshotTime(snap.savedAt)}1613 </span>1614 <span class="peach-history-sub">1615 {[1616 snap.entryCount !== null1617 ? `up to ${snap.entryCount} ${snap.entryCount === 1 ? "entry" : "entries"}`1618 : null,1619 reasonLabel(snap.reason),1620 ]1621 .filter(Boolean)1622 .join(" · ")}1623 </span>1624 </div>1625 <button1626 class={`peach-btn peach-btn--subtle peach-btn--sm peach-history-btn${1627 historyRestoring() === snap.id ? " is-busy" : ""1628 }`}1629 onClick={() => void handleRestoreVaultSnapshot(snap.id)}1630 disabled={historyRestoring() !== null}1631 >1632 <Show when={historyRestoring() === snap.id} fallback="Restore">1633 …1634 </Show>1635 </button>1636 </div>1637 )}1638 </For>1639 </div>1640 <Show when={historySnapshots().length > 3}>1641 <button1642 class="peach-settings-advanced-toggle"1643 onClick={() => setHistoryShowAll((v) => !v)}1644 aria-expanded={historyShowAll()}1645 >1646 <span class="peach-settings-hint" style="margin:0">1647 {historyShowAll() ? "show less" : `${historySnapshots().length - 3} older`}1648 </span>1649 <span class="peach-settings-advanced-chevron">1650 {historyShowAll() ? "▲" : "▼"}1651 </span>1652 </button>1653 </Show>1654 <p class="peach-history-footer">1655 {historySnapshots().length} of 10 snapshots1656 </p>1657 </Show>16581659 <button1660 class="peach-settings-advanced-toggle"1661 onClick={() => setShowVaultAdvanced((v) => !v)}1662 aria-expanded={showVaultAdvanced()}1663 >1664 <span class="peach-settings-sub-label" style="margin:0">ADVANCED</span>1665 <span class="peach-settings-advanced-chevron">1666 {showVaultAdvanced() ? "▲" : "▼"}1667 </span>1668 </button>16691670 <Show when={showVaultAdvanced()}>1671 <div class="peach-danger-card">1672 <div class="peach-danger-card-head">1673 <span class="peach-danger-card-glyph" aria-hidden="true">⟳</span>1674 <h3 class="peach-danger-card-title">Replace vault from backup</h3>1675 </div>1676 <p class="peach-danger-card-copy">1677 Wipes the current vault and restores from a backup file.1678 Recoverable only if your backup is good.1679 </p>1680 <button1681 class="peach-danger-card-button"1682 onClick={() => props.onOpenFileImport?.()}1683 >1684 Choose backup file1685 </button>1686 </div>16871688 <Show when={props.onOpenPanic}>1689 <div class="peach-danger-card peach-danger-card--hazard">1690 <div class="peach-danger-card-head">1691 <span class="peach-danger-card-glyph" aria-hidden="true">⚠</span>1692 <h3 class="peach-danger-card-title">Destroy vault</h3>1693 </div>1694 <p class="peach-danger-card-copy">1695 Permanently wipes every entry, setting, and license on this1696 device. There is no recovery.1697 </p>1698 <button1699 class="peach-danger-card-button"1700 onClick={() => props.onOpenPanic?.()}1701 >1702 Begin destruction1703 </button>1704 </div>1705 </Show>1706 </Show>1707 </div>1708 </div>1709 );1710 }17111712 function renderSync() {1713 return (1714 <div class="peach-settings-body">1715 <div class="peach-settings-group">1716 <p class="peach-settings-sub-label" style="margin-top:0">SYNC STATUS</p>1717 <Show when={trustDataCorrupted()}>1718 <p class="peach-settings-value" role="alert" style="color:var(--peach-danger,#e5805c);line-height:1.5">1719 Device trust data is damaged — re-pair your devices after updating1720 </p>1721 </Show>1722 <div1723 class="peach-settings-row peach-settings-row--meta"1724 style="align-items:center;gap:8px"1725 aria-label={syncLightLabel()}1726 title={syncLightLabel()}1727 >1728 <span class={`stk-pip stk-pip--sync is-${syncLight()}`} aria-hidden="true" />1729 <span class="peach-settings-value" style="color:var(--peach-t2);line-height:1.5;flex:1;min-width:0;overflow-wrap:anywhere">1730 {syncFlash() ?? lastSyncLine()}1731 </span>1732 </div>1733 <Show when={pairedDevices().length > 0}>1734 <div class="peach-settings-row peach-settings-row--meta">1735 <span class="peach-settings-value" style="color:var(--peach-t2)">1736 {(syncSummary().entriesSent ?? 0) + (syncSummary().entriesReceived ?? 0) > 01737 ? `${syncSummary().entriesSent ?? 0} sent · ${syncSummary().entriesReceived ?? 0} received`1738 : ""}1739 </span>1740 <button1741 class="peach-btn peach-btn--subtle peach-btn--sm"1742 aria-label="Sync this vault now"1743 disabled={syncStatus() === "syncing"}1744 onClick={() => void syncNow()}1745 >{syncStatus() === "syncing" ? "syncing…" : "sync now"}</button>1746 </div>1747 </Show>17481749 <p class="peach-settings-sub-label">SECURE SYNC RESET</p>1750 <div class="peach-settings-row peach-settings-row--top">1751 <div class="peach-settings-row-main">1752 <span class="peach-settings-value" style="color:var(--peach-t1)">1753 {secureResetHeadline(generation()?.state)}1754 </span>1755 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1756 {secureResetDetail(generation()?.state)}1757 </span>1758 </div>1759 <Show1760 when={1761 generation()?.state === "LEGACY_ACTIVE" ||1762 generation()?.state === "RESET_REQUIRED" ||1763 generation()?.state === "RESET_CONFIRMED"1764 }1765 >1766 <button1767 class="peach-btn peach-btn--subtle peach-btn--sm"1768 onClick={() => {1769 setResetError(null);1770 setResetPassword("");1771 setResetOpen(true);1772 }}1773 >reset sync</button>1774 </Show>1775 </div>1776 <Show when={resetError()}>1777 <p1778 class="peach-settings-value"1779 role="alert"1780 style="color:var(--peach-danger,#e5805c);font-size:11px;line-height:1.5"1781 >{resetError()}</p>1782 </Show>1783 <Show when={resetOpen()}>1784 <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1785 <p class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1786 {SECURE_RESET_DISCLOSURE}1787 </p>1788 <input1789 class="peach-settings-input"1790 type="password"1791 placeholder="master password"1792 value={resetPassword()}1793 onInput={(e) => setResetPassword((e.target as HTMLInputElement).value)}1794 autocomplete="current-password"1795 />1796 <div style="display:flex;gap:4px">1797 <button1798 class="peach-btn peach-btn--subtle peach-btn--sm"1799 disabled={resetting()}1800 onClick={() => void runSecureReset()}1801 >{resetting() ? "resetting…" : "reset"}</button>1802 <button1803 class="peach-btn peach-btn--subtle peach-btn--sm"1804 disabled={resetting()}1805 onClick={() => setResetOpen(false)}1806 >cancel</button>1807 </div>1808 </div>1809 </Show>18101811 <p class="peach-settings-sub-label">ADD A DEVICE</p>1812 <button1813 class="peach-sync-pair-option"1814 onClick={() => props.onOpenSyncPairing?.("code")}1815 >1816 <span class="peach-sync-pair-option-icon" aria-hidden="true">1817 <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">1818 <rect x="6" y="2.5" width="8" height="15" rx="1.6" />1819 <path d="M9 15 L11 15" />1820 </svg>1821 </span>1822 <span class="peach-sync-pair-option-main">1823 <span class="peach-sync-pair-option-title">Show a code</span>1824 <span class="peach-sync-pair-option-sub">Scan it with Peach on your phone, then confirm one 8-digit code</span>1825 </span>1826 <span class="peach-sync-pair-option-chevron" aria-hidden="true">›</span>1827 </button>1828 <Show when={!isPro()}>1829 <p class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2);margin:8px 2px 0">1830 At least one device needs Peach Pro — pair with a Pro device and it carries over to here.1831 </p>1832 </Show>18331834 <Show when={pairedDevices().length > 0}>1835 <p class="peach-settings-sub-label">PAIRED DEVICES</p>1836 <For each={pairedDevices()}>1837 {(d) => (1838 <div class="peach-settings-row peach-settings-row--top">1839 <div class="peach-settings-row-main">1840 <Show1841 when={renameDeviceId() === d.deviceId}1842 fallback={<span>{d.localAlias ?? d.deviceName ?? "Unnamed device"}</span>}1843 >1844 <input1845 class="peach-settings-input"1846 aria-label={`Rename ${d.localAlias ?? d.deviceName ?? "device"}`}1847 maxlength={64}1848 value={renameDraft()}1849 onInput={(event) => setRenameDraft(event.currentTarget.value)}1850 onKeyDown={(event) => {1851 if (event.key === "Enter") void renameDevice(d.deviceId);1852 if (event.key === "Escape") setRenameDeviceId(null);1853 }}1854 />1855 </Show>1856 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1857 {deviceTypeLabel(d.deviceType)} · {syncRelativeTime(d.lastSyncedAt)}{isSyncStale(d) ? " · not seen recently" : ""}1858 </span>1859 </div>1860 <Show1861 when={confirmUnpair() === d.deviceId}1862 fallback={1863 <div style="display:flex;gap:4px;flex-shrink:0">1864 <Show1865 when={renameDeviceId() === d.deviceId}1866 fallback={1867 <button1868 class="peach-btn peach-btn--subtle peach-btn--sm"1869 onClick={() => {1870 setRenameDraft(d.localAlias ?? d.deviceName ?? "");1871 setRenameDeviceId(d.deviceId);1872 }}1873 >rename</button>1874 }1875 >1876 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => setRenameDeviceId(null)}>cancel</button>1877 <button class="peach-btn peach-btn--subtle peach-btn--sm" disabled={!renameDraft().trim()} onClick={() => void renameDevice(d.deviceId)}>save</button>1878 </Show>1879 <button1880 class="peach-btn peach-btn--subtle peach-btn--sm"1881 onClick={() => setConfirmUnpair(d.deviceId)}1882 >remove</button>1883 </div>1884 }1885 >1886 {18871888188918901891}1892 <div class="peach-unpair-confirm">1893 <p class="peach-unpair-confirm-note">1894 Stops this device receiving future changes. It doesn't1895 lock it out of what it already has — if it's lost or1896 stolen, also change your master password and generate1897 new recovery words.1898 </p>1899 <div style="display:flex;gap:6px;justify-content:flex-end">1900 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => setConfirmUnpair(null)}>cancel</button>1901 <button1902 class="peach-btn peach-btn--sm"1903 style="background:var(--peach-breach);border-color:var(--peach-breach)"1904 onClick={() => void unpairDevice(d.deviceId)}1905 >remove</button>1906 </div>1907 </div>1908 </Show>1909 </div>1910 )}1911 </For>1912 </Show>19131914 <Show when={pendingVouched().length > 0}>1915 <p class="peach-settings-sub-label">1916 PENDING DEVICES1917 <span class="peach-pro-badge" style="background:#C8A840;color:#1a1a1a">{pendingVouched().length}</span>1918 </p>1919 <For each={pendingVouched()}>1920 {(dev) => (1921 <div class="peach-settings-row peach-settings-row--top">1922 <div class="peach-settings-row-main">1923 <span>{dev.deviceName}</span>1924 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1925 Key {dev.longTermPublicKeyHex.slice(0, 12).match(/.{4}/g)?.join(" ")} ·1926 requested {new Date(dev.receivedAt).toISOString().slice(0, 16).replace("T", " ")} UTC ·1927 vouched by device {dev.vouchedByDeviceId}. Allowing it can read1928 existing vault contents and all future changes.1929 </span>1930 </div>1931 <div style="display:flex;gap:6px;flex-shrink:0">1932 <button1933 class="peach-btn peach-btn--sm"1934 disabled={vouchedBusy() === dev.deviceId}1935 onClick={() => void confirmVouched(dev.deviceId)}1936 >Allow</button>1937 <button1938 class="peach-btn peach-btn--subtle peach-btn--sm"1939 disabled={vouchedBusy() === dev.deviceId}1940 onClick={() => void rejectVouched(dev.deviceId)}1941 >Reject</button>1942 </div>1943 </div>1944 )}1945 </For>1946 </Show>19471948 <button1949 class="peach-settings-advanced-toggle peach-sync-disclosure-toggle"1950 onClick={() => setShowSyncConnection((value) => !value)}1951 aria-expanded={showSyncConnection()}1952 aria-controls="peach-sync-connection"1953 >1954 <span>1955 <span class="peach-settings-sub-label" style="margin:0">CONNECTION</span>1956 <span class="peach-sync-disclosure-summary">Relay and privacy choices</span>1957 </span>1958 <span class="peach-settings-advanced-chevron">1959 {showSyncConnection() ? "▲" : "▼"}1960 </span>1961 </button>1962 <Show when={showSyncConnection()}>1963 <div id="peach-sync-connection" class="peach-sync-disclosure-content">1964 <SyncModeSelector />1965 </div>1966 </Show>19671968 <button1969 class="peach-settings-advanced-toggle peach-sync-disclosure-toggle"1970 onClick={() => setShowSyncRecovery((value) => !value)}1971 aria-expanded={showSyncRecovery()}1972 aria-controls="peach-sync-recovery"1973 >1974 <span>1975 <span class="peach-settings-sub-label" style="margin:0">RECOVERY & SECURITY</span>1976 <span class="peach-sync-disclosure-summary">For a lost or stolen device</span>1977 </span>1978 <span class="peach-settings-advanced-chevron">1979 {showSyncRecovery() ? "▲" : "▼"}1980 </span>1981 </button>1982 <Show when={showSyncRecovery()}>1983 <div id="peach-sync-recovery" class="peach-sync-disclosure-content">1984 <Show1985 when={rotateConfirmOpen()}1986 fallback={1987 <div class="peach-settings-row peach-settings-row--top">1988 <div class="peach-settings-row-main">1989 <span>Rotate sync identity</span>1990 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1991 Disconnects every device and replaces the keys used for sync.1992 </span>1993 </div>1994 <button1995 class="peach-btn peach-btn--subtle peach-btn--sm"1996 style="color:var(--peach-breach);border-color:color-mix(in srgb,var(--peach-breach) 40%,transparent)"1997 onClick={() => { setRotateError(null); setRotateConfirmOpen(true); }}1998 >rotate</button>1999 </div>2000 }2001 >2002 <div class="peach-info-notice" style="margin-top:8px;border-color:color-mix(in srgb,var(--peach-breach) 40%,transparent)">2003 This disconnects every paired device and changes your 12 recovery words. You'll be shown the new words next.2004 <Show when={rotateError()}>2005 <div style="color:var(--peach-breach);margin-top:6px">{rotateError()}</div>2006 </Show>2007 <div style="display:flex;gap:6px;margin-top:10px">2008 <button2009 class="peach-btn peach-btn--subtle peach-btn--sm"2010 disabled={rotating()}2011 onClick={() => setRotateConfirmOpen(false)}2012 >Cancel</button>2013 <button2014 class="peach-btn peach-btn--sm"2015 style="background:var(--peach-breach);border-color:var(--peach-breach)"2016 disabled={rotating()}2017 onClick={() => void doRotateSyncIdentity()}2018 >{rotating() ? "Rotating…" : "Rotate sync identity"}</button>2019 </div>2020 </div>2021 </Show>2022 </div>2023 </Show>2024 </div>2025 </div>2026 );2027 }20282029 function renderAppearance() {2030 const sizeOptions: Array<{ id: "medium" | "large"; label: string }> = [2031 { id: "medium", label: "Default" },2032 { id: "large", label: "Large" },2033 ];2034 return (2035 <div class="peach-settings-body">2036 <div class="peach-settings-group">2037 <p class="peach-settings-sub-label">THEME</p>2038 <div class="peach-settings-row peach-settings-row--meta">2039 <span>Theme</span>2040 <button2041 class="peach-btn peach-btn--subtle peach-btn--sm"2042 onClick={() =>2043 props.onThemeChange(props.theme === "dark" ? "light" : "dark")2044 }2045 >2046 {props.theme === "dark" ? "Switch to light" : "Switch to dark"}2047 </button>2048 </div>20492050 <p class="peach-settings-sub-label">ACCENT</p>2051 <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">2052 <span>Accent</span>2053 <div class="peach-accent-picker">2054 <For each={ACCENT_COLORS}>2055 {(color) => (2056 <button2057 class="peach-accent-swatch peach-swatch"2058 classList={{ "is-active": props.accentColor === color.name }}2059 style={{ background: color.hex }}2060 onClick={() => void props.onAccentChange(color.name)}2061 aria-label={color.name}2062 title={color.name}2063 />2064 )}2065 </For>2066 </div>2067 </div>20682069 <p class="peach-settings-sub-label">TEXT SIZE</p>2070 <div class="peach-settings-row peach-settings-row--meta">2071 <span>Size</span>2072 <div class="peach-pill-group">2073 <For each={sizeOptions}>2074 {(opt) => (2075 <button2076 class="peach-btn peach-btn--ghost peach-btn--sm"2077 classList={{ "is-active": props.textSize === opt.id }}2078 onClick={() => void props.onTextSizeChange(opt.id)}2079 >2080 {opt.label}2081 </button>2082 )}2083 </For>2084 </div>2085 </div>20862087 <p class="peach-settings-sub-label">MOTION</p>2088 <div class="peach-settings-row peach-settings-row--meta">2089 <span>Reduce motion</span>2090 <button2091 class="peach-btn peach-btn--subtle peach-btn--sm"2092 onClick={() => void props.onReduceMotionChange(!props.reduceMotion)}2093 >2094 {props.reduceMotion ? "On" : "Off"}2095 </button>2096 </div>2097 </div>2098 </div>2099 );2100 }21012102 function renderShortcuts() {2103 const [capturingAction, setCapturingAction] = createSignal<ShortcutAction | null>(null);2104 const [conflictMsg, setConflictMsg] = createSignal<string | null>(null);21052106 function startCapture(action: ShortcutAction) {2107 setConflictMsg(null);2108 setCapturingAction(action);2109 }2110 function cancelCapture() {2111 setCapturingAction(null);2112 setConflictMsg(null);2113 }2114 function commitBinding(action: ShortcutAction, binding: Keybinding) {2115 const conflict = findConflict(props.shortcuts, binding, action);2116 if (conflict) {2117 const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict);2118 setConflictMsg(`Already bound to "${other?.label ?? conflict}"`);2119 return;2120 }2121 const next = { ...props.shortcuts, [action]: binding };2122 setCapturingAction(null);2123 setConflictMsg(null);2124 void props.onShortcutsChange(next);2125 }2126 function resetAction(action: ShortcutAction) {2127 const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] };2128 void props.onShortcutsChange(next);2129 }2130 function resetAll() {2131 void props.onShortcutsChange({ ...DEFAULT_BINDINGS });2132 }21332134 2135 2136 function handleCaptureKey(e: KeyboardEvent) {2137 e.preventDefault();2138 e.stopPropagation();2139 if (e.key === "Escape") { cancelCapture(); return; }2140 const binding = keybindingFromEvent(e);2141 if (!binding) return; 2142 const action = capturingAction();2143 if (action) commitBinding(action, binding);2144 }21452146 return (2147 <div class="peach-settings-body">2148 <div class="peach-settings-group peach-shortcuts-group">2149 <p class="peach-settings-sub-label">ENTRY VIEW</p>2150 <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry")}>2151 {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}2152 </For>2153 </div>21542155 <div class="peach-settings-group peach-shortcuts-group">2156 <p class="peach-settings-sub-label">ANYWHERE IN POPUP</p>2157 <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global")}>2158 {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}2159 </For>2160 </div>21612162 <Show when={conflictMsg()}>2163 <div class="peach-shortcuts-conflict">{conflictMsg()}</div>2164 </Show>21652166 <div class="peach-settings-group" style="display:flex;flex-direction:column;gap:8px">2167 <div class="peach-settings-row peach-settings-row--meta">2168 <span>Inline autofill on page</span>2169 <a2170 class="peach-settings-link"2171 href="chrome://extensions/shortcuts"2172 onClick={(e) => {2173 e.preventDefault();2174 chrome.tabs.create({ url: "chrome://extensions/shortcuts" });2175 }}2176 >2177 ⌘⇧P — customize ↗2178 </a>2179 </div>2180 <button2181 class="peach-btn peach-btn--subtle peach-btn--sm"2182 style="align-self:flex-start"2183 onClick={resetAll}2184 >2185 Reset all to defaults2186 </button>2187 </div>2188 </div>2189 );21902191 function renderRow(2192 desc: typeof SHORTCUT_DESCRIPTORS[number],2193 capturing: () => ShortcutAction | null,2194 onStart: (a: ShortcutAction) => void,2195 onCancel: () => void,2196 onReset: (a: ShortcutAction) => void,2197 onKey: (e: KeyboardEvent) => void,2198 ) {2199 const isActive = () => capturing() === desc.action;2200 const binding = () => props.shortcuts[desc.action];2201 const isCustom = () => {2202 const b = binding();2203 const d = DEFAULT_BINDINGS[desc.action];2204 return b.key !== d.key2205 || Boolean(b.ctrl) !== Boolean(d.ctrl)2206 || Boolean(b.meta) !== Boolean(d.meta)2207 || Boolean(b.shift) !== Boolean(d.shift)2208 || Boolean(b.alt) !== Boolean(d.alt);2209 };2210 return (2211 <div class="peach-shortcut-row">2212 <span class="peach-shortcut-row-label">{desc.label}</span>2213 <button2214 class="peach-shortcut-chip"2215 classList={{ "is-capturing": isActive(), "is-custom": isCustom() }}2216 onClick={() => isActive() ? onCancel() : onStart(desc.action)}2217 onKeyDown={(e) => { if (isActive()) onKey(e); }}2218 ref={(el) => {2219 2220 if (el) {2221 queueMicrotask(() => { if (isActive()) el.focus(); });2222 }2223 }}2224 >2225 {isActive() ? "Press a key…" : formatKeybinding(binding())}2226 </button>2227 <Show when={isCustom() && !isActive()}>2228 <button2229 class="peach-shortcut-reset"2230 title="Reset to default"2231 onClick={() => onReset(desc.action)}2232 >2233 ↺2234 </button>2235 </Show>2236 </div>2237 );2238 }2239 }22402241 const PRO_FEATURES: Array<{2242 feature: ProFeature;2243 name: string;2244 desc: string;2245 }> = [2246 { feature: "sync", name: "Peach Sync", desc: "Encrypted · peer-to-peer" },2247 { feature: "codex", name: "Peach Codex", desc: "Restore from paper" },2248 { feature: "passkeys", name: "Passkeys", desc: "Zero-knowledge WebAuthn" },2249 { feature: "advanced_health", name: "Advanced Vault Health", desc: "Reuse · age · exposure" },2250 ];22512252 const PRO_SECURITY_FEATURES: Array<{2253 feature: ProFeature;2254 name: string;2255 desc: string;2256 }> = [2257 { feature: "canary_entry", name: "Canary entries", desc: "Detects vault compromise" },2258 { feature: "duress_vault", name: "Duress vault", desc: "Decoy under coercion" },2259 ];22602261 const [licenseKey, setLicenseKey] = createSignal<string | null>(null);2262 const [showKey, setShowKey] = createSignal(false);22632264 const [deactivating, setDeactivating] = createSignal(false);22652266 async function handleDeactivateLicense() {2267 setDeactivating(true);2268 await sendMessage({ type: "DEACTIVATE_LICENSE" });2269 setLicenseInfo(null);2270 await props.onLicenseChange?.();2271 setDeactivating(false);2272 }22732274 async function handleTestCanary(entry: {id: string; title: string; canary: Canary}) {2275 setCanaryTestingId(entry.id);2276 setCanaryTestResult(null);2277 const res = await sendMessage({2278 type: "FIRE_CANARY_PING",2279 payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger: "test" },2280 }) as { ok: boolean };2281 setCanaryTestResult({ id: entry.id, ok: res.ok });2282 setCanaryTestingId(null);2283 setTimeout(() => setCanaryTestResult((r) => r?.id === entry.id ? null : r), 3000);2284 }22852286 function renderLicense() {2287 async function onActivate() {2288 await handleActivateLicense();2289 if ((licenseInfo()?.valid ?? false) && licenseInfo()?.plan === "pro") {2290 await props.onLicenseChange?.();2291 }2292 }22932294 return (2295 <div class="peach-settings-body">2296 <Show2297 when={isPro()}2298 fallback={2299 <div class="peach-pro-upgrade">2300 <p class="peach-pro-eyebrow">PEACH PRO · ONE-TIME</p>23012302 <h2 class="peach-pro-headline">2303 Every<em>thing,</em><br />every<em>where.</em>2304 </h2>23052306 <p class="peach-pro-subtitle">2307 One key unlocks every Pro feature — for the lifetime of the app.2308 </p>23092310 <div class="peach-pro-grid">2311 <For each={PRO_FEATURES}>2312 {(item, i) => (2313 <div2314 class="peach-pro-grid-item"2315 classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}2316 >2317 <span class="peach-pro-grid-num">{String(i() + 1).padStart(2, "0")}</span>2318 <span class="peach-pro-grid-name">{item.name}</span>2319 <span class="peach-pro-grid-desc">{item.desc}</span>2320 </div>2321 )}2322 </For>2323 </div>23242325 <p class="peach-pro-section-label">ADVANCED SECURITY</p>2326 <div class="peach-pro-grid">2327 <For each={PRO_SECURITY_FEATURES}>2328 {(item, i) => (2329 <div2330 class="peach-pro-grid-item"2331 classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}2332 >2333 <span class="peach-pro-grid-num">{String(PRO_FEATURES.length + i() + 1).padStart(2, "0")}</span>2334 <span class="peach-pro-grid-name">{item.name}</span>2335 <span class="peach-pro-grid-desc">{item.desc}</span>2336 </div>2337 )}2338 </For>2339 </div>23402341 <div class="peach-pro-upgrade-actions">2342 <p class="peach-pro-pricing">2343 $39 <span class="peach-pro-pricing-sub">once · yours forever</span>2344 </p>2345 <a2346 href="https://buy.polar.sh/polar_cl_IXSFIPojx67rcOTksn8LkH5mnU3osPHtYqBIl1eyuIz"2347 target="_blank"2348 rel="noopener noreferrer"2349 class="peach-pro-upgrade-cta"2350 >2351 Get Peach Pro2352 </a>2353 <div class="peach-pro-key-divider"><span>ALREADY HAVE A KEY</span></div>2354 <div class="peach-pro-key-inline">2355 <input2356 class="peach-pro-key-inline-input"2357 type="text"2358 placeholder="PEACH-XXXX-XXXX-XXXX"2359 value={licenseKeyInput()}2360 onInput={(e) => setLicenseKeyInput(e.currentTarget.value)}2361 onKeyDown={(e) => { if (e.key === "Enter") void onActivate(); }}2362 autocomplete="off"2363 spellcheck={false}2364 />2365 <button2366 class="peach-pro-key-apply"2367 onClick={() => void onActivate()}2368 disabled={activatingLicense() || !licenseKeyInput().trim()}2369 >2370 {activatingLicense() ? "…" : "APPLY"}2371 </button>2372 </div>2373 <Show when={licenseError()}>2374 <p class="peach-pro-key-error">{licenseError()}</p>2375 </Show>2376 </div>2377 </div>2378 }2379 >2380 <div class="peach-pro-active">2381 <div class="peach-pro-active-hero">2382 <button2383 class="peach-pro-active-mark-wrap"2384 type="button"2385 aria-label="Celebrate"2386 onClick={handleProCelebrationClick}2387 >2388 <PeachMark class="peach-pro-active-mark" />2389 <Show when={justActivated()}>2390 <div class="peach-pro-sparks" aria-hidden="true">2391 <span class="peach-pro-spark" style="--spark-x:0px;--spark-y:-54px;--spark-delay:0ms">✦</span>2392 <span class="peach-pro-spark" style="--spark-x:38px;--spark-y:-38px;--spark-delay:50ms">✦</span>2393 <span class="peach-pro-spark" style="--spark-x:54px;--spark-y:0px;--spark-delay:110ms">·</span>2394 <span class="peach-pro-spark" style="--spark-x:38px;--spark-y:38px;--spark-delay:30ms">✦</span>2395 <span class="peach-pro-spark" style="--spark-x:0px;--spark-y:54px;--spark-delay:80ms">·</span>2396 <span class="peach-pro-spark" style="--spark-x:-38px;--spark-y:38px;--spark-delay:130ms">✦</span>2397 <span class="peach-pro-spark" style="--spark-x:-54px;--spark-y:0px;--spark-delay:55ms">·</span>2398 <span class="peach-pro-spark" style="--spark-x:-38px;--spark-y:-38px;--spark-delay:95ms">✦</span>2399 </div>2400 </Show>2401 </button>2402 <h2 class="peach-pro-active-title">Peach Pro</h2>2403 <p class="peach-pro-active-since">{formatActivatedSince(licenseInfo()?.issued)}</p>2404 <Show when={justActivated()}>2405 <p class="peach-pro-thankyou-text" aria-live="polite">thank you ✦</p>2406 </Show>2407 </div>24082409 <Show when={licenseKey()}>2410 <div class="peach-settings-license">2411 <span class="peach-settings-label">LICENSE KEY</span>2412 <div class="peach-settings-license-row">2413 <Show2414 when={showKey()}2415 fallback={2416 <span class="peach-settings-license-key peach-settings-license-key--masked" aria-label="License key hidden">2417 ••••••••••••••••••••••••••••2418 </span>2419 }2420 >2421 <span class="peach-settings-license-key">{licenseKey()!}</span>2422 </Show>2423 <div class="peach-settings-license-actions">2424 <button2425 class="peach-settings-license-copy"2426 title="Copy license key"2427 onClick={() => copyLicenseKey(licenseKey()!)}2428 >2429 {copied() ? "✓" : "copy"}2430 </button>2431 <button2432 class="peach-settings-license-toggle"2433 aria-label={showKey() ? "Hide license key" : "Reveal license key"}2434 title={showKey() ? "Hide" : "Reveal"}2435 onClick={() => setShowKey(v => !v)}2436 >2437 <Show2438 when={showKey()}2439 fallback={2440 <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">2441 <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />2442 <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />2443 </svg>2444 }2445 >2446 <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">2447 <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />2448 <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />2449 <line x1="2" y1="2" x2="12" y2="12" />2450 </svg>2451 </Show>2452 </button>2453 </div>2454 </div>2455 </div>2456 </Show>24572458 <div class="peach-pro-active-features">2459 <For each={[...PRO_FEATURES, ...PRO_SECURITY_FEATURES]}>2460 {(item) => (2461 <div class="peach-pro-active-feature">2462 <span class="peach-pro-active-feature-mark">✦</span>2463 <span class="peach-pro-active-feature-name">{item.name}</span>2464 </div>2465 )}2466 </For>2467 </div>24682469 <button2470 class="peach-pro-deactivate"2471 onClick={() => void handleDeactivateLicense()}2472 disabled={deactivating()}2473 >2474 {deactivating() ? "Deactivating…" : "Deactivate"}2475 </button>2476 </div>2477 </Show>2478 </div>2479 );2480 }24812482 function renderFeedback() {2483 return (2484 <div class="peach-settings-body">2485 <div class="peach-settings-group peach-feedback-group">2486 <Show2487 when={feedbackState() === 'sent'}2488 fallback={2489 <>2490 <p class="peach-feedback-intro">2491 Help us improve Peach. Every report goes directly to our inbox.2492 </p>24932494 <p class="peach-settings-sub-label">TYPE</p>2495 <div class="peach-feedback-chips">2496 <For each={FEEDBACK_CATEGORIES}>2497 {(cat) => {2498 const selected = () => fbCategory() === cat;2499 return (2500 <button2501 type="button"2502 class={`peach-feedback-chip ${selected() ? "is-active" : ""}`}2503 aria-pressed={selected()}2504 onClick={() => setFbCategory(cat)}2505 >2506 {cat}2507 </button>2508 );2509 }}2510 </For>2511 </div>25122513 <p class="peach-settings-sub-label">MESSAGE</p>2514 <textarea2515 class="peach-feedback-textarea"2516 placeholder="Describe the issue or idea..."2517 maxLength={MAX_FEEDBACK_MESSAGE_LENGTH}2518 value={fbMessage()}2519 onInput={(e) => setFbMessage(e.currentTarget.value)}2520 />25212522 <p class="peach-feedback-meta">2523 Version {version} · will be included automatically2524 </p>25252526 <button2527 type="button"2528 class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"2529 disabled={!fbMessage().trim() || feedbackState() === 'sending'}2530 onClick={() => void handleSubmitFeedback()}2531 >2532 <Switch>2533 <Match when={feedbackState() === 'idle'}>Send</Match>2534 <Match when={feedbackState() === 'sending'}>Sending...</Match>2535 <Match when={feedbackState() === 'error'}>Failed - try again</Match>2536 </Switch>2537 </button>25382539 <p class="peach-feedback-anon">2540 Sent anonymously · no account required2541 </p>2542 </>2543 }2544 >2545 <div class="peach-feedback-thanks" role="status" aria-live="polite">2546 <p class="peach-feedback-thanks-title">Thank you</p>2547 <p>2548 Your note is in the queue. Someone will process it soon.2549 </p>2550 </div>2551 </Show>2552 </div>2553 </div>2554 );2555 }25562557 const bodies: StackBodies = {2558 protection: renderSecurity(),2559 vault: renderVault(),2560 sync: renderSync(),2561 appearance: renderAppearance(),2562 shortcuts: renderShortcuts(),2563 pro: renderLicense(),2564 feedback: renderFeedback(),2565 };25662567 return (2568 <Show2569 when={showWhatsNew()}2570 fallback={2571 <Show2572 when={showNewRecoveryWords()}2573 fallback={2574 <div2575 class="peach-overlay-view peach-settings-overlay"2576 classList={{ "ov-enter-right": !props.isExiting, "ov-exit-right": !!props.isExiting }}2577 style="display:flex;flex-direction:column"2578 >2579 <StackDeck2580 active={activeCard()}2581 onActiveChange={setActiveCard}2582 onBack={props.onBack}2583 cards={cards()}2584 bodies={bodies}2585 version={version}2586 isFanning={isFanning()}2587 celebratingCardId={justActivated() ? "pro" : null}2588 isPro={isPro()}2589 />2590 </div>2591 }2592 >2593 <RecoveryWordDisplayView onDone={() => setShowNewRecoveryWords(false)} />2594 </Show>2595 }2596 >2597 <div class="peach-overlay-view peach-settings-overlay">2598 <WhatsNewView mode="history" onContinue={() => setShowWhatsNew(false)} />2599 </div>2600 </Show>2601 );2602}