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 type { Canary } from "../../shared/types";9import { ACCENT_COLORS } from "../../shared/types";10import type {11 AccentColorName,12 PasskeyProviderState,13 ProFeature,14 VaultSnapshot,15} from "../../shared/types";16import type { PairedDevice } from "../../shared/sync-types";17import {18 describeSyncOutcome,19 syncIndicator,20 syncIndicatorLabel,21 type SyncOutcomeSummary,22} from "../../shared/sync-outcome";23import {24 DEFAULT_BINDINGS,25 findConflict,26 formatKeybinding,27 keybindingFromEvent,28 SHORTCUT_DESCRIPTORS,29 type Keybinding,30 type ShortcutAction,31} from "../../shared/shortcuts";32import { WEB3FORMS_KEY } from "../../shared/config";3334interface LicenseInfo {35 valid: boolean;36 plan: "free" | "pro";37 issued?: string;38}3940interface SettingsProps {41 entriesCount: number;42 breachMonitoring: boolean;43 lockOnClose: boolean;44 autoLockMinutes: number;45 trashRetentionDays: number;46 theme: "dark" | "light";47 accentColor: AccentColorName;48 textSize: "small" | "medium" | "large";49 reduceMotion: boolean;50 isPro?: boolean;51 initialSection?: string | null;52 upgradeHighlight?: ProFeature | null;53 onBreachMonitoringChange: (enabled: boolean) => void | Promise<void>;54 onLockOnCloseChange: (enabled: boolean) => void | Promise<void>;55 onAutoLockMinutesChange: (minutes: number) => void | Promise<void>;56 onTrashRetentionChange: (days: number) => void | Promise<void>;57 onThemeChange: (theme: "dark" | "light") => void;58 onAccentChange: (name: AccentColorName) => void | Promise<void>;59 onTextSizeChange: (size: "small" | "medium" | "large") => void | Promise<void>;60 onReduceMotionChange: (enabled: boolean) => void | Promise<void>;61 shortcuts: Record<ShortcutAction, Keybinding>;62 onShortcutsChange: (active: Record<ShortcutAction, Keybinding>) => void | Promise<void>;63 onLicenseChange?: () => void | Promise<void>;64 isExiting?: boolean;65 onBack: () => void;66 onImport: () => void;67 onExport: () => void;68 onBreachCheckDone: () => void;69 onOpenHealth?: () => void;70 onOpenSyncPairing?: (method?: "code") => void;71 onOpenFileImport?: () => void;72 onOpenPanic?: () => void;73 onOpenCodexGenerate?: () => void;74 onOpenCodexRestore?: () => void;75 767778 onProGate?: (feature: ProFeature) => void;79 onCardChange?: (id: CardId) => void;80}8182const FEEDBACK_CATEGORIES = ["Bug report", "Feature request", "Question", "Other"];83const MAX_FEEDBACK_MESSAGE_LENGTH = 4000;84const PRO_CELEBRATION_CARD_STEP_MS = 150;85const PRO_CELEBRATION_THANK_YOU_MS = 3200;8687function sanitizeFeedbackMessage(value: string): string {88 return value89 .trim()90 .slice(0, MAX_FEEDBACK_MESSAGE_LENGTH)91 92 .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "")93 .replace(/&/g, "&")94 .replace(/</g, "<")95 .replace(/>/g, ">")96 .replace(/"/g, """)97 .replace(/'/g, "'");98}99100function formatActivatedSince(issued: string | undefined): string {101 if (!issued) return "Active";102 try {103 const d = new Date(issued);104 if (Number.isNaN(d.getTime())) return "Active";105 return `Active since ${d.toLocaleDateString(undefined, { month: "long", year: "numeric" })}`;106 } catch {107 return "Active";108 }109}110111function initialSectionToCard(value: string | null | undefined): CardId | null {112 if (!value) return null;113 if (value === "security") return "protection";114 if (value === "license") return "pro";115 if (116 value === "protection" ||117 value === "vault" ||118 value === "sync" ||119 value === "appearance" ||120 value === "shortcuts" ||121 value === "pro" ||122 value === "feedback"123 ) {124 return value;125 }126 return null;127}128129export function SettingsView(props: SettingsProps) {130 const manifest = chrome.runtime.getManifest();131 const version = manifest.version;132 const initial = props.initialSection;133 const [activeCard, setActiveCard] = createSignal<CardId>(134 props.upgradeHighlight ? "pro" : initialSectionToCard(initial) ?? "pro"135 );136137 createEffect(() => { props.onCardChange?.(activeCard()); });138139 const [justActivated, setJustActivated] = createSignal(false);140 const [isFanning, setIsFanning] = createSignal(false);141142 function triggerCelebration() {143 if (isFanning()) return;144 setJustActivated(false);145 setIsFanning(true);146 setActiveCard("pro");147 const fanOrder: CardId[] = ["protection", "vault", "sync", "appearance", "shortcuts", "feedback"];148 fanOrder.forEach((id, i) => {149 window.setTimeout(() => setActiveCard(id), (i + 1) * PRO_CELEBRATION_CARD_STEP_MS);150 });151 window.setTimeout(() => {152 setActiveCard("pro");153 setIsFanning(false);154 setJustActivated(true);155 window.setTimeout(() => setJustActivated(false), PRO_CELEBRATION_THANK_YOU_MS);156 }, (fanOrder.length + 1) * PRO_CELEBRATION_CARD_STEP_MS);157 }158159 function handleProCelebrationClick(e: MouseEvent) {160 e.stopPropagation();161 triggerCelebration();162 }163164 const [checkingBreaches, setCheckingBreaches] = createSignal(false);165 const [breachResult, setBreachResult] = createSignal<string | null>(null);166 const [licenseInfo, setLicenseInfo] = createSignal<LicenseInfo | null>(null);167 const [licenseKeyInput, setLicenseKeyInput] = createSignal("");168 const [licenseError, setLicenseError] = createSignal<string | null>(null);169 const [, setLicenseSuccess] = createSignal<string | null>(null);170 const [activatingLicense, setActivatingLicense] = createSignal(false);171172 const isPro = () => props.isPro ?? false;173 const cards = createMemo<StackCardSpec[]>(() => [174 {175 id: "pro",176 num: "I · FOREVER",177 title: "Peach Pro",178 lede: isPro() ? "active · paid once, yours forever" : "$39 once · yours forever",179 bgVar: "--stack-pro-bg",180 fgVar: "--stack-pro-fg",181 icon: "spark",182 onIconClick: isPro() ? triggerCelebration : undefined,183 },184 {185 id: "protection",186 num: "II · PROTECT",187 title: "Protection",188 lede: `${props.breachMonitoring ? "breaches watched" : "breach watch off"} · ${props.lockOnClose ? "locks on close" : "manual lock"}`,189 bgVar: "--stack-protect-bg",190 fgVar: "--stack-protect-fg",191 icon: "shield",192 },193 {194 id: "vault",195 num: "III · KEEP",196 title: "Vault",197 lede: `${props.entriesCount} ${props.entriesCount === 1 ? "entry" : "entries"} · backups, history, cleanup`,198 bgVar: "--stack-vault-bg",199 fgVar: "--stack-vault-fg",200 icon: "lock",201 },202 {203 id: "sync",204 num: "IV · ECHO",205 title: "Sync",206 lede: isPro() ? "your devices, one vault" : "Pro · encrypted device sync",207 bgVar: "--stack-sync-bg",208 fgVar: "--stack-sync-fg",209 icon: "sync",210 },211 {212 id: "appearance",213 num: "V · DRESS",214 title: "Appearance",215 lede: `${props.theme} · ${props.accentColor} · ${props.textSize}`,216 bgVar: "--stack-appear-bg",217 fgVar: "--stack-appear-fg",218 icon: "sun",219 },220 {221 id: "shortcuts",222 num: "VI · KEYS",223 title: "Shortcuts",224 lede: `${SHORTCUT_DESCRIPTORS.length} bindings · what your fingers know`,225 bgVar: "--stack-shortcut-bg",226 fgVar: "--stack-shortcut-fg",227 icon: "keys",228 },229 {230 id: "feedback",231 num: "VII · ECHO",232 title: "Feedback",233 lede: `version ${version} · tell us what would make it better`,234 bgVar: "--stack-feedback-bg",235 fgVar: "--stack-feedback-fg",236 icon: "bubble",237 },238 ]);239 const [passkeyState, setPasskeyState] = createSignal<PasskeyProviderState | null>(null);240 const [passkeySaving, setPasskeySaving] = createSignal(false);241242 243 244 interface PendingVouched { deviceId: string; deviceName: string }245 const [pendingVouched, setPendingVouched] = createSignal<PendingVouched[]>([]);246 const [vouchedBusy, setVouchedBusy] = createSignal<string | null>(null);247 248 const [pairedDevices, setPairedDevices] = createSignal<PairedDevice[]>([]);249 const [confirmUnpair, setConfirmUnpair] = createSignal<string | null>(null);250 const [renameDeviceId, setRenameDeviceId] = createSignal<string | null>(null);251 const [renameDraft, setRenameDraft] = createSignal("");252 const [syncStatus, setSyncStatus] = createSignal<"idle" | "syncing" | "error">("idle");253 const [syncFlash, setSyncFlash] = createSignal<string | null>(null);254 255 256 const [syncSummary, setSyncSummary] = createSignal<SyncOutcomeSummary>({});257 const syncLight = () =>258 syncStatus() === "syncing"259 ? "busy"260 : syncIndicator(syncSummary(), pairedDevices().length);261 const syncLightLabel = () => syncIndicatorLabel(syncLight(), syncSummary());262 const lastSyncLine = () => {263 const s = syncSummary();264 if (!s.outcome) return "No sync attempted yet";265 return `${syncRelativeTime(s.finishedAt)} · ${describeSyncOutcome(s)}`;266 };267 async function loadSyncState() {268 const r = (await sendMessage({ type: "GET_SYNC_STATE" })) as {269 ok: boolean;270 data?: SyncOutcomeSummary;271 };272 if (r.ok && r.data) {273 setSyncSummary(r.data);274 setSyncStatus(r.data.status ?? "idle");275 }276 }277278 const STALE_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000;279 function syncRelativeTime(when: number | undefined): string {280 if (!when) return "Not synced yet";281 const elapsed = Date.now() - when;282 if (elapsed < 60_000) return "Just now";283 const minutes = Math.floor(elapsed / 60_000);284 if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"} ago`;285 const hours = Math.floor(minutes / 60);286 if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;287 const days = Math.floor(hours / 24);288 return days < 7 ? `${days} day${days === 1 ? "" : "s"} ago` : "Not seen recently";289 }290 function isSyncStale(d: PairedDevice): boolean {291 return d.lastSyncedAt !== undefined && Date.now() - d.lastSyncedAt > STALE_THRESHOLD_MS;292 }293 function deviceTypeLabel(t: PairedDevice["deviceType"]): string {294 switch (t) {295 case "android": return "Android";296 case "ios": return "iPhone";297 case "extension": return "Browser";298 default: return "Device";299 }300 }301302 async function loadPairedDevices() {303 if (!isPro()) return;304 const r = (await sendMessage({ type: "LIST_PAIRED_DEVICES" })) as {305 ok: boolean;306 data?: { devices: PairedDevice[] };307 };308 if (r.ok) setPairedDevices(r.data?.devices ?? []);309 }310 async function unpairDevice(deviceId: string) {311 await sendMessage({ type: "UNPAIR_DEVICE", payload: { deviceId } });312 setConfirmUnpair(null);313 await loadPairedDevices();314 }315 async function renameDevice(deviceId: string) {316 const name = renameDraft().trim();317 if (!name) return;318 const result = (await sendMessage({319 type: "RENAME_PAIRED_DEVICE",320 payload: { deviceId, name },321 })) as { ok: boolean };322 if (!result.ok) return;323 setRenameDeviceId(null);324 await loadPairedDevices();325 }326 async function syncNow() {327 setSyncStatus("syncing");328 setSyncFlash(null);329 const res = (await sendMessage({330 type: "TRIGGER_SYNC",331 payload: { reason: "manual", force: true },332 })) as { ok: boolean; error?: string };333 if (!res.ok) {334 setSyncStatus("error");335 setSyncSummary({ status: "error", outcome: "run_failed", detail: res.error });336 return;337 }338 339 340 341 let s: SyncOutcomeSummary = { status: "syncing" };342 for (let i = 0; i < 40 && s.status === "syncing"; i++) {343 await new Promise((r) => setTimeout(r, 250));344 const state = (await sendMessage({ type: "GET_SYNC_STATE" })) as {345 ok: boolean;346 data?: SyncOutcomeSummary;347 };348 s = state.ok ? state.data ?? { status: "idle" } : { status: "error", outcome: "run_failed" };349 }350 setSyncSummary(s);351 setSyncStatus(s.status ?? "idle");352 353 354 setSyncFlash(describeSyncOutcome(s));355 setTimeout(() => setSyncFlash(null), 4000);356 await loadPairedDevices();357 }358 359 const [rotateConfirmOpen, setRotateConfirmOpen] = createSignal(false);360 const [rotating, setRotating] = createSignal(false);361 const [rotateError, setRotateError] = createSignal<string | null>(null);362 const [showSyncConnection, setShowSyncConnection] = createSignal(false);363 const [showSyncRecovery, setShowSyncRecovery] = createSignal(false);364365 async function loadPendingVouched() {366 const r = (await sendMessage({ type: "GET_PENDING_VOUCHED_DEVICES" })) as {367 ok: boolean;368 data?: PendingVouched[];369 };370 if (r.ok) setPendingVouched(r.data ?? []);371 }372 async function confirmVouched(deviceId: string) {373 setVouchedBusy(deviceId);374 try {375 await sendMessage({ type: "CONFIRM_VOUCHED_DEVICE", payload: { deviceId } });376 await loadPendingVouched();377 } finally {378 setVouchedBusy(null);379 }380 }381 async function rejectVouched(deviceId: string) {382 setVouchedBusy(deviceId);383 try {384 await sendMessage({ type: "REJECT_VOUCHED_DEVICE", payload: { deviceId } });385 await loadPendingVouched();386 } finally {387 setVouchedBusy(null);388 }389 }390 async function doRotateSyncIdentity() {391 setRotateError(null);392 setRotating(true);393 try {394 const r = (await sendMessage({ type: "ROTATE_SYNC_IDENTITY" })) as {395 ok: boolean;396 error?: string;397 };398 if (!r.ok) {399 setRotateError(r.error ?? "Rotation failed.");400 return;401 }402 setRotateConfirmOpen(false);403 404 405 setShowNewRecoveryWords(true);406 } finally {407 setRotating(false);408 }409 }410411 const [dedupCount, setDedupCount] = createSignal<number | null>(null);412 const [dedupRunning, setDedupRunning] = createSignal(false);413 const [dedupDone, setDedupDone] = createSignal<number | null>(null);414415 const [declinedCardCount, setDeclinedCardCount] = createSignal(0);416417 418 419 420 const [historySnapshots, setHistorySnapshots] = createSignal<VaultSnapshot[]>([]);421 const [historyLoading, setHistoryLoading] = createSignal(false);422 const [historyRestoring, setHistoryRestoring] = createSignal<string | null>(null);423 const [historyShowAll, setHistoryShowAll] = createSignal(false);424425 async function loadVaultHistory() {426 setHistoryLoading(true);427 try {428 const res = await sendMessage({ type: "GET_VAULT_HISTORY" });429 const list = (res as { ok: boolean; data?: VaultSnapshot[] }).data ?? [];430 setHistorySnapshots(list);431 } finally {432 setHistoryLoading(false);433 }434 }435436 async function handleRestoreVaultSnapshot(id: string) {437 if (historyRestoring()) return;438 setHistoryRestoring(id);439 const res = await sendMessage({440 type: "RESTORE_VAULT_SNAPSHOT",441 payload: { snapshotId: id },442 });443 setHistoryRestoring(null);444 if ((res as { ok: boolean }).ok) {445 446 447 props.onBack();448 }449 }450451 function formatSnapshotTime(savedAt: number): string {452 const diff = Date.now() - savedAt;453 if (diff < 60_000) return "Just now";454 if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;455 const d = new Date(savedAt);456 const isToday = new Date().toDateString() === d.toDateString();457 const isYest =458 new Date(Date.now() - 86_400_000).toDateString() === d.toDateString();459 const t = d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });460 if (isToday) return `Today ${t}`;461 if (isYest) return `Yesterday ${t}`;462 return `${d.toLocaleDateString([], { month: "short", day: "numeric" })} ${t}`;463 }464465 function reasonLabel(reason: VaultSnapshot["reason"]): string {466 return (467 {468 local_save: "saved",469 sync_received: "synced",470 import: "imported",471 restore: "restored",472 } as const473 )[reason] ?? reason;474 }475476 const [showVaultAdvanced, setShowVaultAdvanced] = createSignal(false);477478 const [showChangePw, setShowChangePw] = createSignal(false);479 const [changePwCurrent, setChangePwCurrent] = createSignal("");480 const [changePwNew, setChangePwNew] = createSignal("");481 const [changePwConfirm, setChangePwConfirm] = createSignal("");482 const [changePwSaving, setChangePwSaving] = createSignal(false);483 const [changePwError, setChangePwError] = createSignal<string | null>(null);484 const [changePwSuccess] = createSignal(false);485 const [showNewRecoveryWords, setShowNewRecoveryWords] = createSignal(false);486487 const [pinEnabled, setPinEnabled] = createSignal(false);488 const [pinFlow, setPinFlow] = createSignal<"setup" | "disable" | null>(null);489 const [pinCurrentPassword, setPinCurrentPassword] = createSignal("");490 const [pinValue, setPinValue] = createSignal("");491 const [pinConfirm, setPinConfirm] = createSignal("");492 const [pinSaving, setPinSaving] = createSignal(false);493 const [pinError, setPinError] = createSignal<string | null>(null);494495 496 const [showViewWords, setShowViewWords] = createSignal(false);497 const [viewWordsPassword, setViewWordsPassword] = createSignal("");498 const [viewWordsError, setViewWordsError] = createSignal<string | null>(null);499 const [viewedWords, setViewedWords] = createSignal<string[] | null>(null);500 const [viewWordsBusy, setViewWordsBusy] = createSignal(false);501502 async function handleViewRecoveryWords() {503 setViewWordsError(null);504 if (!viewWordsPassword()) {505 setViewWordsError("Enter your master password.");506 return;507 }508 setViewWordsBusy(true);509 try {510 const r = (await sendMessage({511 type: "GET_RECOVERY_WORDS",512 payload: { password: viewWordsPassword() },513 })) as { ok: boolean; data?: string[]; error?: string };514 if (r.ok) {515 setViewedWords(r.data ?? []);516 setViewWordsPassword("");517 return;518 }519 switch (r.error) {520 case "wrong_password":521 setViewWordsError("Incorrect password");522 break;523 case "password_required":524 setViewWordsError("Enter your master password.");525 break;526 case "recovery_not_setup":527 setViewWordsError("Recovery words aren't set up for this vault yet.");528 break;529 default:530 setViewWordsError("Couldn't retrieve recovery words.");531 }532 } finally {533 setViewWordsBusy(false);534 }535 }536 function closeViewWords() {537 setShowViewWords(false);538 setViewWordsPassword("");539 setViewWordsError(null);540 setViewedWords(null);541 }542543 const [duressEnabled, setDuressEnabled] = createSignal(false);544 const [showDuressSetup, setShowDuressSetup] = createSignal(false);545 const [duressPassword, setDuressPassword] = createSignal("");546 const [duressConfirm, setDuressConfirm] = createSignal("");547 const [duressMainPassword, setDuressMainPassword] = createSignal("");548 const [duressSaving, setDuressSaving] = createSignal(false);549 const [duressError, setDuressError] = createSignal<string | null>(null);550 const [duressSuccess, setDuressSuccess] = createSignal<string | null>(null);551552 const [canaryEntries, setCanaryEntries] = createSignal<Array<{id: string; title: string; canary: Canary}>>([]);553554 const [canaryTestingId, setCanaryTestingId] = createSignal<string | null>(null);555 const [canaryTestResult, setCanaryTestResult] = createSignal<{id: string; ok: boolean} | null>(null);556557 const [fbCategory, setFbCategory] = createSignal("Bug report");558 const [fbMessage, setFbMessage] = createSignal("");559 type FeedbackState = 'idle' | 'sending' | 'sent' | 'error';560 const [feedbackState, setFeedbackState] = createSignal<FeedbackState>('idle');561562 createEffect(() => {563 if (props.upgradeHighlight) {564 setActiveCard("pro");565 }566 });567568 onMount(async () => {569 const [licenseRes, passkeyRes, duressRes, settingsRes, keyRes] = await Promise.all([570 sendMessage({ type: "GET_LICENSE" }),571 sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }),572 sendMessage({ type: "IS_DURESS_ENABLED" }),573 sendMessage({ type: "GET_SETTINGS" }),574 sendMessage({ type: "GET_LICENSE_KEY" }),575 ]);576577 578 579 void loadVaultHistory();580581 582 583 void loadPendingVouched();584 void loadPairedDevices();585 void loadSyncState();586 const onBroadcast = (msg: unknown) => {587 const m = msg as { type?: string; payload?: unknown } | null;588 if (m?.type === "VOUCHED_DEVICE_PENDING") {589 void loadPendingVouched();590 }591 if (m?.type === "SYNC_STATE_CHANGED") {592 const payload = (m.payload ?? {}) as SyncOutcomeSummary;593 setSyncSummary(payload);594 setSyncStatus(payload.status ?? "idle");595 }596 };597 chrome.runtime?.onMessage?.addListener(onBroadcast);598 onCleanup(() => {599 try { chrome.runtime?.onMessage?.removeListener(onBroadcast); } catch { }600 });601602 if ((licenseRes as { ok: boolean }).ok) {603 setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null);604 }605606 if ((passkeyRes as { ok: boolean }).ok) {607 setPasskeyState(608 (passkeyRes as { ok: boolean; data?: PasskeyProviderState }).data ?? null609 );610 }611612 if ((duressRes as { ok: boolean }).ok) {613 setDuressEnabled((duressRes as { ok: boolean; data?: boolean }).data ?? false);614 }615616 if ((settingsRes as { ok: boolean }).ok) {617 const sData = (settingsRes as { ok: boolean; data?: { declinedCardFingerprints?: string[]; pinEnabled?: boolean } }).data;618 setDeclinedCardCount(sData?.declinedCardFingerprints?.length ?? 0);619 setPinEnabled(sData?.pinEnabled === true);620 }621622 if ((keyRes as { ok: boolean }).ok) {623 setLicenseKey((keyRes as { ok: boolean; data?: string }).data ?? null);624 }625626 const entriesRes = await sendMessage({ type: "GET_ENTRIES" });627 if ((entriesRes as { ok: boolean }).ok) {628 const entries = (entriesRes as { ok: boolean; data?: Array<{id: string; title: string; canary?: Canary}> }).data ?? [];629 setCanaryEntries(630 entries631 .filter((e) => e.canary?.webhookUrl)632 .map((e) => ({ id: e.id, title: e.title, canary: e.canary! }))633 );634 }635636 });637638 function resetPinFlow() {639 setPinFlow(null);640 setPinCurrentPassword("");641 setPinValue("");642 setPinConfirm("");643 setPinError(null);644 }645646 async function handleSetupPin() {647 setPinError(null);648 if (!pinCurrentPassword()) { setPinError("enter your current password"); return; }649 if (!/^\d{4,12}$/.test(pinValue())) { setPinError("PIN must be 4–12 digits"); return; }650 if (pinValue() !== pinConfirm()) { setPinError("PINs do not match"); return; }651 setPinSaving(true);652 const res = await sendMessage({653 type: "SETUP_PIN",654 payload: { currentPassword: pinCurrentPassword(), pin: pinValue() },655 }) as { ok: boolean; error?: string };656 if (res.ok) {657 setPinEnabled(true);658 resetPinFlow();659 } else {660 setPinError(res.error === "incorrect_current_password" ? "incorrect current password" : "couldn't set up PIN");661 }662 setPinSaving(false);663 }664665 async function handleDisablePin() {666 setPinError(null);667 if (!pinCurrentPassword()) { setPinError("enter your current password"); return; }668 setPinSaving(true);669 const res = await sendMessage({670 type: "DISABLE_PIN",671 payload: { currentPassword: pinCurrentPassword() },672 }) as { ok: boolean; error?: string };673 if (res.ok) {674 setPinEnabled(false);675 resetPinFlow();676 } else {677 setPinError(res.error === "incorrect_current_password" ? "incorrect current password" : "couldn't remove PIN");678 }679 setPinSaving(false);680 }681682 async function handleChangePassword() {683 setChangePwError(null);684 if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; }685 if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; }686 if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; }687 if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; }688 setChangePwSaving(true);689 const res = await sendMessage({690 type: "CHANGE_PASSWORD",691 payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() },692 }) as { ok: boolean; error?: string };693 if (res.ok) {694 setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");695 setShowChangePw(false);696 697 698 699 await sendMessage({ type: "SET_RECOVERY_CONFIRM_PENDING" });700 setShowNewRecoveryWords(true);701 } else {702 setChangePwError(res.error ?? "failed to change password");703 }704 setChangePwSaving(false);705 }706707 const [copied, setCopied] = createSignal(false);708709 async function copyLicenseKey(key: string) {710 await navigator.clipboard.writeText(key);711 setCopied(true);712 setTimeout(() => setCopied(false), 2000);713 }714715 async function handleActivateLicense() {716 const key = licenseKeyInput().trim();717 if (!key) return;718 setActivatingLicense(true);719 setLicenseError(null);720 setLicenseSuccess(null);721 const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } });722 setActivatingLicense(false);723 if ((res as { ok: boolean }).ok) {724 const data = (res as { ok: boolean; data?: LicenseInfo }).data;725 if (data?.valid) {726 setLicenseInfo(data);727 setLicenseKey(key);728 setLicenseSuccess("License activated!");729 setLicenseKeyInput("");730 } else {731 setLicenseError("Invalid license key");732 }733 } else {734 setLicenseError("Activation failed");735 }736 }737738 async function handleTogglePasskeys() {739 const current = passkeyState();740 if (!current || !current.supported) return;741 setPasskeySaving(true);742 const res = await sendMessage({743 type: "SET_PASSKEY_PROVIDER_ENABLED",744 payload: { enabled: !current.enabled },745 });746 setPasskeySaving(false);747 if ((res as { ok: boolean }).ok) {748 setPasskeyState(749 (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null750 );751 }752 }753754 async function handleCheckAllBreaches() {755 setCheckingBreaches(true);756 setBreachResult(null);757 const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });758 setCheckingBreaches(false);759 if ((res as { ok: boolean }).ok) {760 const data = (res as { ok: boolean; data?: Record<string, number> }).data ?? {};761 const breached = Object.values(data).filter((count) => count > 0).length;762 setBreachResult(763 breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean"764 );765 props.onBreachCheckDone();766 }767 }768769770 async function handleSetupDuress() {771 const mainPw = duressMainPassword().trim();772 const duressPw = duressPassword().trim();773 const confirmPw = duressConfirm().trim();774775 if (!mainPw) { setDuressError("enter your main password to confirm"); return; }776 if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; }777 if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; }778 if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; }779780 setDuressSaving(true);781 setDuressError(null);782783 const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };784 if (!verifyRes.ok || !verifyRes.data) {785 setDuressError("incorrect main password");786 setDuressSaving(false);787 return;788 }789790 const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string };791 setDuressSaving(false);792 if (res.ok) {793 setDuressEnabled(true);794 setShowDuressSetup(false);795 setDuressPassword("");796 setDuressConfirm("");797 setDuressMainPassword("");798 setDuressSuccess("Duress password set. If entered, a decoy vault will be shown.");799 setTimeout(() => setDuressSuccess(null), 5000);800 } else {801 setDuressError(802 res.error === "session_expired"803 ? "Vault session expired — lock and unlock Peach, then try again"804 : res.error ?? "failed to set duress password"805 );806 }807 }808809 async function handleClearDeclinedCards() {810 await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } });811 setDeclinedCardCount(0);812 }813814 async function handleCountDuplicates() {815 setDedupRunning(true);816 setDedupDone(null);817 const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number };818 setDedupRunning(false);819 if (res.ok) setDedupCount(res.data ?? 0);820 }821822 async function handleDeduplicate() {823 setDedupRunning(true);824 const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number };825 setDedupRunning(false);826 if (res.ok) {827 setDedupDone(res.data ?? 0);828 setDedupCount(0);829 }830 }831832 async function handleDisableDuress() {833 const mainPw = duressMainPassword().trim();834 if (!mainPw) { setDuressError("enter your main password to confirm"); return; }835836 setDuressSaving(true);837 setDuressError(null);838839 const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };840 if (!verifyRes.ok || !verifyRes.data) {841 setDuressError("incorrect main password");842 setDuressSaving(false);843 return;844 }845846 const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string };847 setDuressSaving(false);848 if (res.ok) {849 setDuressEnabled(false);850 setShowDuressSetup(false);851 setDuressMainPassword("");852 setDuressSuccess("Duress password disabled.");853 setTimeout(() => setDuressSuccess(null), 4000);854 } else {855 setDuressError(res.error ?? "failed to disable duress password");856 }857 }858859 async function handleSubmitFeedback() {860 const message = sanitizeFeedbackMessage(fbMessage());861 if (!message) return;862863 setFeedbackState('sending');864865 try {866 const category = FEEDBACK_CATEGORIES.includes(fbCategory())867 ? fbCategory()868 : FEEDBACK_CATEGORIES[0];869 const res = await fetch('https://api.web3forms.com/submit', {870 method: 'POST',871 headers: { 'Content-Type': 'application/json' },872 body: JSON.stringify({873 access_key: WEB3FORMS_KEY,874 subject: `Peach Feedback - ${category}`,875 message,876 from_name: 'Peach User',877 878 botcheck: '',879 }),880 });881882 if (!res.ok) throw new Error(`HTTP ${res.status}`);883 const data = await res.json() as { success: boolean; message?: string };884 if (!data.success) throw new Error(data.message ?? 'unknown error');885886 setFeedbackState('sent');887 setFbMessage('');888 setFbCategory(FEEDBACK_CATEGORIES[0]);889 } catch (err) {890 console.error('[feedback]', err);891 setFeedbackState('error');892 setTimeout(() => setFeedbackState('idle'), 4000);893 }894 }895896 function renderSecurity() {897 return (898 <div class="peach-settings-body">899 <div class="peach-settings-group">900 <p class="peach-settings-sub-label">LOCKING</p>901 <div class="peach-settings-row">902 <span>Lock on close</span>903 <button904 class={`peach-toggle ${props.lockOnClose ? "is-on" : ""}`}905 onClick={() => void props.onLockOnCloseChange(!props.lockOnClose)}906 aria-label="Toggle lock on close"907 >908 <span class="peach-toggle-knob"></span>909 </button>910 </div>911 <div class="peach-settings-row">912 <span>Auto-lock after</span>913 <select914 class="peach-settings-select"915 value={String(props.autoLockMinutes)}916 onChange={(e) => void props.onAutoLockMinutesChange(Number(e.currentTarget.value))}917 >918 <option value="1">1 minute</option>919 <option value="5">5 minutes</option>920 <option value="15">15 minutes</option>921 <option value="30">30 minutes</option>922 <option value="0">Never</option>923 </select>924 </div>925926 <p class="peach-settings-sub-label">QUICK UNLOCK</p>927 <div class="peach-settings-row peach-settings-row--meta">928 <span>PIN unlock</span>929 <button930 class="peach-btn peach-btn--subtle peach-btn--sm"931 onClick={() => { setPinFlow(pinEnabled() ? "disable" : "setup"); setPinError(null); }}932 >933 {pinEnabled() ? "remove" : "set up"}934 </button>935 </div>936 <Show when={pinFlow() === "setup"}>937 <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">938 <input939 class="peach-settings-input"940 type="password"941 placeholder="current password"942 value={pinCurrentPassword()}943 onInput={(e) => setPinCurrentPassword((e.target as HTMLInputElement).value)}944 autocomplete="current-password"945 />946 <input947 class="peach-settings-input"948 type="password"949 inputmode="numeric"950 maxlength="12"951 placeholder="new 4–12 digit PIN"952 value={pinValue()}953 onInput={(e) => setPinValue((e.target as HTMLInputElement).value.replace(/\D/g, "").slice(0, 12))}954 autocomplete="off"955 />956 <input957 class="peach-settings-input"958 type="password"959 inputmode="numeric"960 maxlength="12"961 placeholder="confirm PIN"962 value={pinConfirm()}963 onInput={(e) => setPinConfirm((e.target as HTMLInputElement).value.replace(/\D/g, "").slice(0, 12))}964 autocomplete="off"965 onKeyDown={(e) => { if (e.key === "Enter") void handleSetupPin(); }}966 />967 <Show when={pinError()}>968 <p class="peach-hint peach-hint-error" style="margin:0">{pinError()}</p>969 </Show>970 <button971 class="peach-btn peach-btn--accent peach-btn--sm"972 style="align-self:flex-start"973 onClick={() => void handleSetupPin()}974 disabled={pinSaving() || !pinCurrentPassword() || !/^\d{4,12}$/.test(pinValue()) || !/^\d{4,12}$/.test(pinConfirm())}975 >976 {pinSaving() ? "saving…" : "save PIN"}977 </button>978 </div>979 </Show>980 <Show when={pinFlow() === "disable"}>981 <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">982 <input983 class="peach-settings-input"984 type="password"985 placeholder="current password"986 value={pinCurrentPassword()}987 onInput={(e) => setPinCurrentPassword((e.target as HTMLInputElement).value)}988 autocomplete="current-password"989 onKeyDown={(e) => { if (e.key === "Enter") void handleDisablePin(); }}990 />991 <Show when={pinError()}>992 <p class="peach-hint peach-hint-error" style="margin:0">{pinError()}</p>993 </Show>994 <button995 class="peach-btn peach-btn--subtle peach-btn--sm"996 style="align-self:flex-start"997 onClick={() => void handleDisablePin()}998 disabled={pinSaving() || !pinCurrentPassword()}999 >1000 {pinSaving() ? "removing…" : "remove PIN"}1001 </button>1002 </div>1003 </Show>10041005 <p class="peach-settings-sub-label">BREACH PROTECTION</p>1006 <div class="peach-settings-row">1007 <span>Breach monitoring</span>1008 <button1009 class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}1010 onClick={() => void props.onBreachMonitoringChange(!props.breachMonitoring)}1011 aria-label="Toggle breach monitoring"1012 >1013 <span class="peach-toggle-knob"></span>1014 </button>1015 </div>1016 <div class="peach-settings-row peach-settings-row--meta">1017 <span>Check all passwords</span>1018 <button1019 class="peach-btn peach-btn--subtle peach-btn--sm"1020 onClick={() => void handleCheckAllBreaches()}1021 disabled={checkingBreaches()}1022 >1023 {checkingBreaches() ? "checking..." : "run"}1024 </button>1025 </div>1026 <Show when={breachResult()}>1027 <div class="peach-settings-row peach-settings-row--meta">1028 <span>Last check</span>1029 <span class="peach-settings-value">{breachResult()}</span>1030 </div>1031 </Show>10321033 <p class="peach-settings-sub-label">MASTER PASSWORD</p>1034 <div class="peach-settings-row peach-settings-row--meta">1035 <span>Master password</span>1036 <button1037 class="peach-btn peach-btn--subtle peach-btn--sm"1038 onClick={() => { setShowChangePw((v) => !v); setChangePwError(null); }}1039 >1040 {showChangePw() ? "cancel" : "change"}1041 </button>1042 </div>1043 <Show when={showChangePw()}>1044 <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1045 <input1046 class="peach-settings-input"1047 type="password"1048 placeholder="current password"1049 value={changePwCurrent()}1050 onInput={(e) => setChangePwCurrent((e.target as HTMLInputElement).value)}1051 autocomplete="current-password"1052 />1053 <input1054 class="peach-settings-input"1055 type="password"1056 placeholder="new password (min 12 chars)"1057 value={changePwNew()}1058 onInput={(e) => setChangePwNew((e.target as HTMLInputElement).value)}1059 autocomplete="new-password"1060 />1061 <input1062 class="peach-settings-input"1063 type="password"1064 placeholder="confirm new password"1065 value={changePwConfirm()}1066 onInput={(e) => setChangePwConfirm((e.target as HTMLInputElement).value)}1067 autocomplete="new-password"1068 onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }}1069 />1070 <Show when={changePwError()}>1071 <p class="peach-hint peach-hint-error" style="margin:0">{changePwError()}</p>1072 </Show>1073 <Show when={changePwSuccess()}>1074 <p class="peach-hint" style="margin:0;color:var(--peach-accent)">password changed ✓</p>1075 </Show>1076 <button1077 class="peach-btn peach-btn--accent peach-btn--sm"1078 style="align-self:flex-start"1079 onClick={() => void handleChangePassword()}1080 disabled={changePwSaving() || !changePwCurrent() || !changePwNew() || !changePwConfirm()}1081 >1082 {changePwSaving() ? "saving…" : "save new password"}1083 </button>1084 </div>1085 </Show>10861087 {}1088 <div class="peach-settings-row peach-settings-row--meta">1089 <span>Recovery words</span>1090 <button1091 class="peach-btn peach-btn--subtle peach-btn--sm"1092 onClick={() => {1093 if (showViewWords()) { closeViewWords(); } else { setShowViewWords(true); }1094 }}1095 >1096 {showViewWords() ? "cancel" : "view"}1097 </button>1098 </div>1099 <Show when={showViewWords()}>1100 <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1101 <Show1102 when={viewedWords()}1103 fallback={1104 <>1105 <p class="peach-hint" style="margin:0;color:var(--peach-t2)">1106 Enter your master password to reveal your recovery words.1107 </p>1108 <input1109 class="peach-settings-input"1110 type="password"1111 placeholder="master password"1112 value={viewWordsPassword()}1113 onInput={(e) => setViewWordsPassword((e.target as HTMLInputElement).value)}1114 autocomplete="current-password"1115 onKeyDown={(e) => { if (e.key === "Enter") void handleViewRecoveryWords(); }}1116 />1117 <Show when={viewWordsError()}>1118 <p class="peach-hint peach-hint-error" style="margin:0">{viewWordsError()}</p>1119 </Show>1120 <button1121 class="peach-btn peach-btn--accent peach-btn--sm"1122 style="align-self:flex-start"1123 onClick={() => void handleViewRecoveryWords()}1124 disabled={viewWordsBusy() || !viewWordsPassword()}1125 >1126 {viewWordsBusy() ? "checking…" : "reveal recovery words"}1127 </button>1128 </>1129 }1130 >1131 <div class="peach-recovery-words-grid">1132 <For each={viewedWords()!}>1133 {(word, i) => (1134 <span class="peach-recovery-word"><b>{i() + 1}</b> {word}</span>1135 )}1136 </For>1137 </div>1138 <button1139 class="peach-btn peach-btn--subtle peach-btn--sm"1140 style="align-self:flex-start"1141 onClick={closeViewWords}1142 >done</button>1143 </Show>1144 </div>1145 </Show>11461147 <p class="peach-settings-sub-label">1148 DURESS PASSWORD1149 <Show when={!isPro()}>1150 <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>1151 </Show>1152 </p>1153 <div class="peach-settings-row peach-settings-row--top">1154 <div class="peach-settings-row-main">1155 <span>1156 Duress password1157 <Show when={!isPro()}>1158 <span class="peach-pro-badge">Pro</span>1159 </Show>1160 </span>1161 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">1162 If entered under coercion, shows a decoy empty vault instead of your real one.1163 </span>1164 </div>1165 <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>1166 {duressEnabled() ? "on" : "off"}1167 </div>1168 </div>11691170 <Show when={duressEnabled() && !showDuressSetup()}>1171 <div class="peach-settings-row peach-settings-row--meta">1172 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1173 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.1174 </span>1175 </div>1176 </Show>11771178 <Show when={duressSuccess()}>1179 <div class="peach-settings-row peach-settings-row--meta">1180 <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>1181 </div>1182 </Show>11831184 <Show when={!showDuressSetup()}>1185 <div class="peach-settings-row peach-settings-row--meta">1186 <button1187 class="peach-btn peach-btn--subtle peach-btn--sm"1188 onClick={() => {1189 1190 1191 1192 1193 1194 if (!isPro() && !duressEnabled()) {1195 props.onProGate?.("duress_vault");1196 setActiveCard("pro");1197 return;1198 }1199 setShowDuressSetup(true);1200 setDuressError(null);1201 setDuressSuccess(null);1202 setDuressPassword("");1203 setDuressConfirm("");1204 setDuressMainPassword("");1205 }}1206 >1207 {duressEnabled() ? "Change / disable" : "Set up"}1208 </button>1209 </div>1210 </Show>12111212 <Show when={showDuressSetup()}>1213 <div class="peach-duress-form">1214 <label class="peach-label">main password (to confirm)</label>1215 <input1216 class="peach-settings-input"1217 type="password"1218 placeholder="your current master password"1219 autocomplete="current-password"1220 value={duressMainPassword()}1221 onInput={(e) => setDuressMainPassword(e.currentTarget.value)}1222 />1223 <Show when={!duressEnabled()}>1224 <label class="peach-label">new duress password</label>1225 <input1226 class="peach-settings-input"1227 type="password"1228 placeholder="duress password (min 8 chars)"1229 autocomplete="new-password"1230 value={duressPassword()}1231 onInput={(e) => setDuressPassword(e.currentTarget.value)}1232 />1233 <label class="peach-label">confirm duress password</label>1234 <input1235 class="peach-settings-input"1236 type="password"1237 placeholder="confirm duress password"1238 autocomplete="new-password"1239 value={duressConfirm()}1240 onInput={(e) => setDuressConfirm(e.currentTarget.value)}1241 />1242 </Show>12431244 <Show when={duressError()}>1245 <p class="peach-hint peach-hint-error">{duressError()}</p>1246 </Show>12471248 <div class="peach-duress-actions">1249 <button1250 class="peach-btn peach-btn--subtle peach-btn--sm"1251 onClick={() => { setShowDuressSetup(false); setDuressError(null); }}1252 >1253 cancel1254 </button>1255 <Show when={duressEnabled()}>1256 <button1257 class="peach-btn peach-btn--ghost peach-btn--sm"1258 style="color:var(--peach-breach)"1259 onClick={() => void handleDisableDuress()}1260 disabled={duressSaving()}1261 >1262 {duressSaving() ? "disabling..." : "disable duress"}1263 </button>1264 </Show>1265 <Show when={!duressEnabled()}>1266 <button1267 class="peach-btn peach-btn--accent peach-btn--sm"1268 onClick={() => void handleSetupDuress()}1269 disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}1270 >1271 {duressSaving() ? "saving..." : "save"}1272 </button>1273 </Show>1274 </div>1275 </div>1276 </Show>12771278 <p class="peach-settings-sub-label">1279 CANARY ENTRIES1280 <Show when={!isPro()}>1281 <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>1282 </Show>1283 </p>1284 <div class="peach-settings-row peach-settings-row--top">1285 <div class="peach-settings-row-main">1286 <span>1287 Canary entries1288 <Show when={!isPro()}>1289 <span class="peach-pro-badge">Pro</span>1290 </Show>1291 </span>1292 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1293 Silently pings a webhook when an entry is accessed. Configure canary on any entry in your vault.1294 </span>1295 </div>1296 <span class="peach-settings-value" style="flex-shrink:0">1297 {canaryEntries().length > 0 ? `${canaryEntries().length} active` : "none"}1298 </span>1299 </div>1300 <Show when={canaryEntries().length > 0}>1301 <For each={canaryEntries()}>1302 {(entry) => (1303 <div class="peach-settings-row peach-settings-row--meta">1304 <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0">{entry.title}</span>1305 <button1306 class="peach-btn peach-btn--subtle peach-btn--sm"1307 onClick={() => void handleTestCanary(entry)}1308 disabled={canaryTestingId() === entry.id}1309 style="flex-shrink:0"1310 >1311 {canaryTestingId() === entry.id1312 ? "sending…"1313 : canaryTestResult()?.id === entry.id1314 ? (canaryTestResult()!.ok ? "sent ✓" : "failed ✗")1315 : "test"}1316 </button>1317 </div>1318 )}1319 </For>1320 </Show>13211322 <p class="peach-settings-sub-label">PASSKEYS</p>1323 <div class="peach-settings-row peach-settings-row--top">1324 <div class="peach-settings-row-main">1325 <span>Intercept passkey requests</span>1326 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">1327 Peach can handle passkey sign-in and creation on supported Chromium browsers.1328 </span>1329 </div>1330 <button1331 class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}1332 onClick={() => void handleTogglePasskeys()}1333 aria-label="Toggle passkey interception"1334 disabled={!passkeyState()?.supported || passkeySaving()}1335 >1336 <span class="peach-toggle-knob"></span>1337 </button>1338 </div>1339 <div class="peach-settings-row peach-settings-row--meta">1340 <span>Status</span>1341 <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>1342 </div>1343 <div class="peach-settings-row peach-settings-row--meta">1344 <span>Browser</span>1345 <span class="peach-settings-value">1346 {passkeyState()?.browserLabel ?? "Unknown"}1347 {passkeyState()?.supported ? " ✓" : " (interception unavailable)"}1348 </span>1349 </div>1350 <div class="peach-info-notice" style="margin-top:8px">1351 Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.1352 </div>1353 <Show when={passkeyState()?.conflict}>1354 <div class="peach-settings-row peach-settings-row--meta">1355 <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>1356 </div>1357 </Show>1358 </div>1359 </div>1360 );1361 }13621363 function renderVault() {1364 return (1365 <div class="peach-settings-body">1366 <div class="peach-settings-group">1367 <p class="peach-settings-sub-label">ENTRIES</p>1368 <div class="peach-settings-row peach-settings-row--meta">1369 <span>Entries</span>1370 <span class="peach-settings-value">{props.entriesCount}</span>1371 </div>1372 <div class="peach-settings-row peach-settings-row--meta">1373 <span>Duplicates</span>1374 <Show when={dedupDone() !== null} fallback={1375 <button1376 class="peach-btn peach-btn--subtle peach-btn--sm"1377 onClick={() => void handleCountDuplicates()}1378 disabled={dedupRunning()}1379 >1380 {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}1381 </button>1382 }>1383 <span class="peach-settings-value" style="color:var(--peach-sync-ok)">1384 {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}1385 </span>1386 </Show>1387 </div>1388 <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>1389 <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">1390 <div class="peach-restore-warning">1391 {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.1392 </div>1393 <div style="display:flex;gap:6px">1394 <button1395 class="peach-btn peach-btn--subtle peach-btn--sm"1396 onClick={() => setDedupCount(null)}1397 >1398 cancel1399 </button>1400 <button1401 class="peach-btn peach-btn--accent peach-btn--sm"1402 onClick={() => void handleDeduplicate()}1403 disabled={dedupRunning()}1404 >1405 {dedupRunning() ? "removing..." : "remove duplicates"}1406 </button>1407 </div>1408 </div>1409 </Show>14101411 <p class="peach-settings-sub-label">MAINTENANCE</p>1412 <div class="peach-settings-row peach-settings-row--meta">1413 <span>Trash auto-delete</span>1414 <select1415 class="peach-settings-select"1416 value={String(props.trashRetentionDays)}1417 onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}1418 >1419 <option value="7">7 days</option>1420 <option value="30">30 days</option>1421 <option value="90">90 days</option>1422 <option value="0">Never</option>1423 </select>1424 </div>1425 <div class="peach-settings-row peach-settings-row--meta">1426 <span>Advanced Vault Health</span>1427 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>1428 open1429 </button>1430 </div>1431 <Show when={declinedCardCount() > 0}>1432 <div class="peach-settings-row peach-settings-row--meta">1433 <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>1434 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>1435 Clear1436 </button>1437 </div>1438 </Show>14391440 <p class="peach-settings-sub-label">DATA</p>1441 <div class="peach-settings-row peach-settings-row--meta">1442 <span>Import passwords</span>1443 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>1444 open1445 </button>1446 </div>1447 <div class="peach-settings-row peach-settings-row--meta">1448 <span>Export backup</span>1449 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onExport}>1450 open1451 </button>1452 </div>1453 <div class="peach-settings-row peach-settings-row--meta">1454 <span>1455 Peach Codex1456 <Show when={!isPro()}>1457 <span class="peach-pro-badge">Pro</span>1458 </Show>1459 </span>1460 <button1461 class="peach-btn peach-btn--subtle peach-btn--sm"1462 onClick={() => props.onOpenCodexGenerate?.()}1463 >1464 open1465 </button>1466 </div>1467 {1468}1469 <div class="peach-settings-row peach-settings-row--meta">1470 <span>Restore from Codex</span>1471 <button1472 class="peach-btn peach-btn--subtle peach-btn--sm"1473 onClick={() => props.onOpenCodexRestore?.()}1474 >1475 open1476 </button>1477 </div>14781479 <span class="peach-settings-sub-label">VAULT HISTORY</span>1480 <Show when={historyLoading()}>1481 <p class="peach-settings-hint">Loading…</p>1482 </Show>1483 <Show when={!historyLoading() && historySnapshots().length === 0}>1484 <p class="peach-settings-hint">1485 No snapshots yet. Peach saves automatically before every change —1486 saves, syncs, and imports.1487 </p>1488 </Show>1489 <Show when={!historyLoading() && historySnapshots().length > 0}>1490 <p class="peach-settings-hint">1491 Restoring locks your vault. Unlock with the password that1492 encrypted that snapshot to confirm.1493 </p>1494 <div class="peach-history-list">1495 <For each={historySnapshots().slice(0, historyShowAll() ? undefined : 3)}>1496 {(snap) => (1497 <div class="peach-history-row">1498 <div class="peach-history-meta">1499 <span class="peach-history-time">1500 {formatSnapshotTime(snap.savedAt)}1501 </span>1502 <span class="peach-history-sub">1503 {[1504 snap.entryCount !== null1505 ? `up to ${snap.entryCount} ${snap.entryCount === 1 ? "entry" : "entries"}`1506 : null,1507 reasonLabel(snap.reason),1508 ]1509 .filter(Boolean)1510 .join(" · ")}1511 </span>1512 </div>1513 <button1514 class={`peach-btn peach-btn--subtle peach-btn--sm peach-history-btn${1515 historyRestoring() === snap.id ? " is-busy" : ""1516 }`}1517 onClick={() => void handleRestoreVaultSnapshot(snap.id)}1518 disabled={historyRestoring() !== null}1519 >1520 <Show when={historyRestoring() === snap.id} fallback="Restore">1521 …1522 </Show>1523 </button>1524 </div>1525 )}1526 </For>1527 </div>1528 <Show when={historySnapshots().length > 3}>1529 <button1530 class="peach-settings-advanced-toggle"1531 onClick={() => setHistoryShowAll((v) => !v)}1532 aria-expanded={historyShowAll()}1533 >1534 <span class="peach-settings-hint" style="margin:0">1535 {historyShowAll() ? "show less" : `${historySnapshots().length - 3} older`}1536 </span>1537 <span class="peach-settings-advanced-chevron">1538 {historyShowAll() ? "▲" : "▼"}1539 </span>1540 </button>1541 </Show>1542 <p class="peach-history-footer">1543 {historySnapshots().length} of 10 snapshots1544 </p>1545 </Show>15461547 <button1548 class="peach-settings-advanced-toggle"1549 onClick={() => setShowVaultAdvanced((v) => !v)}1550 aria-expanded={showVaultAdvanced()}1551 >1552 <span class="peach-settings-sub-label" style="margin:0">ADVANCED</span>1553 <span class="peach-settings-advanced-chevron">1554 {showVaultAdvanced() ? "▲" : "▼"}1555 </span>1556 </button>15571558 <Show when={showVaultAdvanced()}>1559 <div class="peach-danger-card">1560 <div class="peach-danger-card-head">1561 <span class="peach-danger-card-glyph" aria-hidden="true">⟳</span>1562 <h3 class="peach-danger-card-title">Replace vault from backup</h3>1563 </div>1564 <p class="peach-danger-card-copy">1565 Wipes the current vault and restores from a backup file.1566 Recoverable only if your backup is good.1567 </p>1568 <button1569 class="peach-danger-card-button"1570 onClick={() => props.onOpenFileImport?.()}1571 >1572 Choose backup file1573 </button>1574 </div>15751576 <Show when={props.onOpenPanic}>1577 <div class="peach-danger-card peach-danger-card--hazard">1578 <div class="peach-danger-card-head">1579 <span class="peach-danger-card-glyph" aria-hidden="true">⚠</span>1580 <h3 class="peach-danger-card-title">Destroy vault</h3>1581 </div>1582 <p class="peach-danger-card-copy">1583 Permanently wipes every entry, setting, and license on this1584 device. There is no recovery.1585 </p>1586 <button1587 class="peach-danger-card-button"1588 onClick={() => props.onOpenPanic?.()}1589 >1590 Begin destruction1591 </button>1592 </div>1593 </Show>1594 </Show>1595 </div>1596 </div>1597 );1598 }15991600 function renderSync() {1601 return (1602 <div class="peach-settings-body">1603 <div class="peach-settings-group">1604 <p class="peach-settings-sub-label" style="margin-top:0">SYNC STATUS</p>1605 <div1606 class="peach-settings-row peach-settings-row--meta"1607 style="align-items:center;gap:8px"1608 role="status"1609 aria-label={syncLightLabel()}1610 title={syncLightLabel()}1611 >1612 <span class={`stk-pip stk-pip--sync is-${syncLight()}`} aria-hidden="true" />1613 <span class="peach-settings-value" style="color:var(--peach-t2);line-height:1.5;flex:1;min-width:0;overflow-wrap:anywhere">1614 {syncFlash() ?? lastSyncLine()}1615 </span>1616 </div>1617 <Show when={pairedDevices().length > 0}>1618 <div class="peach-settings-row peach-settings-row--meta">1619 <span class="peach-settings-value" style="color:var(--peach-t2)">1620 {(syncSummary().entriesSent ?? 0) + (syncSummary().entriesReceived ?? 0) > 01621 ? `${syncSummary().entriesSent ?? 0} sent · ${syncSummary().entriesReceived ?? 0} received`1622 : ""}1623 </span>1624 <button1625 class="peach-btn peach-btn--subtle peach-btn--sm"1626 aria-label="Sync this vault now"1627 disabled={syncStatus() === "syncing"}1628 onClick={() => void syncNow()}1629 >{syncStatus() === "syncing" ? "syncing…" : "sync now"}</button>1630 </div>1631 </Show>16321633 <p class="peach-settings-sub-label">ADD A DEVICE</p>1634 <button1635 class="peach-sync-pair-option"1636 onClick={() => props.onOpenSyncPairing?.("code")}1637 >1638 <span class="peach-sync-pair-option-icon" aria-hidden="true">1639 <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">1640 <rect x="6" y="2.5" width="8" height="15" rx="1.6" />1641 <path d="M9 15 L11 15" />1642 </svg>1643 </span>1644 <span class="peach-sync-pair-option-main">1645 <span class="peach-sync-pair-option-title">Show a code</span>1646 <span class="peach-sync-pair-option-sub">Scan it with Peach on your phone, then confirm one 8-digit code</span>1647 </span>1648 <span class="peach-sync-pair-option-chevron" aria-hidden="true">›</span>1649 </button>1650 <Show when={!isPro()}>1651 <p class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2);margin:8px 2px 0">1652 At least one device needs Peach Pro — pair with a Pro device and it carries over to here.1653 </p>1654 </Show>16551656 <Show when={pairedDevices().length > 0}>1657 <p class="peach-settings-sub-label">PAIRED DEVICES</p>1658 <For each={pairedDevices()}>1659 {(d) => (1660 <div class="peach-settings-row peach-settings-row--top">1661 <div class="peach-settings-row-main">1662 <Show1663 when={renameDeviceId() === d.deviceId}1664 fallback={<span>{d.localAlias ?? d.deviceName ?? "Unnamed device"}</span>}1665 >1666 <input1667 class="peach-settings-input"1668 aria-label={`Rename ${d.localAlias ?? d.deviceName ?? "device"}`}1669 maxlength={64}1670 value={renameDraft()}1671 onInput={(event) => setRenameDraft(event.currentTarget.value)}1672 onKeyDown={(event) => {1673 if (event.key === "Enter") void renameDevice(d.deviceId);1674 if (event.key === "Escape") setRenameDeviceId(null);1675 }}1676 />1677 </Show>1678 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1679 {deviceTypeLabel(d.deviceType)} · {syncRelativeTime(d.lastSyncedAt)}{isSyncStale(d) ? " · not seen recently" : ""}1680 </span>1681 </div>1682 <Show1683 when={confirmUnpair() === d.deviceId}1684 fallback={1685 <div style="display:flex;gap:4px;flex-shrink:0">1686 <Show1687 when={renameDeviceId() === d.deviceId}1688 fallback={1689 <button1690 class="peach-btn peach-btn--subtle peach-btn--sm"1691 onClick={() => {1692 setRenameDraft(d.localAlias ?? d.deviceName ?? "");1693 setRenameDeviceId(d.deviceId);1694 }}1695 >rename</button>1696 }1697 >1698 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => setRenameDeviceId(null)}>cancel</button>1699 <button class="peach-btn peach-btn--subtle peach-btn--sm" disabled={!renameDraft().trim()} onClick={() => void renameDevice(d.deviceId)}>save</button>1700 </Show>1701 <button1702 class="peach-btn peach-btn--subtle peach-btn--sm"1703 onClick={() => setConfirmUnpair(d.deviceId)}1704 >remove</button>1705 </div>1706 }1707 >1708 {17091710171117121713}1714 <div class="peach-unpair-confirm">1715 <p class="peach-unpair-confirm-note">1716 Stops this device receiving future changes. It doesn't1717 lock it out of what it already has — if it's lost or1718 stolen, also change your master password and generate1719 new recovery words.1720 </p>1721 <div style="display:flex;gap:6px;justify-content:flex-end">1722 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => setConfirmUnpair(null)}>cancel</button>1723 <button1724 class="peach-btn peach-btn--sm"1725 style="background:var(--peach-breach);border-color:var(--peach-breach)"1726 onClick={() => void unpairDevice(d.deviceId)}1727 >remove</button>1728 </div>1729 </div>1730 </Show>1731 </div>1732 )}1733 </For>1734 </Show>17351736 <Show when={pendingVouched().length > 0}>1737 <p class="peach-settings-sub-label">1738 PENDING DEVICES1739 <span class="peach-pro-badge" style="background:#C8A840;color:#1a1a1a">{pendingVouched().length}</span>1740 </p>1741 <For each={pendingVouched()}>1742 {(dev) => (1743 <div class="peach-settings-row peach-settings-row--top">1744 <div class="peach-settings-row-main">1745 <span>{dev.deviceName}</span>1746 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1747 Was added by another of your devices. Allow it to sync with this vault?1748 </span>1749 </div>1750 <div style="display:flex;gap:6px;flex-shrink:0">1751 <button1752 class="peach-btn peach-btn--sm"1753 disabled={vouchedBusy() === dev.deviceId}1754 onClick={() => void confirmVouched(dev.deviceId)}1755 >Allow</button>1756 <button1757 class="peach-btn peach-btn--subtle peach-btn--sm"1758 disabled={vouchedBusy() === dev.deviceId}1759 onClick={() => void rejectVouched(dev.deviceId)}1760 >Reject</button>1761 </div>1762 </div>1763 )}1764 </For>1765 </Show>17661767 <button1768 class="peach-settings-advanced-toggle peach-sync-disclosure-toggle"1769 onClick={() => setShowSyncConnection((value) => !value)}1770 aria-expanded={showSyncConnection()}1771 aria-controls="peach-sync-connection"1772 >1773 <span>1774 <span class="peach-settings-sub-label" style="margin:0">CONNECTION</span>1775 <span class="peach-sync-disclosure-summary">Relay and privacy choices</span>1776 </span>1777 <span class="peach-settings-advanced-chevron">1778 {showSyncConnection() ? "▲" : "▼"}1779 </span>1780 </button>1781 <Show when={showSyncConnection()}>1782 <div id="peach-sync-connection" class="peach-sync-disclosure-content">1783 <SyncModeSelector />1784 </div>1785 </Show>17861787 <button1788 class="peach-settings-advanced-toggle peach-sync-disclosure-toggle"1789 onClick={() => setShowSyncRecovery((value) => !value)}1790 aria-expanded={showSyncRecovery()}1791 aria-controls="peach-sync-recovery"1792 >1793 <span>1794 <span class="peach-settings-sub-label" style="margin:0">RECOVERY & SECURITY</span>1795 <span class="peach-sync-disclosure-summary">For a lost or stolen device</span>1796 </span>1797 <span class="peach-settings-advanced-chevron">1798 {showSyncRecovery() ? "▲" : "▼"}1799 </span>1800 </button>1801 <Show when={showSyncRecovery()}>1802 <div id="peach-sync-recovery" class="peach-sync-disclosure-content">1803 <Show1804 when={rotateConfirmOpen()}1805 fallback={1806 <div class="peach-settings-row peach-settings-row--top">1807 <div class="peach-settings-row-main">1808 <span>Rotate sync identity</span>1809 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1810 Disconnects every device and replaces the keys used for sync.1811 </span>1812 </div>1813 <button1814 class="peach-btn peach-btn--subtle peach-btn--sm"1815 style="color:var(--peach-breach);border-color:color-mix(in srgb,var(--peach-breach) 40%,transparent)"1816 onClick={() => { setRotateError(null); setRotateConfirmOpen(true); }}1817 >rotate</button>1818 </div>1819 }1820 >1821 <div class="peach-info-notice" style="margin-top:8px;border-color:color-mix(in srgb,var(--peach-breach) 40%,transparent)">1822 This disconnects every paired device and changes your 12 recovery words. You'll be shown the new words next.1823 <Show when={rotateError()}>1824 <div style="color:var(--peach-breach);margin-top:6px">{rotateError()}</div>1825 </Show>1826 <div style="display:flex;gap:6px;margin-top:10px">1827 <button1828 class="peach-btn peach-btn--subtle peach-btn--sm"1829 disabled={rotating()}1830 onClick={() => setRotateConfirmOpen(false)}1831 >Cancel</button>1832 <button1833 class="peach-btn peach-btn--sm"1834 style="background:var(--peach-breach);border-color:var(--peach-breach)"1835 disabled={rotating()}1836 onClick={() => void doRotateSyncIdentity()}1837 >{rotating() ? "Rotating…" : "Rotate sync identity"}</button>1838 </div>1839 </div>1840 </Show>1841 </div>1842 </Show>1843 </div>1844 </div>1845 );1846 }18471848 function renderAppearance() {1849 const sizeOptions: Array<{ id: "medium" | "large"; label: string }> = [1850 { id: "medium", label: "Default" },1851 { id: "large", label: "Large" },1852 ];1853 return (1854 <div class="peach-settings-body">1855 <div class="peach-settings-group">1856 <p class="peach-settings-sub-label">THEME</p>1857 <div class="peach-settings-row peach-settings-row--meta">1858 <span>Theme</span>1859 <button1860 class="peach-btn peach-btn--subtle peach-btn--sm"1861 onClick={() =>1862 props.onThemeChange(props.theme === "dark" ? "light" : "dark")1863 }1864 >1865 {props.theme === "dark" ? "Switch to light" : "Switch to dark"}1866 </button>1867 </div>18681869 <p class="peach-settings-sub-label">ACCENT</p>1870 <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">1871 <span>Accent</span>1872 <div class="peach-accent-picker">1873 <For each={ACCENT_COLORS}>1874 {(color) => (1875 <button1876 class="peach-accent-swatch peach-swatch"1877 classList={{ "is-active": props.accentColor === color.name }}1878 style={{ background: color.hex }}1879 onClick={() => void props.onAccentChange(color.name)}1880 aria-label={color.name}1881 title={color.name}1882 />1883 )}1884 </For>1885 </div>1886 </div>18871888 <p class="peach-settings-sub-label">TEXT SIZE</p>1889 <div class="peach-settings-row peach-settings-row--meta">1890 <span>Size</span>1891 <div class="peach-pill-group">1892 <For each={sizeOptions}>1893 {(opt) => (1894 <button1895 class="peach-btn peach-btn--ghost peach-btn--sm"1896 classList={{ "is-active": props.textSize === opt.id }}1897 onClick={() => void props.onTextSizeChange(opt.id)}1898 >1899 {opt.label}1900 </button>1901 )}1902 </For>1903 </div>1904 </div>19051906 <p class="peach-settings-sub-label">MOTION</p>1907 <div class="peach-settings-row peach-settings-row--meta">1908 <span>Reduce motion</span>1909 <button1910 class="peach-btn peach-btn--subtle peach-btn--sm"1911 onClick={() => void props.onReduceMotionChange(!props.reduceMotion)}1912 >1913 {props.reduceMotion ? "On" : "Off"}1914 </button>1915 </div>1916 </div>1917 </div>1918 );1919 }19201921 function renderShortcuts() {1922 const [capturingAction, setCapturingAction] = createSignal<ShortcutAction | null>(null);1923 const [conflictMsg, setConflictMsg] = createSignal<string | null>(null);19241925 function startCapture(action: ShortcutAction) {1926 setConflictMsg(null);1927 setCapturingAction(action);1928 }1929 function cancelCapture() {1930 setCapturingAction(null);1931 setConflictMsg(null);1932 }1933 function commitBinding(action: ShortcutAction, binding: Keybinding) {1934 const conflict = findConflict(props.shortcuts, binding, action);1935 if (conflict) {1936 const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict);1937 setConflictMsg(`Already bound to "${other?.label ?? conflict}"`);1938 return;1939 }1940 const next = { ...props.shortcuts, [action]: binding };1941 setCapturingAction(null);1942 setConflictMsg(null);1943 void props.onShortcutsChange(next);1944 }1945 function resetAction(action: ShortcutAction) {1946 const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] };1947 void props.onShortcutsChange(next);1948 }1949 function resetAll() {1950 void props.onShortcutsChange({ ...DEFAULT_BINDINGS });1951 }19521953 1954 1955 function handleCaptureKey(e: KeyboardEvent) {1956 e.preventDefault();1957 e.stopPropagation();1958 if (e.key === "Escape") { cancelCapture(); return; }1959 const binding = keybindingFromEvent(e);1960 if (!binding) return; 1961 const action = capturingAction();1962 if (action) commitBinding(action, binding);1963 }19641965 return (1966 <div class="peach-settings-body">1967 <div class="peach-settings-group peach-shortcuts-group">1968 <p class="peach-settings-sub-label">ENTRY VIEW</p>1969 <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry")}>1970 {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1971 </For>1972 </div>19731974 <div class="peach-settings-group peach-shortcuts-group">1975 <p class="peach-settings-sub-label">ANYWHERE IN POPUP</p>1976 <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global")}>1977 {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1978 </For>1979 </div>19801981 <Show when={conflictMsg()}>1982 <div class="peach-shortcuts-conflict">{conflictMsg()}</div>1983 </Show>19841985 <div class="peach-settings-group" style="display:flex;flex-direction:column;gap:8px">1986 <div class="peach-settings-row peach-settings-row--meta">1987 <span>Inline autofill on page</span>1988 <a1989 class="peach-settings-link"1990 href="chrome://extensions/shortcuts"1991 onClick={(e) => {1992 e.preventDefault();1993 chrome.tabs.create({ url: "chrome://extensions/shortcuts" });1994 }}1995 >1996 ⌘⇧P — customize ↗1997 </a>1998 </div>1999 <button2000 class="peach-btn peach-btn--subtle peach-btn--sm"2001 style="align-self:flex-start"2002 onClick={resetAll}2003 >2004 Reset all to defaults2005 </button>2006 </div>2007 </div>2008 );20092010 function renderRow(2011 desc: typeof SHORTCUT_DESCRIPTORS[number],2012 capturing: () => ShortcutAction | null,2013 onStart: (a: ShortcutAction) => void,2014 onCancel: () => void,2015 onReset: (a: ShortcutAction) => void,2016 onKey: (e: KeyboardEvent) => void,2017 ) {2018 const isActive = () => capturing() === desc.action;2019 const binding = () => props.shortcuts[desc.action];2020 const isCustom = () => {2021 const b = binding();2022 const d = DEFAULT_BINDINGS[desc.action];2023 return b.key !== d.key2024 || Boolean(b.ctrl) !== Boolean(d.ctrl)2025 || Boolean(b.meta) !== Boolean(d.meta)2026 || Boolean(b.shift) !== Boolean(d.shift)2027 || Boolean(b.alt) !== Boolean(d.alt);2028 };2029 return (2030 <div class="peach-shortcut-row">2031 <span class="peach-shortcut-row-label">{desc.label}</span>2032 <button2033 class="peach-shortcut-chip"2034 classList={{ "is-capturing": isActive(), "is-custom": isCustom() }}2035 onClick={() => isActive() ? onCancel() : onStart(desc.action)}2036 onKeyDown={(e) => { if (isActive()) onKey(e); }}2037 ref={(el) => {2038 2039 if (el) {2040 queueMicrotask(() => { if (isActive()) el.focus(); });2041 }2042 }}2043 >2044 {isActive() ? "Press a key…" : formatKeybinding(binding())}2045 </button>2046 <Show when={isCustom() && !isActive()}>2047 <button2048 class="peach-shortcut-reset"2049 title="Reset to default"2050 onClick={() => onReset(desc.action)}2051 >2052 ↺2053 </button>2054 </Show>2055 </div>2056 );2057 }2058 }20592060 const PRO_FEATURES: Array<{2061 feature: ProFeature;2062 name: string;2063 desc: string;2064 }> = [2065 { feature: "sync", name: "Peach Sync", desc: "Encrypted · peer-to-peer" },2066 { feature: "codex", name: "Peach Codex", desc: "Restore from paper" },2067 { feature: "passkeys", name: "Passkeys", desc: "Zero-knowledge WebAuthn" },2068 { feature: "advanced_health", name: "Advanced Vault Health", desc: "Reuse · age · exposure" },2069 ];20702071 const PRO_SECURITY_FEATURES: Array<{2072 feature: ProFeature;2073 name: string;2074 desc: string;2075 }> = [2076 { feature: "canary_entry", name: "Canary entries", desc: "Detects vault compromise" },2077 { feature: "duress_vault", name: "Duress vault", desc: "Decoy under coercion" },2078 ];20792080 const [licenseKey, setLicenseKey] = createSignal<string | null>(null);2081 const [showKey, setShowKey] = createSignal(false);20822083 const [deactivating, setDeactivating] = createSignal(false);20842085 async function handleDeactivateLicense() {2086 setDeactivating(true);2087 await sendMessage({ type: "DEACTIVATE_LICENSE" });2088 setLicenseInfo(null);2089 await props.onLicenseChange?.();2090 setDeactivating(false);2091 }20922093 async function handleTestCanary(entry: {id: string; title: string; canary: Canary}) {2094 setCanaryTestingId(entry.id);2095 setCanaryTestResult(null);2096 const res = await sendMessage({2097 type: "FIRE_CANARY_PING",2098 payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger: "test" },2099 }) as { ok: boolean };2100 setCanaryTestResult({ id: entry.id, ok: res.ok });2101 setCanaryTestingId(null);2102 setTimeout(() => setCanaryTestResult((r) => r?.id === entry.id ? null : r), 3000);2103 }21042105 function renderLicense() {2106 async function onActivate() {2107 await handleActivateLicense();2108 if ((licenseInfo()?.valid ?? false) && licenseInfo()?.plan === "pro") {2109 await props.onLicenseChange?.();2110 }2111 }21122113 return (2114 <div class="peach-settings-body">2115 <Show2116 when={isPro()}2117 fallback={2118 <div class="peach-pro-upgrade">2119 <p class="peach-pro-eyebrow">PEACH PRO · ONE-TIME</p>21202121 <h2 class="peach-pro-headline">2122 Every<em>thing,</em><br />every<em>where.</em>2123 </h2>21242125 <p class="peach-pro-subtitle">2126 One key unlocks every Pro feature — for the lifetime of the app.2127 </p>21282129 <div class="peach-pro-grid">2130 <For each={PRO_FEATURES}>2131 {(item, i) => (2132 <div2133 class="peach-pro-grid-item"2134 classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}2135 >2136 <span class="peach-pro-grid-num">{String(i() + 1).padStart(2, "0")}</span>2137 <span class="peach-pro-grid-name">{item.name}</span>2138 <span class="peach-pro-grid-desc">{item.desc}</span>2139 </div>2140 )}2141 </For>2142 </div>21432144 <p class="peach-pro-section-label">ADVANCED SECURITY</p>2145 <div class="peach-pro-grid">2146 <For each={PRO_SECURITY_FEATURES}>2147 {(item, i) => (2148 <div2149 class="peach-pro-grid-item"2150 classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}2151 >2152 <span class="peach-pro-grid-num">{String(PRO_FEATURES.length + i() + 1).padStart(2, "0")}</span>2153 <span class="peach-pro-grid-name">{item.name}</span>2154 <span class="peach-pro-grid-desc">{item.desc}</span>2155 </div>2156 )}2157 </For>2158 </div>21592160 <div class="peach-pro-upgrade-actions">2161 <p class="peach-pro-pricing">2162 $39 <span class="peach-pro-pricing-sub">once · yours forever</span>2163 </p>2164 <a2165 href="https://buy.polar.sh/polar_cl_IXSFIPojx67rcOTksn8LkH5mnU3osPHtYqBIl1eyuIz"2166 target="_blank"2167 rel="noopener noreferrer"2168 class="peach-pro-upgrade-cta"2169 >2170 Get Peach Pro2171 </a>2172 <div class="peach-pro-key-divider"><span>ALREADY HAVE A KEY</span></div>2173 <div class="peach-pro-key-inline">2174 <input2175 class="peach-pro-key-inline-input"2176 type="text"2177 placeholder="PEACH-XXXX-XXXX-XXXX"2178 value={licenseKeyInput()}2179 onInput={(e) => setLicenseKeyInput(e.currentTarget.value)}2180 onKeyDown={(e) => { if (e.key === "Enter") void onActivate(); }}2181 autocomplete="off"2182 spellcheck={false}2183 />2184 <button2185 class="peach-pro-key-apply"2186 onClick={() => void onActivate()}2187 disabled={activatingLicense() || !licenseKeyInput().trim()}2188 >2189 {activatingLicense() ? "…" : "APPLY"}2190 </button>2191 </div>2192 <Show when={licenseError()}>2193 <p class="peach-pro-key-error">{licenseError()}</p>2194 </Show>2195 </div>2196 </div>2197 }2198 >2199 <div class="peach-pro-active">2200 <div class="peach-pro-active-hero">2201 <button2202 class="peach-pro-active-mark-wrap"2203 type="button"2204 aria-label="Celebrate"2205 onClick={handleProCelebrationClick}2206 >2207 <PeachMark class="peach-pro-active-mark" />2208 <Show when={justActivated()}>2209 <div class="peach-pro-sparks" aria-hidden="true">2210 <span class="peach-pro-spark" style="--spark-x:0px;--spark-y:-54px;--spark-delay:0ms">✦</span>2211 <span class="peach-pro-spark" style="--spark-x:38px;--spark-y:-38px;--spark-delay:50ms">✦</span>2212 <span class="peach-pro-spark" style="--spark-x:54px;--spark-y:0px;--spark-delay:110ms">·</span>2213 <span class="peach-pro-spark" style="--spark-x:38px;--spark-y:38px;--spark-delay:30ms">✦</span>2214 <span class="peach-pro-spark" style="--spark-x:0px;--spark-y:54px;--spark-delay:80ms">·</span>2215 <span class="peach-pro-spark" style="--spark-x:-38px;--spark-y:38px;--spark-delay:130ms">✦</span>2216 <span class="peach-pro-spark" style="--spark-x:-54px;--spark-y:0px;--spark-delay:55ms">·</span>2217 <span class="peach-pro-spark" style="--spark-x:-38px;--spark-y:-38px;--spark-delay:95ms">✦</span>2218 </div>2219 </Show>2220 </button>2221 <h2 class="peach-pro-active-title">Peach Pro</h2>2222 <p class="peach-pro-active-since">{formatActivatedSince(licenseInfo()?.issued)}</p>2223 <Show when={justActivated()}>2224 <p class="peach-pro-thankyou-text" aria-live="polite">thank you ✦</p>2225 </Show>2226 </div>22272228 <Show when={licenseKey()}>2229 <div class="peach-settings-license">2230 <span class="peach-settings-label">LICENSE KEY</span>2231 <div class="peach-settings-license-row">2232 <Show2233 when={showKey()}2234 fallback={2235 <span class="peach-settings-license-key peach-settings-license-key--masked" aria-label="License key hidden">2236 ••••••••••••••••••••••••••••2237 </span>2238 }2239 >2240 <span class="peach-settings-license-key">{licenseKey()!}</span>2241 </Show>2242 <div class="peach-settings-license-actions">2243 <button2244 class="peach-settings-license-copy"2245 title="Copy license key"2246 onClick={() => copyLicenseKey(licenseKey()!)}2247 >2248 {copied() ? "✓" : "copy"}2249 </button>2250 <button2251 class="peach-settings-license-toggle"2252 aria-label={showKey() ? "Hide license key" : "Reveal license key"}2253 title={showKey() ? "Hide" : "Reveal"}2254 onClick={() => setShowKey(v => !v)}2255 >2256 <Show2257 when={showKey()}2258 fallback={2259 <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">2260 <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />2261 <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />2262 </svg>2263 }2264 >2265 <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">2266 <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />2267 <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />2268 <line x1="2" y1="2" x2="12" y2="12" />2269 </svg>2270 </Show>2271 </button>2272 </div>2273 </div>2274 </div>2275 </Show>22762277 <div class="peach-pro-active-features">2278 <For each={[...PRO_FEATURES, ...PRO_SECURITY_FEATURES]}>2279 {(item) => (2280 <div class="peach-pro-active-feature">2281 <span class="peach-pro-active-feature-mark">✦</span>2282 <span class="peach-pro-active-feature-name">{item.name}</span>2283 </div>2284 )}2285 </For>2286 </div>22872288 <button2289 class="peach-pro-deactivate"2290 onClick={() => void handleDeactivateLicense()}2291 disabled={deactivating()}2292 >2293 {deactivating() ? "Deactivating…" : "Deactivate"}2294 </button>2295 </div>2296 </Show>2297 </div>2298 );2299 }23002301 function renderFeedback() {2302 return (2303 <div class="peach-settings-body">2304 <div class="peach-settings-group peach-feedback-group">2305 <Show2306 when={feedbackState() === 'sent'}2307 fallback={2308 <>2309 <p class="peach-feedback-intro">2310 Help us improve Peach. Every report goes directly to our inbox.2311 </p>23122313 <p class="peach-settings-sub-label">TYPE</p>2314 <div class="peach-feedback-chips">2315 <For each={FEEDBACK_CATEGORIES}>2316 {(cat) => {2317 const selected = () => fbCategory() === cat;2318 return (2319 <button2320 type="button"2321 class={`peach-feedback-chip ${selected() ? "is-active" : ""}`}2322 aria-pressed={selected()}2323 onClick={() => setFbCategory(cat)}2324 >2325 {cat}2326 </button>2327 );2328 }}2329 </For>2330 </div>23312332 <p class="peach-settings-sub-label">MESSAGE</p>2333 <textarea2334 class="peach-feedback-textarea"2335 placeholder="Describe the issue or idea..."2336 maxLength={MAX_FEEDBACK_MESSAGE_LENGTH}2337 value={fbMessage()}2338 onInput={(e) => setFbMessage(e.currentTarget.value)}2339 />23402341 <p class="peach-feedback-meta">2342 Version {version} · will be included automatically2343 </p>23442345 <button2346 type="button"2347 class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"2348 disabled={!fbMessage().trim() || feedbackState() === 'sending'}2349 onClick={() => void handleSubmitFeedback()}2350 >2351 <Switch>2352 <Match when={feedbackState() === 'idle'}>Send</Match>2353 <Match when={feedbackState() === 'sending'}>Sending...</Match>2354 <Match when={feedbackState() === 'error'}>Failed - try again</Match>2355 </Switch>2356 </button>23572358 <p class="peach-feedback-anon">2359 Sent anonymously · no account required2360 </p>2361 </>2362 }2363 >2364 <div class="peach-feedback-thanks" role="status" aria-live="polite">2365 <p class="peach-feedback-thanks-title">Thank you</p>2366 <p>2367 Your note is in the queue. Someone will process it soon.2368 </p>2369 </div>2370 </Show>2371 </div>2372 </div>2373 );2374 }23752376 const bodies: StackBodies = {2377 protection: renderSecurity(),2378 vault: renderVault(),2379 sync: renderSync(),2380 appearance: renderAppearance(),2381 shortcuts: renderShortcuts(),2382 pro: renderLicense(),2383 feedback: renderFeedback(),2384 };23852386 return (2387 <Show2388 when={showNewRecoveryWords()}2389 fallback={2390 <div2391 class="peach-overlay-view peach-settings-overlay"2392 classList={{ "ov-enter-right": !props.isExiting, "ov-exit-right": !!props.isExiting }}2393 style="display:flex;flex-direction:column"2394 >2395 <StackDeck2396 active={activeCard()}2397 onActiveChange={setActiveCard}2398 onBack={props.onBack}2399 cards={cards()}2400 bodies={bodies}2401 version={version}2402 isFanning={isFanning()}2403 celebratingCardId={justActivated() ? "pro" : null}2404 isPro={isPro()}2405 />2406 </div>2407 }2408 >2409 <RecoveryWordDisplayView onDone={() => setShowNewRecoveryWords(false)} />2410 </Show>2411 );2412}