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 700 await chrome.storage?.local?.set?.({ peach_recovery_pending: "1" });701 setShowNewRecoveryWords(true);702 } else {703 setChangePwError(res.error ?? "failed to change password");704 }705 setChangePwSaving(false);706 }707708 const [copied, setCopied] = createSignal(false);709710 async function copyLicenseKey(key: string) {711 await navigator.clipboard.writeText(key);712 setCopied(true);713 setTimeout(() => setCopied(false), 2000);714 }715716 async function handleActivateLicense() {717 const key = licenseKeyInput().trim();718 if (!key) return;719 setActivatingLicense(true);720 setLicenseError(null);721 setLicenseSuccess(null);722 const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } });723 setActivatingLicense(false);724 if ((res as { ok: boolean }).ok) {725 const data = (res as { ok: boolean; data?: LicenseInfo }).data;726 if (data?.valid) {727 setLicenseInfo(data);728 setLicenseKey(key);729 setLicenseSuccess("License activated!");730 setLicenseKeyInput("");731 } else {732 setLicenseError("Invalid license key");733 }734 } else {735 setLicenseError("Activation failed");736 }737 }738739 async function handleTogglePasskeys() {740 const current = passkeyState();741 if (!current || !current.supported) return;742 setPasskeySaving(true);743 const res = await sendMessage({744 type: "SET_PASSKEY_PROVIDER_ENABLED",745 payload: { enabled: !current.enabled },746 });747 setPasskeySaving(false);748 if ((res as { ok: boolean }).ok) {749 setPasskeyState(750 (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null751 );752 }753 }754755 async function handleCheckAllBreaches() {756 setCheckingBreaches(true);757 setBreachResult(null);758 const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });759 setCheckingBreaches(false);760 if ((res as { ok: boolean }).ok) {761 const data = (res as { ok: boolean; data?: Record<string, number> }).data ?? {};762 const breached = Object.values(data).filter((count) => count > 0).length;763 setBreachResult(764 breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean"765 );766 props.onBreachCheckDone();767 }768 }769770771 async function handleSetupDuress() {772 const mainPw = duressMainPassword().trim();773 const duressPw = duressPassword().trim();774 const confirmPw = duressConfirm().trim();775776 if (!mainPw) { setDuressError("enter your main password to confirm"); return; }777 if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; }778 if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; }779 if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; }780781 setDuressSaving(true);782 setDuressError(null);783784 const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };785 if (!verifyRes.ok || !verifyRes.data) {786 setDuressError("incorrect main password");787 setDuressSaving(false);788 return;789 }790791 const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string };792 setDuressSaving(false);793 if (res.ok) {794 setDuressEnabled(true);795 setShowDuressSetup(false);796 setDuressPassword("");797 setDuressConfirm("");798 setDuressMainPassword("");799 setDuressSuccess("Duress password set. If entered, a decoy vault will be shown.");800 setTimeout(() => setDuressSuccess(null), 5000);801 } else {802 setDuressError(803 res.error === "session_expired"804 ? "Vault session expired — lock and unlock Peach, then try again"805 : res.error ?? "failed to set duress password"806 );807 }808 }809810 async function handleClearDeclinedCards() {811 await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } });812 setDeclinedCardCount(0);813 }814815 async function handleCountDuplicates() {816 setDedupRunning(true);817 setDedupDone(null);818 const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number };819 setDedupRunning(false);820 if (res.ok) setDedupCount(res.data ?? 0);821 }822823 async function handleDeduplicate() {824 setDedupRunning(true);825 const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number };826 setDedupRunning(false);827 if (res.ok) {828 setDedupDone(res.data ?? 0);829 setDedupCount(0);830 }831 }832833 async function handleDisableDuress() {834 const mainPw = duressMainPassword().trim();835 if (!mainPw) { setDuressError("enter your main password to confirm"); return; }836837 setDuressSaving(true);838 setDuressError(null);839840 const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };841 if (!verifyRes.ok || !verifyRes.data) {842 setDuressError("incorrect main password");843 setDuressSaving(false);844 return;845 }846847 const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string };848 setDuressSaving(false);849 if (res.ok) {850 setDuressEnabled(false);851 setShowDuressSetup(false);852 setDuressMainPassword("");853 setDuressSuccess("Duress password disabled.");854 setTimeout(() => setDuressSuccess(null), 4000);855 } else {856 setDuressError(res.error ?? "failed to disable duress password");857 }858 }859860 async function handleSubmitFeedback() {861 const message = sanitizeFeedbackMessage(fbMessage());862 if (!message) return;863864 setFeedbackState('sending');865866 try {867 const category = FEEDBACK_CATEGORIES.includes(fbCategory())868 ? fbCategory()869 : FEEDBACK_CATEGORIES[0];870 const res = await fetch('https://api.web3forms.com/submit', {871 method: 'POST',872 headers: { 'Content-Type': 'application/json' },873 body: JSON.stringify({874 access_key: WEB3FORMS_KEY,875 subject: `Peach Feedback - ${category}`,876 message,877 from_name: 'Peach User',878 879 botcheck: '',880 }),881 });882883 if (!res.ok) throw new Error(`HTTP ${res.status}`);884 const data = await res.json() as { success: boolean; message?: string };885 if (!data.success) throw new Error(data.message ?? 'unknown error');886887 setFeedbackState('sent');888 setFbMessage('');889 setFbCategory(FEEDBACK_CATEGORIES[0]);890 } catch (err) {891 console.error('[feedback]', err);892 setFeedbackState('error');893 setTimeout(() => setFeedbackState('idle'), 4000);894 }895 }896897 function renderSecurity() {898 return (899 <div class="peach-settings-body">900 <div class="peach-settings-group">901 <p class="peach-settings-sub-label">LOCKING</p>902 <div class="peach-settings-row">903 <span>Lock on close</span>904 <button905 class={`peach-toggle ${props.lockOnClose ? "is-on" : ""}`}906 onClick={() => void props.onLockOnCloseChange(!props.lockOnClose)}907 aria-label="Toggle lock on close"908 >909 <span class="peach-toggle-knob"></span>910 </button>911 </div>912 <div class="peach-settings-row">913 <span>Auto-lock after</span>914 <select915 class="peach-settings-select"916 value={String(props.autoLockMinutes)}917 onChange={(e) => void props.onAutoLockMinutesChange(Number(e.currentTarget.value))}918 >919 <option value="1">1 minute</option>920 <option value="5">5 minutes</option>921 <option value="15">15 minutes</option>922 <option value="30">30 minutes</option>923 <option value="0">Never</option>924 </select>925 </div>926927 <p class="peach-settings-sub-label">QUICK UNLOCK</p>928 <div class="peach-settings-row peach-settings-row--meta">929 <span>PIN unlock</span>930 <button931 class="peach-btn peach-btn--subtle peach-btn--sm"932 onClick={() => { setPinFlow(pinEnabled() ? "disable" : "setup"); setPinError(null); }}933 >934 {pinEnabled() ? "remove" : "set up"}935 </button>936 </div>937 <Show when={pinFlow() === "setup"}>938 <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">939 <input940 class="peach-settings-input"941 type="password"942 placeholder="current password"943 value={pinCurrentPassword()}944 onInput={(e) => setPinCurrentPassword((e.target as HTMLInputElement).value)}945 autocomplete="current-password"946 />947 <input948 class="peach-settings-input"949 type="password"950 inputmode="numeric"951 maxlength="12"952 placeholder="new 4–12 digit PIN"953 value={pinValue()}954 onInput={(e) => setPinValue((e.target as HTMLInputElement).value.replace(/\D/g, "").slice(0, 12))}955 autocomplete="off"956 />957 <input958 class="peach-settings-input"959 type="password"960 inputmode="numeric"961 maxlength="12"962 placeholder="confirm PIN"963 value={pinConfirm()}964 onInput={(e) => setPinConfirm((e.target as HTMLInputElement).value.replace(/\D/g, "").slice(0, 12))}965 autocomplete="off"966 onKeyDown={(e) => { if (e.key === "Enter") void handleSetupPin(); }}967 />968 <Show when={pinError()}>969 <p class="peach-hint peach-hint-error" style="margin:0">{pinError()}</p>970 </Show>971 <button972 class="peach-btn peach-btn--accent peach-btn--sm"973 style="align-self:flex-start"974 onClick={() => void handleSetupPin()}975 disabled={pinSaving() || !pinCurrentPassword() || !/^\d{4,12}$/.test(pinValue()) || !/^\d{4,12}$/.test(pinConfirm())}976 >977 {pinSaving() ? "saving…" : "save PIN"}978 </button>979 </div>980 </Show>981 <Show when={pinFlow() === "disable"}>982 <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">983 <input984 class="peach-settings-input"985 type="password"986 placeholder="current password"987 value={pinCurrentPassword()}988 onInput={(e) => setPinCurrentPassword((e.target as HTMLInputElement).value)}989 autocomplete="current-password"990 onKeyDown={(e) => { if (e.key === "Enter") void handleDisablePin(); }}991 />992 <Show when={pinError()}>993 <p class="peach-hint peach-hint-error" style="margin:0">{pinError()}</p>994 </Show>995 <button996 class="peach-btn peach-btn--subtle peach-btn--sm"997 style="align-self:flex-start"998 onClick={() => void handleDisablePin()}999 disabled={pinSaving() || !pinCurrentPassword()}1000 >1001 {pinSaving() ? "removing…" : "remove PIN"}1002 </button>1003 </div>1004 </Show>10051006 <p class="peach-settings-sub-label">BREACH PROTECTION</p>1007 <div class="peach-settings-row">1008 <span>Breach monitoring</span>1009 <button1010 class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}1011 onClick={() => void props.onBreachMonitoringChange(!props.breachMonitoring)}1012 aria-label="Toggle breach monitoring"1013 >1014 <span class="peach-toggle-knob"></span>1015 </button>1016 </div>1017 <div class="peach-settings-row peach-settings-row--meta">1018 <span>Check all passwords</span>1019 <button1020 class="peach-btn peach-btn--subtle peach-btn--sm"1021 onClick={() => void handleCheckAllBreaches()}1022 disabled={checkingBreaches()}1023 >1024 {checkingBreaches() ? "checking..." : "run"}1025 </button>1026 </div>1027 <Show when={breachResult()}>1028 <div class="peach-settings-row peach-settings-row--meta">1029 <span>Last check</span>1030 <span class="peach-settings-value">{breachResult()}</span>1031 </div>1032 </Show>10331034 <p class="peach-settings-sub-label">MASTER PASSWORD</p>1035 <div class="peach-settings-row peach-settings-row--meta">1036 <span>Master password</span>1037 <button1038 class="peach-btn peach-btn--subtle peach-btn--sm"1039 onClick={() => { setShowChangePw((v) => !v); setChangePwError(null); }}1040 >1041 {showChangePw() ? "cancel" : "change"}1042 </button>1043 </div>1044 <Show when={showChangePw()}>1045 <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1046 <input1047 class="peach-settings-input"1048 type="password"1049 placeholder="current password"1050 value={changePwCurrent()}1051 onInput={(e) => setChangePwCurrent((e.target as HTMLInputElement).value)}1052 autocomplete="current-password"1053 />1054 <input1055 class="peach-settings-input"1056 type="password"1057 placeholder="new password (min 12 chars)"1058 value={changePwNew()}1059 onInput={(e) => setChangePwNew((e.target as HTMLInputElement).value)}1060 autocomplete="new-password"1061 />1062 <input1063 class="peach-settings-input"1064 type="password"1065 placeholder="confirm new password"1066 value={changePwConfirm()}1067 onInput={(e) => setChangePwConfirm((e.target as HTMLInputElement).value)}1068 autocomplete="new-password"1069 onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }}1070 />1071 <Show when={changePwError()}>1072 <p class="peach-hint peach-hint-error" style="margin:0">{changePwError()}</p>1073 </Show>1074 <Show when={changePwSuccess()}>1075 <p class="peach-hint" style="margin:0;color:var(--peach-accent)">password changed ✓</p>1076 </Show>1077 <button1078 class="peach-btn peach-btn--accent peach-btn--sm"1079 style="align-self:flex-start"1080 onClick={() => void handleChangePassword()}1081 disabled={changePwSaving() || !changePwCurrent() || !changePwNew() || !changePwConfirm()}1082 >1083 {changePwSaving() ? "saving…" : "save new password"}1084 </button>1085 </div>1086 </Show>10871088 {}1089 <div class="peach-settings-row peach-settings-row--meta">1090 <span>Recovery words</span>1091 <button1092 class="peach-btn peach-btn--subtle peach-btn--sm"1093 onClick={() => {1094 if (showViewWords()) { closeViewWords(); } else { setShowViewWords(true); }1095 }}1096 >1097 {showViewWords() ? "cancel" : "view"}1098 </button>1099 </div>1100 <Show when={showViewWords()}>1101 <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1102 <Show1103 when={viewedWords()}1104 fallback={1105 <>1106 <p class="peach-hint" style="margin:0;color:var(--peach-t2)">1107 Enter your master password to reveal your recovery words.1108 </p>1109 <input1110 class="peach-settings-input"1111 type="password"1112 placeholder="master password"1113 value={viewWordsPassword()}1114 onInput={(e) => setViewWordsPassword((e.target as HTMLInputElement).value)}1115 autocomplete="current-password"1116 onKeyDown={(e) => { if (e.key === "Enter") void handleViewRecoveryWords(); }}1117 />1118 <Show when={viewWordsError()}>1119 <p class="peach-hint peach-hint-error" style="margin:0">{viewWordsError()}</p>1120 </Show>1121 <button1122 class="peach-btn peach-btn--accent peach-btn--sm"1123 style="align-self:flex-start"1124 onClick={() => void handleViewRecoveryWords()}1125 disabled={viewWordsBusy() || !viewWordsPassword()}1126 >1127 {viewWordsBusy() ? "checking…" : "reveal recovery words"}1128 </button>1129 </>1130 }1131 >1132 <div class="peach-recovery-words-grid">1133 <For each={viewedWords()!}>1134 {(word, i) => (1135 <span class="peach-recovery-word"><b>{i() + 1}</b> {word}</span>1136 )}1137 </For>1138 </div>1139 <button1140 class="peach-btn peach-btn--subtle peach-btn--sm"1141 style="align-self:flex-start"1142 onClick={closeViewWords}1143 >done</button>1144 </Show>1145 </div>1146 </Show>11471148 <p class="peach-settings-sub-label">1149 DURESS PASSWORD1150 <Show when={!isPro()}>1151 <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>1152 </Show>1153 </p>1154 <div class="peach-settings-row peach-settings-row--top">1155 <div class="peach-settings-row-main">1156 <span>1157 Duress password1158 <Show when={!isPro()}>1159 <span class="peach-pro-badge">Pro</span>1160 </Show>1161 </span>1162 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">1163 If entered under coercion, shows a decoy empty vault instead of your real one.1164 </span>1165 </div>1166 <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>1167 {duressEnabled() ? "on" : "off"}1168 </div>1169 </div>11701171 <Show when={duressEnabled() && !showDuressSetup()}>1172 <div class="peach-settings-row peach-settings-row--meta">1173 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1174 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.1175 </span>1176 </div>1177 </Show>11781179 <Show when={duressSuccess()}>1180 <div class="peach-settings-row peach-settings-row--meta">1181 <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>1182 </div>1183 </Show>11841185 <Show when={!showDuressSetup()}>1186 <div class="peach-settings-row peach-settings-row--meta">1187 <button1188 class="peach-btn peach-btn--subtle peach-btn--sm"1189 onClick={() => {1190 1191 1192 1193 1194 1195 if (!isPro() && !duressEnabled()) {1196 props.onProGate?.("duress_vault");1197 setActiveCard("pro");1198 return;1199 }1200 setShowDuressSetup(true);1201 setDuressError(null);1202 setDuressSuccess(null);1203 setDuressPassword("");1204 setDuressConfirm("");1205 setDuressMainPassword("");1206 }}1207 >1208 {duressEnabled() ? "Change / disable" : "Set up"}1209 </button>1210 </div>1211 </Show>12121213 <Show when={showDuressSetup()}>1214 <div class="peach-duress-form">1215 <label class="peach-label">main password (to confirm)</label>1216 <input1217 class="peach-settings-input"1218 type="password"1219 placeholder="your current master password"1220 autocomplete="current-password"1221 value={duressMainPassword()}1222 onInput={(e) => setDuressMainPassword(e.currentTarget.value)}1223 />1224 <Show when={!duressEnabled()}>1225 <label class="peach-label">new duress password</label>1226 <input1227 class="peach-settings-input"1228 type="password"1229 placeholder="duress password (min 8 chars)"1230 autocomplete="new-password"1231 value={duressPassword()}1232 onInput={(e) => setDuressPassword(e.currentTarget.value)}1233 />1234 <label class="peach-label">confirm duress password</label>1235 <input1236 class="peach-settings-input"1237 type="password"1238 placeholder="confirm duress password"1239 autocomplete="new-password"1240 value={duressConfirm()}1241 onInput={(e) => setDuressConfirm(e.currentTarget.value)}1242 />1243 </Show>12441245 <Show when={duressError()}>1246 <p class="peach-hint peach-hint-error">{duressError()}</p>1247 </Show>12481249 <div class="peach-duress-actions">1250 <button1251 class="peach-btn peach-btn--subtle peach-btn--sm"1252 onClick={() => { setShowDuressSetup(false); setDuressError(null); }}1253 >1254 cancel1255 </button>1256 <Show when={duressEnabled()}>1257 <button1258 class="peach-btn peach-btn--ghost peach-btn--sm"1259 style="color:var(--peach-breach)"1260 onClick={() => void handleDisableDuress()}1261 disabled={duressSaving()}1262 >1263 {duressSaving() ? "disabling..." : "disable duress"}1264 </button>1265 </Show>1266 <Show when={!duressEnabled()}>1267 <button1268 class="peach-btn peach-btn--accent peach-btn--sm"1269 onClick={() => void handleSetupDuress()}1270 disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}1271 >1272 {duressSaving() ? "saving..." : "save"}1273 </button>1274 </Show>1275 </div>1276 </div>1277 </Show>12781279 <p class="peach-settings-sub-label">1280 CANARY ENTRIES1281 <Show when={!isPro()}>1282 <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>1283 </Show>1284 </p>1285 <div class="peach-settings-row peach-settings-row--top">1286 <div class="peach-settings-row-main">1287 <span>1288 Canary entries1289 <Show when={!isPro()}>1290 <span class="peach-pro-badge">Pro</span>1291 </Show>1292 </span>1293 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1294 Silently pings a webhook when an entry is accessed. Configure canary on any entry in your vault.1295 </span>1296 </div>1297 <span class="peach-settings-value" style="flex-shrink:0">1298 {canaryEntries().length > 0 ? `${canaryEntries().length} active` : "none"}1299 </span>1300 </div>1301 <Show when={canaryEntries().length > 0}>1302 <For each={canaryEntries()}>1303 {(entry) => (1304 <div class="peach-settings-row peach-settings-row--meta">1305 <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0">{entry.title}</span>1306 <button1307 class="peach-btn peach-btn--subtle peach-btn--sm"1308 onClick={() => void handleTestCanary(entry)}1309 disabled={canaryTestingId() === entry.id}1310 style="flex-shrink:0"1311 >1312 {canaryTestingId() === entry.id1313 ? "sending…"1314 : canaryTestResult()?.id === entry.id1315 ? (canaryTestResult()!.ok ? "sent ✓" : "failed ✗")1316 : "test"}1317 </button>1318 </div>1319 )}1320 </For>1321 </Show>13221323 <p class="peach-settings-sub-label">PASSKEYS</p>1324 <div class="peach-settings-row peach-settings-row--top">1325 <div class="peach-settings-row-main">1326 <span>Intercept passkey requests</span>1327 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">1328 Peach can handle passkey sign-in and creation on supported Chromium browsers.1329 </span>1330 </div>1331 <button1332 class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}1333 onClick={() => void handleTogglePasskeys()}1334 aria-label="Toggle passkey interception"1335 disabled={!passkeyState()?.supported || passkeySaving()}1336 >1337 <span class="peach-toggle-knob"></span>1338 </button>1339 </div>1340 <div class="peach-settings-row peach-settings-row--meta">1341 <span>Status</span>1342 <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>1343 </div>1344 <div class="peach-settings-row peach-settings-row--meta">1345 <span>Browser</span>1346 <span class="peach-settings-value">1347 {passkeyState()?.browserLabel ?? "Unknown"}1348 {passkeyState()?.supported ? " ✓" : " (interception unavailable)"}1349 </span>1350 </div>1351 <div class="peach-info-notice" style="margin-top:8px">1352 Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.1353 </div>1354 <Show when={passkeyState()?.conflict}>1355 <div class="peach-settings-row peach-settings-row--meta">1356 <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>1357 </div>1358 </Show>1359 </div>1360 </div>1361 );1362 }13631364 function renderVault() {1365 return (1366 <div class="peach-settings-body">1367 <div class="peach-settings-group">1368 <p class="peach-settings-sub-label">ENTRIES</p>1369 <div class="peach-settings-row peach-settings-row--meta">1370 <span>Entries</span>1371 <span class="peach-settings-value">{props.entriesCount}</span>1372 </div>1373 <div class="peach-settings-row peach-settings-row--meta">1374 <span>Duplicates</span>1375 <Show when={dedupDone() !== null} fallback={1376 <button1377 class="peach-btn peach-btn--subtle peach-btn--sm"1378 onClick={() => void handleCountDuplicates()}1379 disabled={dedupRunning()}1380 >1381 {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}1382 </button>1383 }>1384 <span class="peach-settings-value" style="color:var(--peach-sync-ok)">1385 {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}1386 </span>1387 </Show>1388 </div>1389 <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>1390 <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">1391 <div class="peach-restore-warning">1392 {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.1393 </div>1394 <div style="display:flex;gap:6px">1395 <button1396 class="peach-btn peach-btn--subtle peach-btn--sm"1397 onClick={() => setDedupCount(null)}1398 >1399 cancel1400 </button>1401 <button1402 class="peach-btn peach-btn--accent peach-btn--sm"1403 onClick={() => void handleDeduplicate()}1404 disabled={dedupRunning()}1405 >1406 {dedupRunning() ? "removing..." : "remove duplicates"}1407 </button>1408 </div>1409 </div>1410 </Show>14111412 <p class="peach-settings-sub-label">MAINTENANCE</p>1413 <div class="peach-settings-row peach-settings-row--meta">1414 <span>Trash auto-delete</span>1415 <select1416 class="peach-settings-select"1417 value={String(props.trashRetentionDays)}1418 onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}1419 >1420 <option value="7">7 days</option>1421 <option value="30">30 days</option>1422 <option value="90">90 days</option>1423 <option value="0">Never</option>1424 </select>1425 </div>1426 <div class="peach-settings-row peach-settings-row--meta">1427 <span>Advanced Vault Health</span>1428 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>1429 open1430 </button>1431 </div>1432 <Show when={declinedCardCount() > 0}>1433 <div class="peach-settings-row peach-settings-row--meta">1434 <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>1435 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>1436 Clear1437 </button>1438 </div>1439 </Show>14401441 <p class="peach-settings-sub-label">DATA</p>1442 <div class="peach-settings-row peach-settings-row--meta">1443 <span>Import passwords</span>1444 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>1445 open1446 </button>1447 </div>1448 <div class="peach-settings-row peach-settings-row--meta">1449 <span>Export backup</span>1450 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onExport}>1451 open1452 </button>1453 </div>1454 <div class="peach-settings-row peach-settings-row--meta">1455 <span>1456 Peach Codex1457 <Show when={!isPro()}>1458 <span class="peach-pro-badge">Pro</span>1459 </Show>1460 </span>1461 <button1462 class="peach-btn peach-btn--subtle peach-btn--sm"1463 onClick={() => props.onOpenCodexGenerate?.()}1464 >1465 open1466 </button>1467 </div>1468 {1469}1470 <div class="peach-settings-row peach-settings-row--meta">1471 <span>Restore from Codex</span>1472 <button1473 class="peach-btn peach-btn--subtle peach-btn--sm"1474 onClick={() => props.onOpenCodexRestore?.()}1475 >1476 open1477 </button>1478 </div>14791480 <span class="peach-settings-sub-label">VAULT HISTORY</span>1481 <Show when={historyLoading()}>1482 <p class="peach-settings-hint">Loading…</p>1483 </Show>1484 <Show when={!historyLoading() && historySnapshots().length === 0}>1485 <p class="peach-settings-hint">1486 No snapshots yet. Peach saves automatically before every change —1487 saves, syncs, and imports.1488 </p>1489 </Show>1490 <Show when={!historyLoading() && historySnapshots().length > 0}>1491 <p class="peach-settings-hint">1492 Restoring locks your vault. Unlock with the password that1493 encrypted that snapshot to confirm.1494 </p>1495 <div class="peach-history-list">1496 <For each={historySnapshots().slice(0, historyShowAll() ? undefined : 3)}>1497 {(snap) => (1498 <div class="peach-history-row">1499 <div class="peach-history-meta">1500 <span class="peach-history-time">1501 {formatSnapshotTime(snap.savedAt)}1502 </span>1503 <span class="peach-history-sub">1504 {[1505 snap.entryCount !== null1506 ? `up to ${snap.entryCount} ${snap.entryCount === 1 ? "entry" : "entries"}`1507 : null,1508 reasonLabel(snap.reason),1509 ]1510 .filter(Boolean)1511 .join(" · ")}1512 </span>1513 </div>1514 <button1515 class={`peach-btn peach-btn--subtle peach-btn--sm peach-history-btn${1516 historyRestoring() === snap.id ? " is-busy" : ""1517 }`}1518 onClick={() => void handleRestoreVaultSnapshot(snap.id)}1519 disabled={historyRestoring() !== null}1520 >1521 <Show when={historyRestoring() === snap.id} fallback="Restore">1522 …1523 </Show>1524 </button>1525 </div>1526 )}1527 </For>1528 </div>1529 <Show when={historySnapshots().length > 3}>1530 <button1531 class="peach-settings-advanced-toggle"1532 onClick={() => setHistoryShowAll((v) => !v)}1533 aria-expanded={historyShowAll()}1534 >1535 <span class="peach-settings-hint" style="margin:0">1536 {historyShowAll() ? "show less" : `${historySnapshots().length - 3} older`}1537 </span>1538 <span class="peach-settings-advanced-chevron">1539 {historyShowAll() ? "▲" : "▼"}1540 </span>1541 </button>1542 </Show>1543 <p class="peach-history-footer">1544 {historySnapshots().length} of 10 snapshots1545 </p>1546 </Show>15471548 <button1549 class="peach-settings-advanced-toggle"1550 onClick={() => setShowVaultAdvanced((v) => !v)}1551 aria-expanded={showVaultAdvanced()}1552 >1553 <span class="peach-settings-sub-label" style="margin:0">ADVANCED</span>1554 <span class="peach-settings-advanced-chevron">1555 {showVaultAdvanced() ? "▲" : "▼"}1556 </span>1557 </button>15581559 <Show when={showVaultAdvanced()}>1560 <div class="peach-danger-card">1561 <div class="peach-danger-card-head">1562 <span class="peach-danger-card-glyph" aria-hidden="true">⟳</span>1563 <h3 class="peach-danger-card-title">Replace vault from backup</h3>1564 </div>1565 <p class="peach-danger-card-copy">1566 Wipes the current vault and restores from a backup file.1567 Recoverable only if your backup is good.1568 </p>1569 <button1570 class="peach-danger-card-button"1571 onClick={() => props.onOpenFileImport?.()}1572 >1573 Choose backup file1574 </button>1575 </div>15761577 <Show when={props.onOpenPanic}>1578 <div class="peach-danger-card peach-danger-card--hazard">1579 <div class="peach-danger-card-head">1580 <span class="peach-danger-card-glyph" aria-hidden="true">⚠</span>1581 <h3 class="peach-danger-card-title">Destroy vault</h3>1582 </div>1583 <p class="peach-danger-card-copy">1584 Permanently wipes every entry, setting, and license on this1585 device. There is no recovery.1586 </p>1587 <button1588 class="peach-danger-card-button"1589 onClick={() => props.onOpenPanic?.()}1590 >1591 Begin destruction1592 </button>1593 </div>1594 </Show>1595 </Show>1596 </div>1597 </div>1598 );1599 }16001601 function renderSync() {1602 return (1603 <div class="peach-settings-body">1604 <div class="peach-settings-group">1605 <p class="peach-settings-sub-label" style="margin-top:0">SYNC STATUS</p>1606 <div1607 class="peach-settings-row peach-settings-row--meta"1608 style="align-items:center;gap:8px"1609 role="status"1610 aria-label={syncLightLabel()}1611 title={syncLightLabel()}1612 >1613 <span class={`stk-pip stk-pip--sync is-${syncLight()}`} aria-hidden="true" />1614 <span class="peach-settings-value" style="color:var(--peach-t2);line-height:1.5;flex:1;min-width:0;overflow-wrap:anywhere">1615 {syncFlash() ?? lastSyncLine()}1616 </span>1617 </div>1618 <Show when={pairedDevices().length > 0}>1619 <div class="peach-settings-row peach-settings-row--meta">1620 <span class="peach-settings-value" style="color:var(--peach-t2)">1621 {(syncSummary().entriesSent ?? 0) + (syncSummary().entriesReceived ?? 0) > 01622 ? `${syncSummary().entriesSent ?? 0} sent · ${syncSummary().entriesReceived ?? 0} received`1623 : ""}1624 </span>1625 <button1626 class="peach-btn peach-btn--subtle peach-btn--sm"1627 aria-label="Sync this vault now"1628 disabled={syncStatus() === "syncing"}1629 onClick={() => void syncNow()}1630 >{syncStatus() === "syncing" ? "syncing…" : "sync now"}</button>1631 </div>1632 </Show>16331634 <p class="peach-settings-sub-label">ADD A DEVICE</p>1635 <button1636 class="peach-sync-pair-option"1637 onClick={() => props.onOpenSyncPairing?.("code")}1638 >1639 <span class="peach-sync-pair-option-icon" aria-hidden="true">1640 <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">1641 <rect x="6" y="2.5" width="8" height="15" rx="1.6" />1642 <path d="M9 15 L11 15" />1643 </svg>1644 </span>1645 <span class="peach-sync-pair-option-main">1646 <span class="peach-sync-pair-option-title">Show a code</span>1647 <span class="peach-sync-pair-option-sub">Scan it with Peach on your phone, then confirm one 8-digit code</span>1648 </span>1649 <span class="peach-sync-pair-option-chevron" aria-hidden="true">›</span>1650 </button>1651 <Show when={!isPro()}>1652 <p class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2);margin:8px 2px 0">1653 At least one device needs Peach Pro — pair with a Pro device and it carries over to here.1654 </p>1655 </Show>16561657 <Show when={pairedDevices().length > 0}>1658 <p class="peach-settings-sub-label">PAIRED DEVICES</p>1659 <For each={pairedDevices()}>1660 {(d) => (1661 <div class="peach-settings-row peach-settings-row--top">1662 <div class="peach-settings-row-main">1663 <Show1664 when={renameDeviceId() === d.deviceId}1665 fallback={<span>{d.localAlias ?? d.deviceName ?? "Unnamed device"}</span>}1666 >1667 <input1668 class="peach-settings-input"1669 aria-label={`Rename ${d.localAlias ?? d.deviceName ?? "device"}`}1670 maxlength={64}1671 value={renameDraft()}1672 onInput={(event) => setRenameDraft(event.currentTarget.value)}1673 onKeyDown={(event) => {1674 if (event.key === "Enter") void renameDevice(d.deviceId);1675 if (event.key === "Escape") setRenameDeviceId(null);1676 }}1677 />1678 </Show>1679 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1680 {deviceTypeLabel(d.deviceType)} · {syncRelativeTime(d.lastSyncedAt)}{isSyncStale(d) ? " · not seen recently" : ""}1681 </span>1682 </div>1683 <Show1684 when={confirmUnpair() === d.deviceId}1685 fallback={1686 <div style="display:flex;gap:4px;flex-shrink:0">1687 <Show1688 when={renameDeviceId() === d.deviceId}1689 fallback={1690 <button1691 class="peach-btn peach-btn--subtle peach-btn--sm"1692 onClick={() => {1693 setRenameDraft(d.localAlias ?? d.deviceName ?? "");1694 setRenameDeviceId(d.deviceId);1695 }}1696 >rename</button>1697 }1698 >1699 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => setRenameDeviceId(null)}>cancel</button>1700 <button class="peach-btn peach-btn--subtle peach-btn--sm" disabled={!renameDraft().trim()} onClick={() => void renameDevice(d.deviceId)}>save</button>1701 </Show>1702 <button1703 class="peach-btn peach-btn--subtle peach-btn--sm"1704 onClick={() => setConfirmUnpair(d.deviceId)}1705 >remove</button>1706 </div>1707 }1708 >1709 {17101711171217131714}1715 <div class="peach-unpair-confirm">1716 <p class="peach-unpair-confirm-note">1717 Stops this device receiving future changes. It doesn't1718 lock it out of what it already has — if it's lost or1719 stolen, also change your master password and generate1720 new recovery words.1721 </p>1722 <div style="display:flex;gap:6px;justify-content:flex-end">1723 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => setConfirmUnpair(null)}>cancel</button>1724 <button1725 class="peach-btn peach-btn--sm"1726 style="background:var(--peach-breach);border-color:var(--peach-breach)"1727 onClick={() => void unpairDevice(d.deviceId)}1728 >remove</button>1729 </div>1730 </div>1731 </Show>1732 </div>1733 )}1734 </For>1735 </Show>17361737 <Show when={pendingVouched().length > 0}>1738 <p class="peach-settings-sub-label">1739 PENDING DEVICES1740 <span class="peach-pro-badge" style="background:#C8A840;color:#1a1a1a">{pendingVouched().length}</span>1741 </p>1742 <For each={pendingVouched()}>1743 {(dev) => (1744 <div class="peach-settings-row peach-settings-row--top">1745 <div class="peach-settings-row-main">1746 <span>{dev.deviceName}</span>1747 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1748 Was added by another of your devices. Allow it to sync with this vault?1749 </span>1750 </div>1751 <div style="display:flex;gap:6px;flex-shrink:0">1752 <button1753 class="peach-btn peach-btn--sm"1754 disabled={vouchedBusy() === dev.deviceId}1755 onClick={() => void confirmVouched(dev.deviceId)}1756 >Allow</button>1757 <button1758 class="peach-btn peach-btn--subtle peach-btn--sm"1759 disabled={vouchedBusy() === dev.deviceId}1760 onClick={() => void rejectVouched(dev.deviceId)}1761 >Reject</button>1762 </div>1763 </div>1764 )}1765 </For>1766 </Show>17671768 <button1769 class="peach-settings-advanced-toggle peach-sync-disclosure-toggle"1770 onClick={() => setShowSyncConnection((value) => !value)}1771 aria-expanded={showSyncConnection()}1772 aria-controls="peach-sync-connection"1773 >1774 <span>1775 <span class="peach-settings-sub-label" style="margin:0">CONNECTION</span>1776 <span class="peach-sync-disclosure-summary">Relay and privacy choices</span>1777 </span>1778 <span class="peach-settings-advanced-chevron">1779 {showSyncConnection() ? "▲" : "▼"}1780 </span>1781 </button>1782 <Show when={showSyncConnection()}>1783 <div id="peach-sync-connection" class="peach-sync-disclosure-content">1784 <SyncModeSelector />1785 </div>1786 </Show>17871788 <button1789 class="peach-settings-advanced-toggle peach-sync-disclosure-toggle"1790 onClick={() => setShowSyncRecovery((value) => !value)}1791 aria-expanded={showSyncRecovery()}1792 aria-controls="peach-sync-recovery"1793 >1794 <span>1795 <span class="peach-settings-sub-label" style="margin:0">RECOVERY & SECURITY</span>1796 <span class="peach-sync-disclosure-summary">For a lost or stolen device</span>1797 </span>1798 <span class="peach-settings-advanced-chevron">1799 {showSyncRecovery() ? "▲" : "▼"}1800 </span>1801 </button>1802 <Show when={showSyncRecovery()}>1803 <div id="peach-sync-recovery" class="peach-sync-disclosure-content">1804 <Show1805 when={rotateConfirmOpen()}1806 fallback={1807 <div class="peach-settings-row peach-settings-row--top">1808 <div class="peach-settings-row-main">1809 <span>Rotate sync identity</span>1810 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1811 Disconnects every device and replaces the keys used for sync.1812 </span>1813 </div>1814 <button1815 class="peach-btn peach-btn--subtle peach-btn--sm"1816 style="color:var(--peach-breach);border-color:color-mix(in srgb,var(--peach-breach) 40%,transparent)"1817 onClick={() => { setRotateError(null); setRotateConfirmOpen(true); }}1818 >rotate</button>1819 </div>1820 }1821 >1822 <div class="peach-info-notice" style="margin-top:8px;border-color:color-mix(in srgb,var(--peach-breach) 40%,transparent)">1823 This disconnects every paired device and changes your 12 recovery words. You'll be shown the new words next.1824 <Show when={rotateError()}>1825 <div style="color:var(--peach-breach);margin-top:6px">{rotateError()}</div>1826 </Show>1827 <div style="display:flex;gap:6px;margin-top:10px">1828 <button1829 class="peach-btn peach-btn--subtle peach-btn--sm"1830 disabled={rotating()}1831 onClick={() => setRotateConfirmOpen(false)}1832 >Cancel</button>1833 <button1834 class="peach-btn peach-btn--sm"1835 style="background:var(--peach-breach);border-color:var(--peach-breach)"1836 disabled={rotating()}1837 onClick={() => void doRotateSyncIdentity()}1838 >{rotating() ? "Rotating…" : "Rotate sync identity"}</button>1839 </div>1840 </div>1841 </Show>1842 </div>1843 </Show>1844 </div>1845 </div>1846 );1847 }18481849 function renderAppearance() {1850 const sizeOptions: Array<{ id: "medium" | "large"; label: string }> = [1851 { id: "medium", label: "Default" },1852 { id: "large", label: "Large" },1853 ];1854 return (1855 <div class="peach-settings-body">1856 <div class="peach-settings-group">1857 <p class="peach-settings-sub-label">THEME</p>1858 <div class="peach-settings-row peach-settings-row--meta">1859 <span>Theme</span>1860 <button1861 class="peach-btn peach-btn--subtle peach-btn--sm"1862 onClick={() =>1863 props.onThemeChange(props.theme === "dark" ? "light" : "dark")1864 }1865 >1866 {props.theme === "dark" ? "Switch to light" : "Switch to dark"}1867 </button>1868 </div>18691870 <p class="peach-settings-sub-label">ACCENT</p>1871 <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">1872 <span>Accent</span>1873 <div class="peach-accent-picker">1874 <For each={ACCENT_COLORS}>1875 {(color) => (1876 <button1877 class="peach-accent-swatch peach-swatch"1878 classList={{ "is-active": props.accentColor === color.name }}1879 style={{ background: color.hex }}1880 onClick={() => void props.onAccentChange(color.name)}1881 aria-label={color.name}1882 title={color.name}1883 />1884 )}1885 </For>1886 </div>1887 </div>18881889 <p class="peach-settings-sub-label">TEXT SIZE</p>1890 <div class="peach-settings-row peach-settings-row--meta">1891 <span>Size</span>1892 <div class="peach-pill-group">1893 <For each={sizeOptions}>1894 {(opt) => (1895 <button1896 class="peach-btn peach-btn--ghost peach-btn--sm"1897 classList={{ "is-active": props.textSize === opt.id }}1898 onClick={() => void props.onTextSizeChange(opt.id)}1899 >1900 {opt.label}1901 </button>1902 )}1903 </For>1904 </div>1905 </div>19061907 <p class="peach-settings-sub-label">MOTION</p>1908 <div class="peach-settings-row peach-settings-row--meta">1909 <span>Reduce motion</span>1910 <button1911 class="peach-btn peach-btn--subtle peach-btn--sm"1912 onClick={() => void props.onReduceMotionChange(!props.reduceMotion)}1913 >1914 {props.reduceMotion ? "On" : "Off"}1915 </button>1916 </div>1917 </div>1918 </div>1919 );1920 }19211922 function renderShortcuts() {1923 const [capturingAction, setCapturingAction] = createSignal<ShortcutAction | null>(null);1924 const [conflictMsg, setConflictMsg] = createSignal<string | null>(null);19251926 function startCapture(action: ShortcutAction) {1927 setConflictMsg(null);1928 setCapturingAction(action);1929 }1930 function cancelCapture() {1931 setCapturingAction(null);1932 setConflictMsg(null);1933 }1934 function commitBinding(action: ShortcutAction, binding: Keybinding) {1935 const conflict = findConflict(props.shortcuts, binding, action);1936 if (conflict) {1937 const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict);1938 setConflictMsg(`Already bound to "${other?.label ?? conflict}"`);1939 return;1940 }1941 const next = { ...props.shortcuts, [action]: binding };1942 setCapturingAction(null);1943 setConflictMsg(null);1944 void props.onShortcutsChange(next);1945 }1946 function resetAction(action: ShortcutAction) {1947 const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] };1948 void props.onShortcutsChange(next);1949 }1950 function resetAll() {1951 void props.onShortcutsChange({ ...DEFAULT_BINDINGS });1952 }19531954 1955 1956 function handleCaptureKey(e: KeyboardEvent) {1957 e.preventDefault();1958 e.stopPropagation();1959 if (e.key === "Escape") { cancelCapture(); return; }1960 const binding = keybindingFromEvent(e);1961 if (!binding) return; 1962 const action = capturingAction();1963 if (action) commitBinding(action, binding);1964 }19651966 return (1967 <div class="peach-settings-body">1968 <div class="peach-settings-group peach-shortcuts-group">1969 <p class="peach-settings-sub-label">ENTRY VIEW</p>1970 <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry")}>1971 {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1972 </For>1973 </div>19741975 <div class="peach-settings-group peach-shortcuts-group">1976 <p class="peach-settings-sub-label">ANYWHERE IN POPUP</p>1977 <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global")}>1978 {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1979 </For>1980 </div>19811982 <Show when={conflictMsg()}>1983 <div class="peach-shortcuts-conflict">{conflictMsg()}</div>1984 </Show>19851986 <div class="peach-settings-group" style="display:flex;flex-direction:column;gap:8px">1987 <div class="peach-settings-row peach-settings-row--meta">1988 <span>Inline autofill on page</span>1989 <a1990 class="peach-settings-link"1991 href="chrome://extensions/shortcuts"1992 onClick={(e) => {1993 e.preventDefault();1994 chrome.tabs.create({ url: "chrome://extensions/shortcuts" });1995 }}1996 >1997 ⌘⇧P — customize ↗1998 </a>1999 </div>2000 <button2001 class="peach-btn peach-btn--subtle peach-btn--sm"2002 style="align-self:flex-start"2003 onClick={resetAll}2004 >2005 Reset all to defaults2006 </button>2007 </div>2008 </div>2009 );20102011 function renderRow(2012 desc: typeof SHORTCUT_DESCRIPTORS[number],2013 capturing: () => ShortcutAction | null,2014 onStart: (a: ShortcutAction) => void,2015 onCancel: () => void,2016 onReset: (a: ShortcutAction) => void,2017 onKey: (e: KeyboardEvent) => void,2018 ) {2019 const isActive = () => capturing() === desc.action;2020 const binding = () => props.shortcuts[desc.action];2021 const isCustom = () => {2022 const b = binding();2023 const d = DEFAULT_BINDINGS[desc.action];2024 return b.key !== d.key2025 || Boolean(b.ctrl) !== Boolean(d.ctrl)2026 || Boolean(b.meta) !== Boolean(d.meta)2027 || Boolean(b.shift) !== Boolean(d.shift)2028 || Boolean(b.alt) !== Boolean(d.alt);2029 };2030 return (2031 <div class="peach-shortcut-row">2032 <span class="peach-shortcut-row-label">{desc.label}</span>2033 <button2034 class="peach-shortcut-chip"2035 classList={{ "is-capturing": isActive(), "is-custom": isCustom() }}2036 onClick={() => isActive() ? onCancel() : onStart(desc.action)}2037 onKeyDown={(e) => { if (isActive()) onKey(e); }}2038 ref={(el) => {2039 2040 if (el) {2041 queueMicrotask(() => { if (isActive()) el.focus(); });2042 }2043 }}2044 >2045 {isActive() ? "Press a key…" : formatKeybinding(binding())}2046 </button>2047 <Show when={isCustom() && !isActive()}>2048 <button2049 class="peach-shortcut-reset"2050 title="Reset to default"2051 onClick={() => onReset(desc.action)}2052 >2053 ↺2054 </button>2055 </Show>2056 </div>2057 );2058 }2059 }20602061 const PRO_FEATURES: Array<{2062 feature: ProFeature;2063 name: string;2064 desc: string;2065 }> = [2066 { feature: "sync", name: "Peach Sync", desc: "Encrypted · peer-to-peer" },2067 { feature: "codex", name: "Peach Codex", desc: "Restore from paper" },2068 { feature: "passkeys", name: "Passkeys", desc: "Zero-knowledge WebAuthn" },2069 { feature: "advanced_health", name: "Advanced Vault Health", desc: "Reuse · age · exposure" },2070 ];20712072 const PRO_SECURITY_FEATURES: Array<{2073 feature: ProFeature;2074 name: string;2075 desc: string;2076 }> = [2077 { feature: "canary_entry", name: "Canary entries", desc: "Detects vault compromise" },2078 { feature: "duress_vault", name: "Duress vault", desc: "Decoy under coercion" },2079 ];20802081 const [licenseKey, setLicenseKey] = createSignal<string | null>(null);2082 const [showKey, setShowKey] = createSignal(false);20832084 const [deactivating, setDeactivating] = createSignal(false);20852086 async function handleDeactivateLicense() {2087 setDeactivating(true);2088 await sendMessage({ type: "DEACTIVATE_LICENSE" });2089 setLicenseInfo(null);2090 await props.onLicenseChange?.();2091 setDeactivating(false);2092 }20932094 async function handleTestCanary(entry: {id: string; title: string; canary: Canary}) {2095 setCanaryTestingId(entry.id);2096 setCanaryTestResult(null);2097 const res = await sendMessage({2098 type: "FIRE_CANARY_PING",2099 payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger: "test" },2100 }) as { ok: boolean };2101 setCanaryTestResult({ id: entry.id, ok: res.ok });2102 setCanaryTestingId(null);2103 setTimeout(() => setCanaryTestResult((r) => r?.id === entry.id ? null : r), 3000);2104 }21052106 function renderLicense() {2107 async function onActivate() {2108 await handleActivateLicense();2109 if ((licenseInfo()?.valid ?? false) && licenseInfo()?.plan === "pro") {2110 await props.onLicenseChange?.();2111 }2112 }21132114 return (2115 <div class="peach-settings-body">2116 <Show2117 when={isPro()}2118 fallback={2119 <div class="peach-pro-upgrade">2120 <p class="peach-pro-eyebrow">PEACH PRO · ONE-TIME</p>21212122 <h2 class="peach-pro-headline">2123 Every<em>thing,</em><br />every<em>where.</em>2124 </h2>21252126 <p class="peach-pro-subtitle">2127 One key unlocks every Pro feature — for the lifetime of the app.2128 </p>21292130 <div class="peach-pro-grid">2131 <For each={PRO_FEATURES}>2132 {(item, i) => (2133 <div2134 class="peach-pro-grid-item"2135 classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}2136 >2137 <span class="peach-pro-grid-num">{String(i() + 1).padStart(2, "0")}</span>2138 <span class="peach-pro-grid-name">{item.name}</span>2139 <span class="peach-pro-grid-desc">{item.desc}</span>2140 </div>2141 )}2142 </For>2143 </div>21442145 <p class="peach-pro-section-label">ADVANCED SECURITY</p>2146 <div class="peach-pro-grid">2147 <For each={PRO_SECURITY_FEATURES}>2148 {(item, i) => (2149 <div2150 class="peach-pro-grid-item"2151 classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}2152 >2153 <span class="peach-pro-grid-num">{String(PRO_FEATURES.length + i() + 1).padStart(2, "0")}</span>2154 <span class="peach-pro-grid-name">{item.name}</span>2155 <span class="peach-pro-grid-desc">{item.desc}</span>2156 </div>2157 )}2158 </For>2159 </div>21602161 <div class="peach-pro-upgrade-actions">2162 <p class="peach-pro-pricing">2163 $39 <span class="peach-pro-pricing-sub">once · yours forever</span>2164 </p>2165 <a2166 href="https://buy.polar.sh/polar_cl_IXSFIPojx67rcOTksn8LkH5mnU3osPHtYqBIl1eyuIz"2167 target="_blank"2168 rel="noopener noreferrer"2169 class="peach-pro-upgrade-cta"2170 >2171 Get Peach Pro2172 </a>2173 <div class="peach-pro-key-divider"><span>ALREADY HAVE A KEY</span></div>2174 <div class="peach-pro-key-inline">2175 <input2176 class="peach-pro-key-inline-input"2177 type="text"2178 placeholder="PEACH-XXXX-XXXX-XXXX"2179 value={licenseKeyInput()}2180 onInput={(e) => setLicenseKeyInput(e.currentTarget.value)}2181 onKeyDown={(e) => { if (e.key === "Enter") void onActivate(); }}2182 autocomplete="off"2183 spellcheck={false}2184 />2185 <button2186 class="peach-pro-key-apply"2187 onClick={() => void onActivate()}2188 disabled={activatingLicense() || !licenseKeyInput().trim()}2189 >2190 {activatingLicense() ? "…" : "APPLY"}2191 </button>2192 </div>2193 <Show when={licenseError()}>2194 <p class="peach-pro-key-error">{licenseError()}</p>2195 </Show>2196 </div>2197 </div>2198 }2199 >2200 <div class="peach-pro-active">2201 <div class="peach-pro-active-hero">2202 <button2203 class="peach-pro-active-mark-wrap"2204 type="button"2205 aria-label="Celebrate"2206 onClick={handleProCelebrationClick}2207 >2208 <PeachMark class="peach-pro-active-mark" />2209 <Show when={justActivated()}>2210 <div class="peach-pro-sparks" aria-hidden="true">2211 <span class="peach-pro-spark" style="--spark-x:0px;--spark-y:-54px;--spark-delay:0ms">✦</span>2212 <span class="peach-pro-spark" style="--spark-x:38px;--spark-y:-38px;--spark-delay:50ms">✦</span>2213 <span class="peach-pro-spark" style="--spark-x:54px;--spark-y:0px;--spark-delay:110ms">·</span>2214 <span class="peach-pro-spark" style="--spark-x:38px;--spark-y:38px;--spark-delay:30ms">✦</span>2215 <span class="peach-pro-spark" style="--spark-x:0px;--spark-y:54px;--spark-delay:80ms">·</span>2216 <span class="peach-pro-spark" style="--spark-x:-38px;--spark-y:38px;--spark-delay:130ms">✦</span>2217 <span class="peach-pro-spark" style="--spark-x:-54px;--spark-y:0px;--spark-delay:55ms">·</span>2218 <span class="peach-pro-spark" style="--spark-x:-38px;--spark-y:-38px;--spark-delay:95ms">✦</span>2219 </div>2220 </Show>2221 </button>2222 <h2 class="peach-pro-active-title">Peach Pro</h2>2223 <p class="peach-pro-active-since">{formatActivatedSince(licenseInfo()?.issued)}</p>2224 <Show when={justActivated()}>2225 <p class="peach-pro-thankyou-text" aria-live="polite">thank you ✦</p>2226 </Show>2227 </div>22282229 <Show when={licenseKey()}>2230 <div class="peach-settings-license">2231 <span class="peach-settings-label">LICENSE KEY</span>2232 <div class="peach-settings-license-row">2233 <Show2234 when={showKey()}2235 fallback={2236 <span class="peach-settings-license-key peach-settings-license-key--masked" aria-label="License key hidden">2237 ••••••••••••••••••••••••••••2238 </span>2239 }2240 >2241 <span class="peach-settings-license-key">{licenseKey()!}</span>2242 </Show>2243 <div class="peach-settings-license-actions">2244 <button2245 class="peach-settings-license-copy"2246 title="Copy license key"2247 onClick={() => copyLicenseKey(licenseKey()!)}2248 >2249 {copied() ? "✓" : "copy"}2250 </button>2251 <button2252 class="peach-settings-license-toggle"2253 aria-label={showKey() ? "Hide license key" : "Reveal license key"}2254 title={showKey() ? "Hide" : "Reveal"}2255 onClick={() => setShowKey(v => !v)}2256 >2257 <Show2258 when={showKey()}2259 fallback={2260 <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">2261 <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />2262 <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />2263 </svg>2264 }2265 >2266 <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">2267 <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />2268 <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />2269 <line x1="2" y1="2" x2="12" y2="12" />2270 </svg>2271 </Show>2272 </button>2273 </div>2274 </div>2275 </div>2276 </Show>22772278 <div class="peach-pro-active-features">2279 <For each={[...PRO_FEATURES, ...PRO_SECURITY_FEATURES]}>2280 {(item) => (2281 <div class="peach-pro-active-feature">2282 <span class="peach-pro-active-feature-mark">✦</span>2283 <span class="peach-pro-active-feature-name">{item.name}</span>2284 </div>2285 )}2286 </For>2287 </div>22882289 <button2290 class="peach-pro-deactivate"2291 onClick={() => void handleDeactivateLicense()}2292 disabled={deactivating()}2293 >2294 {deactivating() ? "Deactivating…" : "Deactivate"}2295 </button>2296 </div>2297 </Show>2298 </div>2299 );2300 }23012302 function renderFeedback() {2303 return (2304 <div class="peach-settings-body">2305 <div class="peach-settings-group peach-feedback-group">2306 <Show2307 when={feedbackState() === 'sent'}2308 fallback={2309 <>2310 <p class="peach-feedback-intro">2311 Help us improve Peach. Every report goes directly to our inbox.2312 </p>23132314 <p class="peach-settings-sub-label">TYPE</p>2315 <div class="peach-feedback-chips">2316 <For each={FEEDBACK_CATEGORIES}>2317 {(cat) => {2318 const selected = () => fbCategory() === cat;2319 return (2320 <button2321 type="button"2322 class={`peach-feedback-chip ${selected() ? "is-active" : ""}`}2323 aria-pressed={selected()}2324 onClick={() => setFbCategory(cat)}2325 >2326 {cat}2327 </button>2328 );2329 }}2330 </For>2331 </div>23322333 <p class="peach-settings-sub-label">MESSAGE</p>2334 <textarea2335 class="peach-feedback-textarea"2336 placeholder="Describe the issue or idea..."2337 maxLength={MAX_FEEDBACK_MESSAGE_LENGTH}2338 value={fbMessage()}2339 onInput={(e) => setFbMessage(e.currentTarget.value)}2340 />23412342 <p class="peach-feedback-meta">2343 Version {version} · will be included automatically2344 </p>23452346 <button2347 type="button"2348 class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"2349 disabled={!fbMessage().trim() || feedbackState() === 'sending'}2350 onClick={() => void handleSubmitFeedback()}2351 >2352 <Switch>2353 <Match when={feedbackState() === 'idle'}>Send</Match>2354 <Match when={feedbackState() === 'sending'}>Sending...</Match>2355 <Match when={feedbackState() === 'error'}>Failed - try again</Match>2356 </Switch>2357 </button>23582359 <p class="peach-feedback-anon">2360 Sent anonymously · no account required2361 </p>2362 </>2363 }2364 >2365 <div class="peach-feedback-thanks" role="status" aria-live="polite">2366 <p class="peach-feedback-thanks-title">Thank you</p>2367 <p>2368 Your note is in the queue. Someone will process it soon.2369 </p>2370 </div>2371 </Show>2372 </div>2373 </div>2374 );2375 }23762377 const bodies: StackBodies = {2378 protection: renderSecurity(),2379 vault: renderVault(),2380 sync: renderSync(),2381 appearance: renderAppearance(),2382 shortcuts: renderShortcuts(),2383 pro: renderLicense(),2384 feedback: renderFeedback(),2385 };23862387 return (2388 <Show2389 when={showNewRecoveryWords()}2390 fallback={2391 <div2392 class="peach-overlay-view peach-settings-overlay"2393 classList={{ "ov-enter-right": !props.isExiting, "ov-exit-right": !!props.isExiting }}2394 style="display:flex;flex-direction:column"2395 >2396 <StackDeck2397 active={activeCard()}2398 onActiveChange={setActiveCard}2399 onBack={props.onBack}2400 cards={cards()}2401 bodies={bodies}2402 version={version}2403 isFanning={isFanning()}2404 celebratingCardId={justActivated() ? "pro" : null}2405 isPro={isPro()}2406 />2407 </div>2408 }2409 >2410 <RecoveryWordDisplayView onDone={() => setShowNewRecoveryWords(false)} />2411 </Show>2412 );2413}