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 DEFAULT_BINDINGS,19 findConflict,20 formatKeybinding,21 keybindingFromEvent,22 SHORTCUT_DESCRIPTORS,23 type Keybinding,24 type ShortcutAction,25} from "../../shared/shortcuts";26import { isNativePlatform, nativePlugin } from "../../shared/native";27import { WEB3FORMS_KEY } from "../../shared/config";2829interface LicenseInfo {30 valid: boolean;31 plan: "free" | "pro";32 issued?: string;33}3435interface SettingsProps {36 entriesCount: number;37 breachMonitoring: boolean;38 lockOnClose: boolean;39 autoLockMinutes: number;40 trashRetentionDays: number;41 theme: "dark" | "light";42 accentColor: AccentColorName;43 textSize: "small" | "medium" | "large";44 reduceMotion: boolean;45 isPro?: boolean;46 initialSection?: string | null;47 upgradeHighlight?: ProFeature | null;48 onBreachMonitoringChange: (enabled: boolean) => void | Promise<void>;49 onLockOnCloseChange: (enabled: boolean) => void | Promise<void>;50 onAutoLockMinutesChange: (minutes: number) => void | Promise<void>;51 onTrashRetentionChange: (days: number) => void | Promise<void>;52 onThemeChange: (theme: "dark" | "light") => void;53 onAccentChange: (name: AccentColorName) => void | Promise<void>;54 onTextSizeChange: (size: "small" | "medium" | "large") => void | Promise<void>;55 onReduceMotionChange: (enabled: boolean) => void | Promise<void>;56 shortcuts: Record<ShortcutAction, Keybinding>;57 onShortcutsChange: (active: Record<ShortcutAction, Keybinding>) => void | Promise<void>;58 onLicenseChange?: () => void | Promise<void>;59 isExiting?: boolean;60 onBack: () => void;61 onImport: () => void;62 onExport: () => void;63 onBreachCheckDone: () => void;64 onOpenHealth?: () => void;65 onOpenSyncPairing?: (method?: "code") => void;66 onOpenFileImport?: () => void;67 onOpenPanic?: () => void;68 onOpenCodexGenerate?: () => void;69 onOpenCodexRestore?: () => void;70 717273 onProGate?: (feature: ProFeature) => void;74 onCardChange?: (id: CardId) => void;75}7677const FEEDBACK_CATEGORIES = ["Bug report", "Feature request", "Question", "Other"];78const FEEDBACK_DATA_PERMISSION = "technicalAndInteraction";79const MAX_FEEDBACK_MESSAGE_LENGTH = 4000;80const PRO_CELEBRATION_CARD_STEP_MS = 150;81const PRO_CELEBRATION_THANK_YOU_MS = 3200;8283function sanitizeFeedbackMessage(value: string): string {84 return value85 .trim()86 .slice(0, MAX_FEEDBACK_MESSAGE_LENGTH)87 .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "")88 .replace(/&/g, "&")89 .replace(/</g, "<")90 .replace(/>/g, ">")91 .replace(/"/g, """)92 .replace(/'/g, "'");93}9495type DataCollectionPermissionApi = {96 permissions?: {97 getAll?: () => Promise<{ data_collection?: string[] }>;98 request?: (permissions: { data_collection: string[] }) => Promise<boolean>;99 };100};101102async function requestFeedbackDataPermission(): Promise<boolean> {103 const api = ((globalThis as { browser?: DataCollectionPermissionApi }).browser ??104 (typeof chrome !== "undefined" ? chrome : undefined)) as DataCollectionPermissionApi | undefined;105 if (!api?.permissions?.getAll) return true;106107 try {108 const permissions = await api.permissions.getAll();109 if (!("data_collection" in permissions)) return true;110 if (permissions.data_collection?.includes(FEEDBACK_DATA_PERMISSION)) return true;111 return await (api.permissions.request?.({ data_collection: [FEEDBACK_DATA_PERMISSION] }) ?? Promise.resolve(false));112 } catch {113 return false;114 }115}116117function formatActivatedSince(issued: string | undefined): string {118 if (!issued) return "Active";119 try {120 const d = new Date(issued);121 if (Number.isNaN(d.getTime())) return "Active";122 return `Active since ${d.toLocaleDateString(undefined, { month: "long", year: "numeric" })}`;123 } catch {124 return "Active";125 }126}127128function initialSectionToCard(value: string | null | undefined): CardId | null {129 if (!value) return null;130 if (value === "security") return "protection";131 if (value === "license") return "pro";132 if (133 value === "protection" ||134 value === "vault" ||135 value === "sync" ||136 value === "appearance" ||137 value === "shortcuts" ||138 value === "pro" ||139 value === "feedback"140 ) {141 return value;142 }143 return null;144}145146export function SettingsView(props: SettingsProps) {147 const manifest = chrome.runtime.getManifest();148 const version = manifest.version;149 const initial = props.initialSection;150 const [activeCard, setActiveCard] = createSignal<CardId>(151 props.upgradeHighlight ? "pro" : initialSectionToCard(initial) ?? "pro"152 );153154 createEffect(() => { props.onCardChange?.(activeCard()); });155156 const [justActivated, setJustActivated] = createSignal(false);157 const [isFanning, setIsFanning] = createSignal(false);158159 function triggerCelebration() {160 if (isFanning()) return;161 setJustActivated(false);162 setIsFanning(true);163 setActiveCard("pro");164 const fanOrder: CardId[] = ["protection", "vault", "sync", "appearance", "shortcuts", "feedback"];165 fanOrder.forEach((id, i) => {166 window.setTimeout(() => setActiveCard(id), (i + 1) * PRO_CELEBRATION_CARD_STEP_MS);167 });168 window.setTimeout(() => {169 setActiveCard("pro");170 setIsFanning(false);171 setJustActivated(true);172 window.setTimeout(() => setJustActivated(false), PRO_CELEBRATION_THANK_YOU_MS);173 }, (fanOrder.length + 1) * PRO_CELEBRATION_CARD_STEP_MS);174 }175176 function handleProCelebrationClick(e: MouseEvent) {177 e.stopPropagation();178 triggerCelebration();179 }180181 const [checkingBreaches, setCheckingBreaches] = createSignal(false);182 const [breachResult, setBreachResult] = createSignal<string | null>(null);183 const [licenseInfo, setLicenseInfo] = createSignal<LicenseInfo | null>(null);184 const [licenseKeyInput, setLicenseKeyInput] = createSignal("");185 const [licenseError, setLicenseError] = createSignal<string | null>(null);186 const [, setLicenseSuccess] = createSignal<string | null>(null);187 const [activatingLicense, setActivatingLicense] = createSignal(false);188189 const isPro = () => props.isPro ?? false;190 const cards = createMemo<StackCardSpec[]>(() => [191 {192 id: "pro",193 num: "I · FOREVER",194 title: "Peach Pro",195 lede: isPro() ? "active · paid once, yours forever" : "$39 once · yours forever",196 bgVar: "--stack-pro-bg",197 fgVar: "--stack-pro-fg",198 icon: "spark",199 onIconClick: isPro() ? triggerCelebration : undefined,200 },201 {202 id: "protection",203 num: "II · PROTECT",204 title: "Protection",205 lede: `${props.breachMonitoring ? "breaches watched" : "breach watch off"} · ${props.lockOnClose ? "locks on close" : "manual lock"}`,206 bgVar: "--stack-protect-bg",207 fgVar: "--stack-protect-fg",208 icon: "shield",209 },210 {211 id: "vault",212 num: "III · KEEP",213 title: "Vault",214 lede: `${props.entriesCount} ${props.entriesCount === 1 ? "entry" : "entries"} · backups, history, cleanup`,215 bgVar: "--stack-vault-bg",216 fgVar: "--stack-vault-fg",217 icon: "lock",218 },219 {220 id: "sync",221 num: "IV · ECHO",222 title: "Sync",223 lede: isPro() ? "your devices, one vault" : "Pro · encrypted device sync",224 bgVar: "--stack-sync-bg",225 fgVar: "--stack-sync-fg",226 icon: "sync",227 },228 {229 id: "appearance",230 num: "V · DRESS",231 title: "Appearance",232 lede: `${props.theme} · ${props.accentColor} · ${props.textSize}`,233 bgVar: "--stack-appear-bg",234 fgVar: "--stack-appear-fg",235 icon: "sun",236 },237 {238 id: "shortcuts",239 num: "VI · KEYS",240 title: "Shortcuts",241 lede: `${SHORTCUT_DESCRIPTORS.length} bindings · what your fingers know`,242 bgVar: "--stack-shortcut-bg",243 fgVar: "--stack-shortcut-fg",244 icon: "keys",245 },246 {247 id: "feedback",248 num: "VII · ECHO",249 title: "Feedback",250 lede: `version ${version} · tell us what would make it better`,251 bgVar: "--stack-feedback-bg",252 fgVar: "--stack-feedback-fg",253 icon: "bubble",254 },255 ]);256 const [biometricSupported, setBiometricSupported] = createSignal(false);257 const [biometricStored, setBiometricStored] = createSignal(false);258 const [biometricBusy, setBiometricBusy] = createSignal(false);259 const [biometricError, setBiometricError] = createSignal<string | null>(null);260 const [passkeyState, setPasskeyState] = createSignal<PasskeyProviderState | null>(null);261 const [passkeySaving, setPasskeySaving] = createSignal(false);262263 264 265 interface PendingVouched { deviceId: string; deviceName: string }266 const [pendingVouched, setPendingVouched] = createSignal<PendingVouched[]>([]);267 const [vouchedBusy, setVouchedBusy] = createSignal<string | null>(null);268 269 const [pairedDevices, setPairedDevices] = createSignal<PairedDevice[]>([]);270 const [confirmUnpair, setConfirmUnpair] = createSignal<string | null>(null);271 const [syncStatus, setSyncStatus] = createSignal<"idle" | "syncing" | "error">("idle");272 const [syncFlash, setSyncFlash] = createSignal<string | null>(null);273274 const STALE_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000;275 function syncRelativeTime(when: number | undefined): string {276 if (!when) return "Not synced yet";277 const elapsed = Date.now() - when;278 if (elapsed < 60_000) return "Just now";279 const minutes = Math.floor(elapsed / 60_000);280 if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"} ago`;281 const hours = Math.floor(minutes / 60);282 if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;283 const days = Math.floor(hours / 24);284 return days < 7 ? `${days} day${days === 1 ? "" : "s"} ago` : "Not seen recently";285 }286 function isSyncStale(d: PairedDevice): boolean {287 return d.lastSyncedAt !== undefined && Date.now() - d.lastSyncedAt > STALE_THRESHOLD_MS;288 }289 function deviceTypeLabel(t: PairedDevice["deviceType"]): string {290 switch (t) {291 case "android": return "Android";292 case "ios": return "iPhone";293 case "extension": return "Browser";294 default: return "Device";295 }296 }297298 async function loadPairedDevices() {299 if (!isPro()) return;300 const r = (await sendMessage({ type: "LIST_PAIRED_DEVICES" })) as {301 ok: boolean;302 data?: { devices: PairedDevice[] };303 };304 if (r.ok) setPairedDevices(r.data?.devices ?? []);305 }306 async function unpairDevice(deviceId: string) {307 await sendMessage({ type: "UNPAIR_DEVICE", payload: { deviceId } });308 setConfirmUnpair(null);309 await loadPairedDevices();310 }311 async function syncNow() {312 setSyncStatus("syncing");313 setSyncFlash(null);314 const res = (await sendMessage({315 type: "TRIGGER_SYNC",316 payload: { reason: "manual", force: true },317 })) as { ok: boolean; error?: string };318 setTimeout(async () => {319 const state = (await sendMessage({ type: "GET_SYNC_STATE" })) as {320 ok: boolean;321 data?: { status: "idle" | "syncing" | "error"; entriesReceived?: number; error?: string };322 };323 const s = state.data;324 if (!s) return;325 setSyncStatus(s.status);326 if (s.status === "idle") {327 setSyncFlash((s.entriesReceived ?? 0) > 0 ? `Synced — ${s.entriesReceived} updated` : "Up to date");328 setTimeout(() => setSyncFlash(null), 3000);329 await loadPairedDevices();330 } else if (s.status === "error") {331 setSyncFlash(`Sync failed — ${s.error ?? "no host reachable"}`);332 setTimeout(() => setSyncFlash(null), 4000);333 }334 }, 250);335 if (!res.ok) {336 setSyncStatus("error");337 setSyncFlash(`Sync failed — ${res.error ?? "unknown"}`);338 setTimeout(() => setSyncFlash(null), 4000);339 }340 }341 342 const [rotateConfirmOpen, setRotateConfirmOpen] = createSignal(false);343 const [rotating, setRotating] = createSignal(false);344 const [rotateError, setRotateError] = createSignal<string | null>(null);345346 async function loadPendingVouched() {347 const r = (await sendMessage({ type: "GET_PENDING_VOUCHED_DEVICES" })) as {348 ok: boolean;349 data?: PendingVouched[];350 };351 if (r.ok) setPendingVouched(r.data ?? []);352 }353 async function confirmVouched(deviceId: string) {354 setVouchedBusy(deviceId);355 try {356 await sendMessage({ type: "CONFIRM_VOUCHED_DEVICE", payload: { deviceId } });357 await loadPendingVouched();358 } finally {359 setVouchedBusy(null);360 }361 }362 async function rejectVouched(deviceId: string) {363 setVouchedBusy(deviceId);364 try {365 await sendMessage({ type: "REJECT_VOUCHED_DEVICE", payload: { deviceId } });366 await loadPendingVouched();367 } finally {368 setVouchedBusy(null);369 }370 }371 async function doRotateSyncIdentity() {372 setRotateError(null);373 setRotating(true);374 try {375 const r = (await sendMessage({ type: "ROTATE_SYNC_IDENTITY" })) as {376 ok: boolean;377 error?: string;378 };379 if (!r.ok) {380 setRotateError(r.error ?? "Rotation failed.");381 return;382 }383 setRotateConfirmOpen(false);384 385 386 setShowNewRecoveryWords(true);387 } finally {388 setRotating(false);389 }390 }391392 const [dedupCount, setDedupCount] = createSignal<number | null>(null);393 const [dedupRunning, setDedupRunning] = createSignal(false);394 const [dedupDone, setDedupDone] = createSignal<number | null>(null);395396 const [declinedCardCount, setDeclinedCardCount] = createSignal(0);397398 399 400 401 const [historySnapshots, setHistorySnapshots] = createSignal<VaultSnapshot[]>([]);402 const [historyLoading, setHistoryLoading] = createSignal(false);403 const [historyRestoring, setHistoryRestoring] = createSignal<string | null>(null);404 const [historyShowAll, setHistoryShowAll] = createSignal(false);405406 async function loadVaultHistory() {407 setHistoryLoading(true);408 try {409 const res = await sendMessage({ type: "GET_VAULT_HISTORY" });410 const list = (res as { ok: boolean; data?: VaultSnapshot[] }).data ?? [];411 setHistorySnapshots(list);412 } finally {413 setHistoryLoading(false);414 }415 }416417 async function handleRestoreVaultSnapshot(id: string) {418 if (historyRestoring()) return;419 setHistoryRestoring(id);420 const res = await sendMessage({421 type: "RESTORE_VAULT_SNAPSHOT",422 payload: { snapshotId: id },423 });424 setHistoryRestoring(null);425 if ((res as { ok: boolean }).ok) {426 427 428 props.onBack();429 }430 }431432 function formatSnapshotTime(savedAt: number): string {433 const diff = Date.now() - savedAt;434 if (diff < 60_000) return "Just now";435 if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;436 const d = new Date(savedAt);437 const isToday = new Date().toDateString() === d.toDateString();438 const isYest =439 new Date(Date.now() - 86_400_000).toDateString() === d.toDateString();440 const t = d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });441 if (isToday) return `Today ${t}`;442 if (isYest) return `Yesterday ${t}`;443 return `${d.toLocaleDateString([], { month: "short", day: "numeric" })} ${t}`;444 }445446 function reasonLabel(reason: VaultSnapshot["reason"]): string {447 return (448 {449 local_save: "saved",450 sync_received: "synced",451 import: "imported",452 restore: "restored",453 } as const454 )[reason] ?? reason;455 }456457 const [showVaultAdvanced, setShowVaultAdvanced] = createSignal(false);458459 const [showChangePw, setShowChangePw] = createSignal(false);460 const [changePwCurrent, setChangePwCurrent] = createSignal("");461 const [changePwNew, setChangePwNew] = createSignal("");462 const [changePwConfirm, setChangePwConfirm] = createSignal("");463 const [changePwSaving, setChangePwSaving] = createSignal(false);464 const [changePwError, setChangePwError] = createSignal<string | null>(null);465 const [changePwSuccess] = createSignal(false);466 const [showNewRecoveryWords, setShowNewRecoveryWords] = createSignal(false);467468 469 const [showViewWords, setShowViewWords] = createSignal(false);470 const [viewWordsPassword, setViewWordsPassword] = createSignal("");471 const [viewWordsError, setViewWordsError] = createSignal<string | null>(null);472 const [viewedWords, setViewedWords] = createSignal<string[] | null>(null);473 const [viewWordsBusy, setViewWordsBusy] = createSignal(false);474475 async function handleViewRecoveryWords() {476 setViewWordsError(null);477 if (!viewWordsPassword()) {478 setViewWordsError("Enter your master password.");479 return;480 }481 setViewWordsBusy(true);482 try {483 const r = (await sendMessage({484 type: "GET_RECOVERY_WORDS",485 payload: { password: viewWordsPassword() },486 })) as { ok: boolean; data?: string[]; error?: string };487 if (r.ok) {488 setViewedWords(r.data ?? []);489 setViewWordsPassword("");490 return;491 }492 switch (r.error) {493 case "wrong_password":494 setViewWordsError("Incorrect password");495 break;496 case "password_required":497 setViewWordsError("Enter your master password.");498 break;499 case "recovery_not_setup":500 setViewWordsError("Recovery words aren't set up for this vault yet.");501 break;502 default:503 setViewWordsError("Couldn't retrieve recovery words.");504 }505 } finally {506 setViewWordsBusy(false);507 }508 }509 function closeViewWords() {510 setShowViewWords(false);511 setViewWordsPassword("");512 setViewWordsError(null);513 setViewedWords(null);514 }515516 const [duressEnabled, setDuressEnabled] = createSignal(false);517 const [showDuressSetup, setShowDuressSetup] = createSignal(false);518 const [duressPassword, setDuressPassword] = createSignal("");519 const [duressConfirm, setDuressConfirm] = createSignal("");520 const [duressMainPassword, setDuressMainPassword] = createSignal("");521 const [duressSaving, setDuressSaving] = createSignal(false);522 const [duressError, setDuressError] = createSignal<string | null>(null);523 const [duressSuccess, setDuressSuccess] = createSignal<string | null>(null);524525 const [canaryEntries, setCanaryEntries] = createSignal<Array<{id: string; title: string; canary: Canary}>>([]);526527 const [canaryTestingId, setCanaryTestingId] = createSignal<string | null>(null);528 const [canaryTestResult, setCanaryTestResult] = createSignal<{id: string; ok: boolean} | null>(null);529530 const [fbCategory, setFbCategory] = createSignal("Bug report");531 const [fbMessage, setFbMessage] = createSignal("");532 type FeedbackState = 'idle' | 'sending' | 'sent' | 'error';533 const [feedbackState, setFeedbackState] = createSignal<FeedbackState>('idle');534535 createEffect(() => {536 if (props.upgradeHighlight) {537 setActiveCard("pro");538 }539 });540541 onMount(async () => {542 const [licenseRes, passkeyRes, duressRes, settingsRes, keyRes] = await Promise.all([543 sendMessage({ type: "GET_LICENSE" }),544 sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }),545 sendMessage({ type: "IS_DURESS_ENABLED" }),546 sendMessage({ type: "GET_SETTINGS" }),547 sendMessage({ type: "GET_LICENSE_KEY" }),548 ]);549550 551 552 void loadVaultHistory();553554 555 556 void loadPendingVouched();557 void loadPairedDevices();558 const onBroadcast = (msg: unknown) => {559 if ((msg as { type?: string } | null)?.type === "VOUCHED_DEVICE_PENDING") {560 void loadPendingVouched();561 }562 };563 chrome.runtime?.onMessage?.addListener(onBroadcast);564 onCleanup(() => {565 try { chrome.runtime?.onMessage?.removeListener(onBroadcast); } catch { }566 });567568 if ((licenseRes as { ok: boolean }).ok) {569 setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null);570 }571572 if ((passkeyRes as { ok: boolean }).ok) {573 setPasskeyState(574 (passkeyRes as { ok: boolean; data?: PasskeyProviderState }).data ?? null575 );576 }577578 if ((duressRes as { ok: boolean }).ok) {579 setDuressEnabled((duressRes as { ok: boolean; data?: boolean }).data ?? false);580 }581582 if ((settingsRes as { ok: boolean }).ok) {583 const sData = (settingsRes as { ok: boolean; data?: { declinedCardFingerprints?: string[] } }).data;584 setDeclinedCardCount(sData?.declinedCardFingerprints?.length ?? 0);585 }586587 if ((keyRes as { ok: boolean }).ok) {588 setLicenseKey((keyRes as { ok: boolean; data?: string }).data ?? null);589 }590591 const entriesRes = await sendMessage({ type: "GET_ENTRIES" });592 if ((entriesRes as { ok: boolean }).ok) {593 const entries = (entriesRes as { ok: boolean; data?: Array<{id: string; title: string; canary?: Canary}> }).data ?? [];594 setCanaryEntries(595 entries596 .filter((e) => e.canary?.webhookUrl)597 .map((e) => ({ id: e.id, title: e.title, canary: e.canary! }))598 );599 }600601602 if (isNativePlatform()) {603 const biometric = nativePlugin<{ isAvailable: () => Promise<{ available: boolean }> }>("BiometricPlugin");604 const keystore = nativePlugin<{ hasMasterKey: () => Promise<{ exists: boolean }> }>("KeystorePlugin");605 try {606 if (biometric) {607 const a = await biometric.isAvailable();608 setBiometricSupported(a.available);609 }610 if (keystore) {611 const k = await keystore.hasMasterKey();612 setBiometricStored(k.exists);613 }614 } catch { }615 }616 });617618 async function handleEnableBiometrics() {619 setBiometricBusy(true);620 setBiometricError(null);621 try {622 const keyRes = (await sendMessage({ type: "GET_DERIVED_KEY" })) as {623 ok: boolean;624 data?: { masterKeyHex: string };625 error?: string;626 };627 if (!keyRes.ok || !keyRes.data?.masterKeyHex) {628 setBiometricError(keyRes.error === "vault_locked"629 ? "unlock the vault first"630 : "couldn't read derived key");631 return;632 }633 const keystore = nativePlugin<{ storeMasterKey: (p: { masterKeyHex: string }) => Promise<void> }>("KeystorePlugin");634 if (!keystore) {635 setBiometricError("biometric plugin unavailable");636 return;637 }638 await keystore.storeMasterKey({ masterKeyHex: keyRes.data.masterKeyHex });639 await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: true } });640 setBiometricStored(true);641 } catch (e) {642 setBiometricError(e instanceof Error ? e.message : "failed to enable biometrics");643 } finally {644 setBiometricBusy(false);645 }646 }647648 async function handleDisableBiometrics() {649 setBiometricBusy(true);650 setBiometricError(null);651 try {652 const keystore = nativePlugin<{ clearMasterKey: () => Promise<void> }>("KeystorePlugin");653 if (keystore) await keystore.clearMasterKey();654 await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: false } });655 setBiometricStored(false);656 } catch (e) {657 setBiometricError(e instanceof Error ? e.message : "failed to disable biometrics");658 } finally {659 setBiometricBusy(false);660 }661 }662663 async function handleChangePassword() {664 setChangePwError(null);665 if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; }666 if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; }667 if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; }668 if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; }669 setChangePwSaving(true);670 const res = await sendMessage({671 type: "CHANGE_PASSWORD",672 payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() },673 }) as { ok: boolean; error?: string };674 if (res.ok) {675 setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");676 setShowChangePw(false);677 678 679 680 681 await chrome.storage?.local?.set?.({ peach_recovery_pending: "1" });682 setShowNewRecoveryWords(true);683 } else {684 setChangePwError(res.error ?? "failed to change password");685 }686 setChangePwSaving(false);687 }688689 const [copied, setCopied] = createSignal(false);690691 async function copyLicenseKey(key: string) {692 await navigator.clipboard.writeText(key);693 setCopied(true);694 setTimeout(() => setCopied(false), 2000);695 }696697 async function handleActivateLicense() {698 const key = licenseKeyInput().trim();699 if (!key) return;700 setActivatingLicense(true);701 setLicenseError(null);702 setLicenseSuccess(null);703 const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } });704 setActivatingLicense(false);705 if ((res as { ok: boolean }).ok) {706 const data = (res as { ok: boolean; data?: LicenseInfo }).data;707 if (data?.valid) {708 setLicenseInfo(data);709 setLicenseKey(key);710 setLicenseSuccess("License activated!");711 setLicenseKeyInput("");712 } else {713 setLicenseError("Invalid license key");714 }715 } else {716 setLicenseError("Activation failed");717 }718 }719720 async function handleTogglePasskeys() {721 const current = passkeyState();722 if (!current || !current.supported) return;723 setPasskeySaving(true);724 const res = await sendMessage({725 type: "SET_PASSKEY_PROVIDER_ENABLED",726 payload: { enabled: !current.enabled },727 });728 setPasskeySaving(false);729 if ((res as { ok: boolean }).ok) {730 setPasskeyState(731 (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null732 );733 }734 }735736 async function handleCheckAllBreaches() {737 setCheckingBreaches(true);738 setBreachResult(null);739 const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });740 setCheckingBreaches(false);741 if ((res as { ok: boolean }).ok) {742 const data = (res as { ok: boolean; data?: Record<string, number> }).data ?? {};743 const breached = Object.values(data).filter((count) => count > 0).length;744 setBreachResult(745 breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean"746 );747 props.onBreachCheckDone();748 }749 }750751752 async function handleSetupDuress() {753 const mainPw = duressMainPassword().trim();754 const duressPw = duressPassword().trim();755 const confirmPw = duressConfirm().trim();756757 if (!mainPw) { setDuressError("enter your main password to confirm"); return; }758 if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; }759 if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; }760 if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; }761762 setDuressSaving(true);763 setDuressError(null);764765 const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };766 if (!verifyRes.ok || !verifyRes.data) {767 setDuressError("incorrect main password");768 setDuressSaving(false);769 return;770 }771772 const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string };773 setDuressSaving(false);774 if (res.ok) {775 setDuressEnabled(true);776 setShowDuressSetup(false);777 setDuressPassword("");778 setDuressConfirm("");779 setDuressMainPassword("");780 setDuressSuccess("Duress password set. If entered, a decoy vault will be shown.");781 setTimeout(() => setDuressSuccess(null), 5000);782 } else {783 setDuressError(784 res.error === "session_expired"785 ? "Vault session expired — lock and unlock Peach, then try again"786 : res.error ?? "failed to set duress password"787 );788 }789 }790791 async function handleClearDeclinedCards() {792 await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } });793 setDeclinedCardCount(0);794 }795796 async function handleCountDuplicates() {797 setDedupRunning(true);798 setDedupDone(null);799 const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number };800 setDedupRunning(false);801 if (res.ok) setDedupCount(res.data ?? 0);802 }803804 async function handleDeduplicate() {805 setDedupRunning(true);806 const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number };807 setDedupRunning(false);808 if (res.ok) {809 setDedupDone(res.data ?? 0);810 setDedupCount(0);811 }812 }813814 async function handleDisableDuress() {815 const mainPw = duressMainPassword().trim();816 if (!mainPw) { setDuressError("enter your main password to confirm"); return; }817818 setDuressSaving(true);819 setDuressError(null);820821 const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };822 if (!verifyRes.ok || !verifyRes.data) {823 setDuressError("incorrect main password");824 setDuressSaving(false);825 return;826 }827828 const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string };829 setDuressSaving(false);830 if (res.ok) {831 setDuressEnabled(false);832 setShowDuressSetup(false);833 setDuressMainPassword("");834 setDuressSuccess("Duress password disabled.");835 setTimeout(() => setDuressSuccess(null), 4000);836 } else {837 setDuressError(res.error ?? "failed to disable duress password");838 }839 }840841 async function handleSubmitFeedback() {842 const message = sanitizeFeedbackMessage(fbMessage());843 if (!message) return;844845 setFeedbackState('sending');846847 try {848 const hasDataPermission = await requestFeedbackDataPermission();849 if (!hasDataPermission) {850 setFeedbackState('idle');851 return;852 }853854 const category = FEEDBACK_CATEGORIES.includes(fbCategory())855 ? fbCategory()856 : FEEDBACK_CATEGORIES[0];857 const res = await fetch('https://api.web3forms.com/submit', {858 method: 'POST',859 headers: { 'Content-Type': 'application/json' },860 body: JSON.stringify({861 access_key: WEB3FORMS_KEY,862 subject: `Peach Feedback - ${category}`,863 message,864 from_name: 'Peach User',865 866 botcheck: '',867 }),868 });869870 if (!res.ok) throw new Error(`HTTP ${res.status}`);871 const data = await res.json() as { success: boolean; message?: string };872 if (!data.success) throw new Error(data.message ?? 'unknown error');873874 setFeedbackState('sent');875 setFbMessage('');876 setFbCategory(FEEDBACK_CATEGORIES[0]);877 } catch (err) {878 console.error('[feedback]', err);879 setFeedbackState('error');880 setTimeout(() => setFeedbackState('idle'), 4000);881 }882 }883884 function renderSecurity() {885 return (886 <div class="peach-settings-body">887 <div class="peach-settings-group">888 <p class="peach-settings-sub-label">LOCKING</p>889 <div class="peach-settings-row">890 <span>Lock on close</span>891 <button892 class={`peach-toggle ${props.lockOnClose ? "is-on" : ""}`}893 onClick={() => void props.onLockOnCloseChange(!props.lockOnClose)}894 aria-label="Toggle lock on close"895 >896 <span class="peach-toggle-knob"></span>897 </button>898 </div>899 <div class="peach-settings-row">900 <span>Auto-lock after</span>901 <select902 class="peach-settings-select"903 value={String(props.autoLockMinutes)}904 onChange={(e) => void props.onAutoLockMinutesChange(Number(e.currentTarget.value))}905 >906 <option value="1">1 minute</option>907 <option value="5">5 minutes</option>908 <option value="15">15 minutes</option>909 <option value="30">30 minutes</option>910 <option value="0">Never</option>911 </select>912 </div>913914 <Show when={isNativePlatform() && biometricSupported()}>915 <p class="peach-settings-sub-label">BIOMETRICS</p>916 <div class="peach-settings-row">917 <span>Unlock with biometrics</span>918 <button919 class={`peach-toggle ${biometricStored() ? "is-on" : ""}`}920 onClick={() => void (biometricStored() ? handleDisableBiometrics() : handleEnableBiometrics())}921 disabled={biometricBusy()}922 aria-label="Toggle biometric unlock"923 >924 <span class="peach-toggle-knob"></span>925 </button>926 </div>927 <Show when={biometricError()}>928 <div class="peach-settings-row peach-settings-row--meta">929 <span class="peach-hint peach-hint-error">{biometricError()}</span>930 </div>931 </Show>932 </Show>933934 <p class="peach-settings-sub-label">BREACH PROTECTION</p>935 <div class="peach-settings-row">936 <span>Breach monitoring</span>937 <button938 class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}939 onClick={() => void props.onBreachMonitoringChange(!props.breachMonitoring)}940 aria-label="Toggle breach monitoring"941 >942 <span class="peach-toggle-knob"></span>943 </button>944 </div>945 <div class="peach-settings-row peach-settings-row--meta">946 <span>Check all passwords</span>947 <button948 class="peach-btn peach-btn--subtle peach-btn--sm"949 onClick={() => void handleCheckAllBreaches()}950 disabled={checkingBreaches()}951 >952 {checkingBreaches() ? "checking..." : "run"}953 </button>954 </div>955 <Show when={breachResult()}>956 <div class="peach-settings-row peach-settings-row--meta">957 <span>Last check</span>958 <span class="peach-settings-value">{breachResult()}</span>959 </div>960 </Show>961962 <p class="peach-settings-sub-label">MASTER PASSWORD</p>963 <div class="peach-settings-row peach-settings-row--meta">964 <span>Master password</span>965 <button966 class="peach-btn peach-btn--subtle peach-btn--sm"967 onClick={() => { setShowChangePw((v) => !v); setChangePwError(null); }}968 >969 {showChangePw() ? "cancel" : "change"}970 </button>971 </div>972 <Show when={showChangePw()}>973 <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">974 <input975 class="peach-settings-input"976 type="password"977 placeholder="current password"978 value={changePwCurrent()}979 onInput={(e) => setChangePwCurrent((e.target as HTMLInputElement).value)}980 autocomplete="current-password"981 />982 <input983 class="peach-settings-input"984 type="password"985 placeholder="new password (min 12 chars)"986 value={changePwNew()}987 onInput={(e) => setChangePwNew((e.target as HTMLInputElement).value)}988 autocomplete="new-password"989 />990 <input991 class="peach-settings-input"992 type="password"993 placeholder="confirm new password"994 value={changePwConfirm()}995 onInput={(e) => setChangePwConfirm((e.target as HTMLInputElement).value)}996 autocomplete="new-password"997 onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }}998 />999 <Show when={changePwError()}>1000 <p class="peach-hint peach-hint-error" style="margin:0">{changePwError()}</p>1001 </Show>1002 <Show when={changePwSuccess()}>1003 <p class="peach-hint" style="margin:0;color:var(--peach-accent)">password changed ✓</p>1004 </Show>1005 <button1006 class="peach-btn peach-btn--accent peach-btn--sm"1007 style="align-self:flex-start"1008 onClick={() => void handleChangePassword()}1009 disabled={changePwSaving() || !changePwCurrent() || !changePwNew() || !changePwConfirm()}1010 >1011 {changePwSaving() ? "saving…" : "save new password"}1012 </button>1013 </div>1014 </Show>10151016 {}1017 <div class="peach-settings-row peach-settings-row--meta">1018 <span>Recovery words</span>1019 <button1020 class="peach-btn peach-btn--subtle peach-btn--sm"1021 onClick={() => {1022 if (showViewWords()) { closeViewWords(); } else { setShowViewWords(true); }1023 }}1024 >1025 {showViewWords() ? "cancel" : "view"}1026 </button>1027 </div>1028 <Show when={showViewWords()}>1029 <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1030 <Show1031 when={viewedWords()}1032 fallback={1033 <>1034 <p class="peach-hint" style="margin:0;color:var(--peach-t2)">1035 Enter your master password to reveal your recovery words.1036 </p>1037 <input1038 class="peach-settings-input"1039 type="password"1040 placeholder="master password"1041 value={viewWordsPassword()}1042 onInput={(e) => setViewWordsPassword((e.target as HTMLInputElement).value)}1043 autocomplete="current-password"1044 onKeyDown={(e) => { if (e.key === "Enter") void handleViewRecoveryWords(); }}1045 />1046 <Show when={viewWordsError()}>1047 <p class="peach-hint peach-hint-error" style="margin:0">{viewWordsError()}</p>1048 </Show>1049 <button1050 class="peach-btn peach-btn--accent peach-btn--sm"1051 style="align-self:flex-start"1052 onClick={() => void handleViewRecoveryWords()}1053 disabled={viewWordsBusy() || !viewWordsPassword()}1054 >1055 {viewWordsBusy() ? "checking…" : "reveal recovery words"}1056 </button>1057 </>1058 }1059 >1060 <div class="peach-recovery-words-grid">1061 <For each={viewedWords()!}>1062 {(word, i) => (1063 <span class="peach-recovery-word"><b>{i() + 1}</b> {word}</span>1064 )}1065 </For>1066 </div>1067 <button1068 class="peach-btn peach-btn--subtle peach-btn--sm"1069 style="align-self:flex-start"1070 onClick={closeViewWords}1071 >done</button>1072 </Show>1073 </div>1074 </Show>10751076 <p class="peach-settings-sub-label">1077 DURESS PASSWORD1078 <Show when={!isPro()}>1079 <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>1080 </Show>1081 </p>1082 <div class="peach-settings-row peach-settings-row--top">1083 <div class="peach-settings-row-main">1084 <span>1085 Duress password1086 <Show when={!isPro()}>1087 <span class="peach-pro-badge">Pro</span>1088 </Show>1089 </span>1090 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">1091 If entered under coercion, shows a decoy empty vault instead of your real one.1092 </span>1093 </div>1094 <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>1095 {duressEnabled() ? "on" : "off"}1096 </div>1097 </div>10981099 <Show when={duressEnabled() && !showDuressSetup()}>1100 <div class="peach-settings-row peach-settings-row--meta">1101 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1102 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.1103 </span>1104 </div>1105 </Show>11061107 <Show when={duressSuccess()}>1108 <div class="peach-settings-row peach-settings-row--meta">1109 <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>1110 </div>1111 </Show>11121113 <Show when={!showDuressSetup()}>1114 <div class="peach-settings-row peach-settings-row--meta">1115 <button1116 class="peach-btn peach-btn--subtle peach-btn--sm"1117 onClick={() => {1118 1119 1120 1121 1122 1123 if (!isPro() && !duressEnabled()) {1124 props.onProGate?.("duress_vault");1125 setActiveCard("pro");1126 return;1127 }1128 setShowDuressSetup(true);1129 setDuressError(null);1130 setDuressSuccess(null);1131 setDuressPassword("");1132 setDuressConfirm("");1133 setDuressMainPassword("");1134 }}1135 >1136 {duressEnabled() ? "Change / disable" : "Set up"}1137 </button>1138 </div>1139 </Show>11401141 <Show when={showDuressSetup()}>1142 <div class="peach-duress-form">1143 <label class="peach-label">main password (to confirm)</label>1144 <input1145 class="peach-settings-input"1146 type="password"1147 placeholder="your current master password"1148 autocomplete="current-password"1149 value={duressMainPassword()}1150 onInput={(e) => setDuressMainPassword(e.currentTarget.value)}1151 />1152 <Show when={!duressEnabled()}>1153 <label class="peach-label">new duress password</label>1154 <input1155 class="peach-settings-input"1156 type="password"1157 placeholder="duress password (min 8 chars)"1158 autocomplete="new-password"1159 value={duressPassword()}1160 onInput={(e) => setDuressPassword(e.currentTarget.value)}1161 />1162 <label class="peach-label">confirm duress password</label>1163 <input1164 class="peach-settings-input"1165 type="password"1166 placeholder="confirm duress password"1167 autocomplete="new-password"1168 value={duressConfirm()}1169 onInput={(e) => setDuressConfirm(e.currentTarget.value)}1170 />1171 </Show>11721173 <Show when={duressError()}>1174 <p class="peach-hint peach-hint-error">{duressError()}</p>1175 </Show>11761177 <div class="peach-duress-actions">1178 <button1179 class="peach-btn peach-btn--subtle peach-btn--sm"1180 onClick={() => { setShowDuressSetup(false); setDuressError(null); }}1181 >1182 cancel1183 </button>1184 <Show when={duressEnabled()}>1185 <button1186 class="peach-btn peach-btn--ghost peach-btn--sm"1187 style="color:var(--peach-breach)"1188 onClick={() => void handleDisableDuress()}1189 disabled={duressSaving()}1190 >1191 {duressSaving() ? "disabling..." : "disable duress"}1192 </button>1193 </Show>1194 <Show when={!duressEnabled()}>1195 <button1196 class="peach-btn peach-btn--accent peach-btn--sm"1197 onClick={() => void handleSetupDuress()}1198 disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}1199 >1200 {duressSaving() ? "saving..." : "save"}1201 </button>1202 </Show>1203 </div>1204 </div>1205 </Show>12061207 <p class="peach-settings-sub-label">1208 CANARY ENTRIES1209 <Show when={!isPro()}>1210 <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>1211 </Show>1212 </p>1213 <div class="peach-settings-row peach-settings-row--top">1214 <div class="peach-settings-row-main">1215 <span>1216 Canary entries1217 <Show when={!isPro()}>1218 <span class="peach-pro-badge">Pro</span>1219 </Show>1220 </span>1221 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1222 Silently pings a webhook when an entry is accessed. Configure canary on any entry in your vault.1223 </span>1224 </div>1225 <span class="peach-settings-value" style="flex-shrink:0">1226 {canaryEntries().length > 0 ? `${canaryEntries().length} active` : "none"}1227 </span>1228 </div>1229 <Show when={canaryEntries().length > 0}>1230 <For each={canaryEntries()}>1231 {(entry) => (1232 <div class="peach-settings-row peach-settings-row--meta">1233 <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0">{entry.title}</span>1234 <button1235 class="peach-btn peach-btn--subtle peach-btn--sm"1236 onClick={() => void handleTestCanary(entry)}1237 disabled={canaryTestingId() === entry.id}1238 style="flex-shrink:0"1239 >1240 {canaryTestingId() === entry.id1241 ? "sending…"1242 : canaryTestResult()?.id === entry.id1243 ? (canaryTestResult()!.ok ? "sent ✓" : "failed ✗")1244 : "test"}1245 </button>1246 </div>1247 )}1248 </For>1249 </Show>12501251 <p class="peach-settings-sub-label">PASSKEYS</p>1252 <div class="peach-settings-row peach-settings-row--top">1253 <div class="peach-settings-row-main">1254 <span>Intercept passkey requests</span>1255 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">1256 Peach can handle passkey sign-in and creation on supported Chromium browsers.1257 </span>1258 </div>1259 <button1260 class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}1261 onClick={() => void handleTogglePasskeys()}1262 aria-label="Toggle passkey interception"1263 disabled={!passkeyState()?.supported || passkeySaving()}1264 >1265 <span class="peach-toggle-knob"></span>1266 </button>1267 </div>1268 <div class="peach-settings-row peach-settings-row--meta">1269 <span>Status</span>1270 <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>1271 </div>1272 <div class="peach-settings-row peach-settings-row--meta">1273 <span>Browser</span>1274 <span class="peach-settings-value">1275 {passkeyState()?.browserLabel ?? "Unknown"}1276 {passkeyState()?.supported ? " ✓" : " (interception unavailable)"}1277 </span>1278 </div>1279 <div class="peach-info-notice" style="margin-top:8px">1280 Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.1281 </div>1282 <Show when={passkeyState()?.conflict}>1283 <div class="peach-settings-row peach-settings-row--meta">1284 <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>1285 </div>1286 </Show>1287 </div>1288 </div>1289 );1290 }12911292 function renderVault() {1293 return (1294 <div class="peach-settings-body">1295 <div class="peach-settings-group">1296 <p class="peach-settings-sub-label">ENTRIES</p>1297 <div class="peach-settings-row peach-settings-row--meta">1298 <span>Entries</span>1299 <span class="peach-settings-value">{props.entriesCount}</span>1300 </div>1301 <div class="peach-settings-row peach-settings-row--meta">1302 <span>Duplicates</span>1303 <Show when={dedupDone() !== null} fallback={1304 <button1305 class="peach-btn peach-btn--subtle peach-btn--sm"1306 onClick={() => void handleCountDuplicates()}1307 disabled={dedupRunning()}1308 >1309 {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}1310 </button>1311 }>1312 <span class="peach-settings-value" style="color:var(--peach-sync-ok)">1313 {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}1314 </span>1315 </Show>1316 </div>1317 <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>1318 <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">1319 <div class="peach-restore-warning">1320 {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.1321 </div>1322 <div style="display:flex;gap:6px">1323 <button1324 class="peach-btn peach-btn--subtle peach-btn--sm"1325 onClick={() => setDedupCount(null)}1326 >1327 cancel1328 </button>1329 <button1330 class="peach-btn peach-btn--accent peach-btn--sm"1331 onClick={() => void handleDeduplicate()}1332 disabled={dedupRunning()}1333 >1334 {dedupRunning() ? "removing..." : "remove duplicates"}1335 </button>1336 </div>1337 </div>1338 </Show>13391340 <p class="peach-settings-sub-label">MAINTENANCE</p>1341 <div class="peach-settings-row peach-settings-row--meta">1342 <span>Trash auto-delete</span>1343 <select1344 class="peach-settings-select"1345 value={String(props.trashRetentionDays)}1346 onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}1347 >1348 <option value="7">7 days</option>1349 <option value="30">30 days</option>1350 <option value="90">90 days</option>1351 <option value="0">Never</option>1352 </select>1353 </div>1354 <div class="peach-settings-row peach-settings-row--meta">1355 <span>Advanced Vault Health</span>1356 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>1357 open1358 </button>1359 </div>1360 <Show when={declinedCardCount() > 0}>1361 <div class="peach-settings-row peach-settings-row--meta">1362 <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>1363 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>1364 Clear1365 </button>1366 </div>1367 </Show>13681369 <p class="peach-settings-sub-label">DATA</p>1370 <div class="peach-settings-row peach-settings-row--meta">1371 <span>Import passwords</span>1372 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>1373 open1374 </button>1375 </div>1376 <div class="peach-settings-row peach-settings-row--meta">1377 <span>Export backup</span>1378 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onExport}>1379 open1380 </button>1381 </div>1382 <div class="peach-settings-row peach-settings-row--meta">1383 <span>1384 Peach Codex1385 <Show when={!isPro()}>1386 <span class="peach-pro-badge">Pro</span>1387 </Show>1388 </span>1389 <button1390 class="peach-btn peach-btn--subtle peach-btn--sm"1391 onClick={() => props.onOpenCodexGenerate?.()}1392 >1393 open1394 </button>1395 </div>1396 {1397}1398 <div class="peach-settings-row peach-settings-row--meta">1399 <span>Restore from Codex</span>1400 <button1401 class="peach-btn peach-btn--subtle peach-btn--sm"1402 onClick={() => props.onOpenCodexRestore?.()}1403 >1404 open1405 </button>1406 </div>14071408 <span class="peach-settings-sub-label">VAULT HISTORY</span>1409 <Show when={historyLoading()}>1410 <p class="peach-settings-hint">Loading…</p>1411 </Show>1412 <Show when={!historyLoading() && historySnapshots().length === 0}>1413 <p class="peach-settings-hint">1414 No snapshots yet. Peach saves automatically before every change —1415 saves, syncs, and imports.1416 </p>1417 </Show>1418 <Show when={!historyLoading() && historySnapshots().length > 0}>1419 <p class="peach-settings-hint">1420 Restoring locks your vault. Unlock with the password that1421 encrypted that snapshot to confirm.1422 </p>1423 <div class="peach-history-list">1424 <For each={historySnapshots().slice(0, historyShowAll() ? undefined : 3)}>1425 {(snap) => (1426 <div class="peach-history-row">1427 <div class="peach-history-meta">1428 <span class="peach-history-time">1429 {formatSnapshotTime(snap.savedAt)}1430 </span>1431 <span class="peach-history-sub">1432 {[1433 snap.entryCount !== null1434 ? `up to ${snap.entryCount} ${snap.entryCount === 1 ? "entry" : "entries"}`1435 : null,1436 reasonLabel(snap.reason),1437 ]1438 .filter(Boolean)1439 .join(" · ")}1440 </span>1441 </div>1442 <button1443 class={`peach-btn peach-btn--subtle peach-btn--sm peach-history-btn${1444 historyRestoring() === snap.id ? " is-busy" : ""1445 }`}1446 onClick={() => void handleRestoreVaultSnapshot(snap.id)}1447 disabled={historyRestoring() !== null}1448 >1449 <Show when={historyRestoring() === snap.id} fallback="Restore">1450 …1451 </Show>1452 </button>1453 </div>1454 )}1455 </For>1456 </div>1457 <Show when={historySnapshots().length > 3}>1458 <button1459 class="peach-settings-advanced-toggle"1460 onClick={() => setHistoryShowAll((v) => !v)}1461 aria-expanded={historyShowAll()}1462 >1463 <span class="peach-settings-hint" style="margin:0">1464 {historyShowAll() ? "show less" : `${historySnapshots().length - 3} older`}1465 </span>1466 <span class="peach-settings-advanced-chevron">1467 {historyShowAll() ? "▲" : "▼"}1468 </span>1469 </button>1470 </Show>1471 <p class="peach-history-footer">1472 {historySnapshots().length} of 10 snapshots1473 </p>1474 </Show>14751476 <button1477 class="peach-settings-advanced-toggle"1478 onClick={() => setShowVaultAdvanced((v) => !v)}1479 aria-expanded={showVaultAdvanced()}1480 >1481 <span class="peach-settings-sub-label" style="margin:0">ADVANCED</span>1482 <span class="peach-settings-advanced-chevron">1483 {showVaultAdvanced() ? "▲" : "▼"}1484 </span>1485 </button>14861487 <Show when={showVaultAdvanced()}>1488 <div class="peach-danger-card">1489 <div class="peach-danger-card-head">1490 <span class="peach-danger-card-glyph" aria-hidden="true">⟳</span>1491 <h3 class="peach-danger-card-title">Replace vault from backup</h3>1492 </div>1493 <p class="peach-danger-card-copy">1494 Wipes the current vault and restores from a backup file.1495 Recoverable only if your backup is good.1496 </p>1497 <button1498 class="peach-danger-card-button"1499 onClick={() => props.onOpenFileImport?.()}1500 >1501 Choose backup file1502 </button>1503 </div>15041505 <Show when={props.onOpenPanic}>1506 <div class="peach-danger-card peach-danger-card--hazard">1507 <div class="peach-danger-card-head">1508 <span class="peach-danger-card-glyph" aria-hidden="true">⚠</span>1509 <h3 class="peach-danger-card-title">Destroy vault</h3>1510 </div>1511 <p class="peach-danger-card-copy">1512 Permanently wipes every entry, setting, and license on this1513 device. There is no recovery.1514 </p>1515 <button1516 class="peach-danger-card-button"1517 onClick={() => props.onOpenPanic?.()}1518 >1519 Begin destruction1520 </button>1521 </div>1522 </Show>1523 </Show>1524 </div>1525 </div>1526 );1527 }15281529 function renderSync() {1530 return (1531 <div class="peach-settings-body">1532 <div class="peach-settings-group">1533 <div class="peach-settings-row peach-settings-row--meta">1534 <span>Status</span>1535 <span class="peach-settings-value" style="display:inline-flex;align-items:center;gap:8px">1536 {isPro() ? "Pro" : "Free"}1537 <span class={`stk-pip ${isPro() ? "is-on" : ""}`} aria-hidden="true" />1538 </span>1539 </div>15401541 {}1542 <p class="peach-settings-sub-label" style="margin-top:10px">PAIR A DEVICE</p>1543 {15441545}1546 <button1547 class="peach-sync-pair-option"1548 onClick={() => props.onOpenSyncPairing?.("code")}1549 >1550 <span class="peach-sync-pair-option-icon" aria-hidden="true">1551 <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">1552 <rect x="6" y="2.5" width="8" height="15" rx="1.6" />1553 <path d="M9 15 L11 15" />1554 </svg>1555 </span>1556 <span class="peach-sync-pair-option-main">1557 <span class="peach-sync-pair-option-title">Show a code</span>1558 <span class="peach-sync-pair-option-sub">This browser shows a code; scan it with Peach on your phone</span>1559 </span>1560 <span class="peach-sync-pair-option-chevron" aria-hidden="true">›</span>1561 </button>1562 <Show when={!isPro()}>1563 <p class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2);margin:8px 2px 0">1564 At least one device needs Peach Pro — pair with a Pro device and it carries over to here.1565 </p>1566 </Show>15671568 {1569157015711572}1573 <p class="peach-settings-sub-label" style="margin-top:12px">HOW THIS VAULT SYNCS</p>1574 <SyncModeSelector />15751576 {}1577 <Show when={pairedDevices().length > 0}>1578 <p class="peach-settings-sub-label" style="margin-top:12px">PAIRED DEVICES</p>1579 <For each={pairedDevices()}>1580 {(d) => (1581 <div class="peach-settings-row peach-settings-row--top">1582 <div class="peach-settings-row-main">1583 <span>{d.deviceName ?? "Unnamed device"}</span>1584 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1585 {deviceTypeLabel(d.deviceType)} · {syncRelativeTime(d.lastSyncedAt)}{isSyncStale(d) ? " · not seen recently" : ""}1586 </span>1587 </div>1588 <Show1589 when={confirmUnpair() === d.deviceId}1590 fallback={1591 <button1592 class="peach-btn peach-btn--subtle peach-btn--sm"1593 onClick={() => setConfirmUnpair(d.deviceId)}1594 >remove</button>1595 }1596 >1597 {15981599160016011602}1603 <div class="peach-unpair-confirm">1604 <p class="peach-unpair-confirm-note">1605 Stops this device receiving future changes. It doesn't1606 lock it out of what it already has — if it's lost or1607 stolen, also change your master password and generate1608 new recovery words.1609 </p>1610 <div style="display:flex;gap:6px;justify-content:flex-end">1611 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => setConfirmUnpair(null)}>cancel</button>1612 <button1613 class="peach-btn peach-btn--sm"1614 style="background:var(--peach-breach);border-color:var(--peach-breach)"1615 onClick={() => void unpairDevice(d.deviceId)}1616 >remove</button>1617 </div>1618 </div>1619 </Show>1620 </div>1621 )}1622 </For>1623 <div class="peach-settings-row peach-settings-row--meta">1624 <span class="peach-settings-value" style="color:var(--peach-t2)">{syncFlash() ?? ""}</span>1625 <button1626 class="peach-btn peach-btn--subtle peach-btn--sm"1627 disabled={syncStatus() === "syncing"}1628 onClick={() => void syncNow()}1629 >{syncStatus() === "syncing" ? "syncing…" : "sync now"}</button>1630 </div>1631 </Show>1632 {}1633 <Show when={pendingVouched().length > 0}>1634 <p class="peach-settings-sub-label">1635 PENDING DEVICES1636 <span class="peach-pro-badge" style="background:#C8A840;color:#1a1a1a">{pendingVouched().length}</span>1637 </p>1638 <For each={pendingVouched()}>1639 {(dev) => (1640 <div class="peach-settings-row peach-settings-row--top">1641 <div class="peach-settings-row-main">1642 <span>{dev.deviceName}</span>1643 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1644 Was added by another of your devices. Allow it to sync with this vault?1645 </span>1646 </div>1647 <div style="display:flex;gap:6px;flex-shrink:0">1648 <button1649 class="peach-btn peach-btn--sm"1650 disabled={vouchedBusy() === dev.deviceId}1651 onClick={() => void confirmVouched(dev.deviceId)}1652 >Allow</button>1653 <button1654 class="peach-btn peach-btn--subtle peach-btn--sm"1655 disabled={vouchedBusy() === dev.deviceId}1656 onClick={() => void rejectVouched(dev.deviceId)}1657 >Reject</button>1658 </div>1659 </div>1660 )}1661 </For>1662 </Show>16631664 {}1665 <p class="peach-settings-sub-label" style="margin-top:12px">RECOVERY</p>1666 <Show1667 when={rotateConfirmOpen()}1668 fallback={1669 <div class="peach-settings-row peach-settings-row--top">1670 <div class="peach-settings-row-main">1671 <span>Rotate sync identity</span>1672 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1673 Use this if a paired device was lost or stolen. Re-keys the vault so the old device's key is useless.1674 </span>1675 </div>1676 <button1677 class="peach-btn peach-btn--subtle peach-btn--sm"1678 style="color:var(--peach-breach);border-color:color-mix(in srgb,var(--peach-breach) 40%,transparent)"1679 onClick={() => { setRotateError(null); setRotateConfirmOpen(true); }}1680 >rotate</button>1681 </div>1682 }1683 >1684 <div class="peach-info-notice" style="margin-top:8px;border-color:color-mix(in srgb,var(--peach-breach) 40%,transparent)">1685 This will disconnect all paired devices — they will need to pair again — and your 12 recovery words will change. You'll be shown the new words next.1686 <Show when={rotateError()}>1687 <div style="color:var(--peach-breach);margin-top:6px">{rotateError()}</div>1688 </Show>1689 <div style="display:flex;gap:6px;margin-top:10px">1690 <button1691 class="peach-btn peach-btn--subtle peach-btn--sm"1692 disabled={rotating()}1693 onClick={() => setRotateConfirmOpen(false)}1694 >Cancel</button>1695 <button1696 class="peach-btn peach-btn--sm"1697 style="background:var(--peach-breach);border-color:var(--peach-breach)"1698 disabled={rotating()}1699 onClick={() => void doRotateSyncIdentity()}1700 >{rotating() ? "Rotating…" : "Rotate sync identity"}</button>1701 </div>1702 </div>1703 </Show>1704 </div>1705 </div>1706 );1707 }17081709 function renderAppearance() {1710 const sizeOptions: Array<{ id: "medium" | "large"; label: string }> = [1711 { id: "medium", label: "Default" },1712 { id: "large", label: "Large" },1713 ];1714 return (1715 <div class="peach-settings-body">1716 <div class="peach-settings-group">1717 <p class="peach-settings-sub-label">THEME</p>1718 <div class="peach-settings-row peach-settings-row--meta">1719 <span>Theme</span>1720 <button1721 class="peach-btn peach-btn--subtle peach-btn--sm"1722 onClick={() =>1723 props.onThemeChange(props.theme === "dark" ? "light" : "dark")1724 }1725 >1726 {props.theme === "dark" ? "Switch to light" : "Switch to dark"}1727 </button>1728 </div>17291730 <p class="peach-settings-sub-label">ACCENT</p>1731 <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">1732 <span>Accent</span>1733 <div class="peach-accent-picker">1734 <For each={ACCENT_COLORS}>1735 {(color) => (1736 <button1737 class="peach-accent-swatch peach-swatch"1738 classList={{ "is-active": props.accentColor === color.name }}1739 style={{ background: color.hex }}1740 onClick={() => void props.onAccentChange(color.name)}1741 aria-label={color.name}1742 title={color.name}1743 />1744 )}1745 </For>1746 </div>1747 </div>17481749 <p class="peach-settings-sub-label">TEXT SIZE</p>1750 <div class="peach-settings-row peach-settings-row--meta">1751 <span>Size</span>1752 <div class="peach-pill-group">1753 <For each={sizeOptions}>1754 {(opt) => (1755 <button1756 class="peach-btn peach-btn--ghost peach-btn--sm"1757 classList={{ "is-active": props.textSize === opt.id }}1758 onClick={() => void props.onTextSizeChange(opt.id)}1759 >1760 {opt.label}1761 </button>1762 )}1763 </For>1764 </div>1765 </div>17661767 <p class="peach-settings-sub-label">MOTION</p>1768 <div class="peach-settings-row peach-settings-row--meta">1769 <span>Reduce motion</span>1770 <button1771 class="peach-btn peach-btn--subtle peach-btn--sm"1772 onClick={() => void props.onReduceMotionChange(!props.reduceMotion)}1773 >1774 {props.reduceMotion ? "On" : "Off"}1775 </button>1776 </div>1777 </div>1778 </div>1779 );1780 }17811782 function renderShortcuts() {1783 const [capturingAction, setCapturingAction] = createSignal<ShortcutAction | null>(null);1784 const [conflictMsg, setConflictMsg] = createSignal<string | null>(null);17851786 function startCapture(action: ShortcutAction) {1787 setConflictMsg(null);1788 setCapturingAction(action);1789 }1790 function cancelCapture() {1791 setCapturingAction(null);1792 setConflictMsg(null);1793 }1794 function commitBinding(action: ShortcutAction, binding: Keybinding) {1795 const conflict = findConflict(props.shortcuts, binding, action);1796 if (conflict) {1797 const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict);1798 setConflictMsg(`Already bound to "${other?.label ?? conflict}"`);1799 return;1800 }1801 const next = { ...props.shortcuts, [action]: binding };1802 setCapturingAction(null);1803 setConflictMsg(null);1804 void props.onShortcutsChange(next);1805 }1806 function resetAction(action: ShortcutAction) {1807 const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] };1808 void props.onShortcutsChange(next);1809 }1810 function resetAll() {1811 void props.onShortcutsChange({ ...DEFAULT_BINDINGS });1812 }18131814 1815 1816 function handleCaptureKey(e: KeyboardEvent) {1817 e.preventDefault();1818 e.stopPropagation();1819 if (e.key === "Escape") { cancelCapture(); return; }1820 const binding = keybindingFromEvent(e);1821 if (!binding) return; 1822 const action = capturingAction();1823 if (action) commitBinding(action, binding);1824 }18251826 return (1827 <div class="peach-settings-body">1828 <div class="peach-settings-group peach-shortcuts-group">1829 <p class="peach-settings-sub-label">ENTRY VIEW</p>1830 <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry")}>1831 {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1832 </For>1833 </div>18341835 <div class="peach-settings-group peach-shortcuts-group">1836 <p class="peach-settings-sub-label">ANYWHERE IN POPUP</p>1837 <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global")}>1838 {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1839 </For>1840 </div>18411842 <Show when={conflictMsg()}>1843 <div class="peach-shortcuts-conflict">{conflictMsg()}</div>1844 </Show>18451846 <div class="peach-settings-group" style="display:flex;flex-direction:column;gap:8px">1847 <div class="peach-settings-row peach-settings-row--meta">1848 <span>Inline autofill on page</span>1849 <a1850 class="peach-settings-link"1851 href="chrome://extensions/shortcuts"1852 onClick={(e) => {1853 e.preventDefault();1854 chrome.tabs.create({ url: "chrome://extensions/shortcuts" });1855 }}1856 >1857 ⌘⇧P — customize ↗1858 </a>1859 </div>1860 <button1861 class="peach-btn peach-btn--subtle peach-btn--sm"1862 style="align-self:flex-start"1863 onClick={resetAll}1864 >1865 Reset all to defaults1866 </button>1867 </div>1868 </div>1869 );18701871 function renderRow(1872 desc: typeof SHORTCUT_DESCRIPTORS[number],1873 capturing: () => ShortcutAction | null,1874 onStart: (a: ShortcutAction) => void,1875 onCancel: () => void,1876 onReset: (a: ShortcutAction) => void,1877 onKey: (e: KeyboardEvent) => void,1878 ) {1879 const isActive = () => capturing() === desc.action;1880 const binding = () => props.shortcuts[desc.action];1881 const isCustom = () => {1882 const b = binding();1883 const d = DEFAULT_BINDINGS[desc.action];1884 return b.key !== d.key1885 || Boolean(b.ctrl) !== Boolean(d.ctrl)1886 || Boolean(b.meta) !== Boolean(d.meta)1887 || Boolean(b.shift) !== Boolean(d.shift)1888 || Boolean(b.alt) !== Boolean(d.alt);1889 };1890 return (1891 <div class="peach-shortcut-row">1892 <span class="peach-shortcut-row-label">{desc.label}</span>1893 <button1894 class="peach-shortcut-chip"1895 classList={{ "is-capturing": isActive(), "is-custom": isCustom() }}1896 onClick={() => isActive() ? onCancel() : onStart(desc.action)}1897 onKeyDown={(e) => { if (isActive()) onKey(e); }}1898 ref={(el) => {1899 1900 if (el) {1901 queueMicrotask(() => { if (isActive()) el.focus(); });1902 }1903 }}1904 >1905 {isActive() ? "Press a key…" : formatKeybinding(binding())}1906 </button>1907 <Show when={isCustom() && !isActive()}>1908 <button1909 class="peach-shortcut-reset"1910 title="Reset to default"1911 onClick={() => onReset(desc.action)}1912 >1913 ↺1914 </button>1915 </Show>1916 </div>1917 );1918 }1919 }19201921 const PRO_FEATURES: Array<{1922 feature: ProFeature;1923 name: string;1924 desc: string;1925 }> = [1926 { feature: "sync", name: "Peach Sync", desc: "Encrypted · peer-to-peer" },1927 { feature: "codex", name: "Peach Codex", desc: "Restore from paper" },1928 { feature: "passkeys", name: "Passkeys", desc: "Zero-knowledge WebAuthn" },1929 { feature: "advanced_health", name: "Advanced Vault Health", desc: "Reuse · age · exposure" },1930 ];19311932 const PRO_SECURITY_FEATURES: Array<{1933 feature: ProFeature;1934 name: string;1935 desc: string;1936 }> = [1937 { feature: "canary_entry", name: "Canary entries", desc: "Detects vault compromise" },1938 { feature: "duress_vault", name: "Duress vault", desc: "Decoy under coercion" },1939 ];19401941 const [licenseKey, setLicenseKey] = createSignal<string | null>(null);1942 const [showKey, setShowKey] = createSignal(false);19431944 const [deactivating, setDeactivating] = createSignal(false);19451946 async function handleDeactivateLicense() {1947 setDeactivating(true);1948 await sendMessage({ type: "DEACTIVATE_LICENSE" });1949 setLicenseInfo(null);1950 await props.onLicenseChange?.();1951 setDeactivating(false);1952 }19531954 async function handleTestCanary(entry: {id: string; title: string; canary: Canary}) {1955 setCanaryTestingId(entry.id);1956 setCanaryTestResult(null);1957 const res = await sendMessage({1958 type: "FIRE_CANARY_PING",1959 payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger: "test" },1960 }) as { ok: boolean };1961 setCanaryTestResult({ id: entry.id, ok: res.ok });1962 setCanaryTestingId(null);1963 setTimeout(() => setCanaryTestResult((r) => r?.id === entry.id ? null : r), 3000);1964 }19651966 function renderLicense() {1967 async function onActivate() {1968 await handleActivateLicense();1969 if ((licenseInfo()?.valid ?? false) && licenseInfo()?.plan === "pro") {1970 await props.onLicenseChange?.();1971 }1972 }19731974 return (1975 <div class="peach-settings-body">1976 <Show1977 when={isPro()}1978 fallback={1979 <div class="peach-pro-upgrade">1980 <p class="peach-pro-eyebrow">PEACH PRO · ONE-TIME</p>19811982 <h2 class="peach-pro-headline">1983 Every<em>thing,</em><br />every<em>where.</em>1984 </h2>19851986 <p class="peach-pro-subtitle">1987 One key unlocks every Pro feature — for the lifetime of the app.1988 </p>19891990 <div class="peach-pro-grid">1991 <For each={PRO_FEATURES}>1992 {(item, i) => (1993 <div1994 class="peach-pro-grid-item"1995 classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}1996 >1997 <span class="peach-pro-grid-num">{String(i() + 1).padStart(2, "0")}</span>1998 <span class="peach-pro-grid-name">{item.name}</span>1999 <span class="peach-pro-grid-desc">{item.desc}</span>2000 </div>2001 )}2002 </For>2003 </div>20042005 <p class="peach-pro-section-label">ADVANCED SECURITY</p>2006 <div class="peach-pro-grid">2007 <For each={PRO_SECURITY_FEATURES}>2008 {(item, i) => (2009 <div2010 class="peach-pro-grid-item"2011 classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}2012 >2013 <span class="peach-pro-grid-num">{String(PRO_FEATURES.length + i() + 1).padStart(2, "0")}</span>2014 <span class="peach-pro-grid-name">{item.name}</span>2015 <span class="peach-pro-grid-desc">{item.desc}</span>2016 </div>2017 )}2018 </For>2019 </div>20202021 <div class="peach-pro-upgrade-actions">2022 <p class="peach-pro-pricing">2023 $39 <span class="peach-pro-pricing-sub">once · yours forever</span>2024 </p>2025 <a2026 href="https://buy.polar.sh/polar_cl_IXSFIPojx67rcOTksn8LkH5mnU3osPHtYqBIl1eyuIz"2027 target="_blank"2028 rel="noopener noreferrer"2029 class="peach-pro-upgrade-cta"2030 >2031 Get Peach Pro2032 </a>2033 <div class="peach-pro-key-divider"><span>ALREADY HAVE A KEY</span></div>2034 <div class="peach-pro-key-inline">2035 <input2036 class="peach-pro-key-inline-input"2037 type="text"2038 placeholder="PEACH-XXXX-XXXX-XXXX"2039 value={licenseKeyInput()}2040 onInput={(e) => setLicenseKeyInput(e.currentTarget.value)}2041 onKeyDown={(e) => { if (e.key === "Enter") void onActivate(); }}2042 autocomplete="off"2043 spellcheck={false}2044 />2045 <button2046 class="peach-pro-key-apply"2047 onClick={() => void onActivate()}2048 disabled={activatingLicense() || !licenseKeyInput().trim()}2049 >2050 {activatingLicense() ? "…" : "APPLY"}2051 </button>2052 </div>2053 <Show when={licenseError()}>2054 <p class="peach-pro-key-error">{licenseError()}</p>2055 </Show>2056 </div>2057 </div>2058 }2059 >2060 <div class="peach-pro-active">2061 <div class="peach-pro-active-hero">2062 <button2063 class="peach-pro-active-mark-wrap"2064 type="button"2065 aria-label="Celebrate"2066 onClick={handleProCelebrationClick}2067 >2068 <PeachMark class="peach-pro-active-mark" />2069 <Show when={justActivated()}>2070 <div class="peach-pro-sparks" aria-hidden="true">2071 <span class="peach-pro-spark" style="--spark-x:0px;--spark-y:-54px;--spark-delay:0ms">✦</span>2072 <span class="peach-pro-spark" style="--spark-x:38px;--spark-y:-38px;--spark-delay:50ms">✦</span>2073 <span class="peach-pro-spark" style="--spark-x:54px;--spark-y:0px;--spark-delay:110ms">·</span>2074 <span class="peach-pro-spark" style="--spark-x:38px;--spark-y:38px;--spark-delay:30ms">✦</span>2075 <span class="peach-pro-spark" style="--spark-x:0px;--spark-y:54px;--spark-delay:80ms">·</span>2076 <span class="peach-pro-spark" style="--spark-x:-38px;--spark-y:38px;--spark-delay:130ms">✦</span>2077 <span class="peach-pro-spark" style="--spark-x:-54px;--spark-y:0px;--spark-delay:55ms">·</span>2078 <span class="peach-pro-spark" style="--spark-x:-38px;--spark-y:-38px;--spark-delay:95ms">✦</span>2079 </div>2080 </Show>2081 </button>2082 <h2 class="peach-pro-active-title">Peach Pro</h2>2083 <p class="peach-pro-active-since">{formatActivatedSince(licenseInfo()?.issued)}</p>2084 <Show when={justActivated()}>2085 <p class="peach-pro-thankyou-text" aria-live="polite">thank you ✦</p>2086 </Show>2087 </div>20882089 <Show when={licenseKey()}>2090 <div class="peach-settings-license">2091 <span class="peach-settings-label">LICENSE KEY</span>2092 <div class="peach-settings-license-row">2093 <Show2094 when={showKey()}2095 fallback={2096 <span class="peach-settings-license-key peach-settings-license-key--masked" aria-label="License key hidden">2097 ••••••••••••••••••••••••••••2098 </span>2099 }2100 >2101 <span class="peach-settings-license-key">{licenseKey()!}</span>2102 </Show>2103 <div class="peach-settings-license-actions">2104 <button2105 class="peach-settings-license-copy"2106 title="Copy license key"2107 onClick={() => copyLicenseKey(licenseKey()!)}2108 >2109 {copied() ? "✓" : "copy"}2110 </button>2111 <button2112 class="peach-settings-license-toggle"2113 aria-label={showKey() ? "Hide license key" : "Reveal license key"}2114 title={showKey() ? "Hide" : "Reveal"}2115 onClick={() => setShowKey(v => !v)}2116 >2117 <Show2118 when={showKey()}2119 fallback={2120 <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">2121 <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />2122 <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />2123 </svg>2124 }2125 >2126 <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">2127 <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />2128 <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />2129 <line x1="2" y1="2" x2="12" y2="12" />2130 </svg>2131 </Show>2132 </button>2133 </div>2134 </div>2135 </div>2136 </Show>21372138 <div class="peach-pro-active-features">2139 <For each={[...PRO_FEATURES, ...PRO_SECURITY_FEATURES]}>2140 {(item) => (2141 <div class="peach-pro-active-feature">2142 <span class="peach-pro-active-feature-mark">✦</span>2143 <span class="peach-pro-active-feature-name">{item.name}</span>2144 </div>2145 )}2146 </For>2147 </div>21482149 <button2150 class="peach-pro-deactivate"2151 onClick={() => void handleDeactivateLicense()}2152 disabled={deactivating()}2153 >2154 {deactivating() ? "Deactivating…" : "Deactivate"}2155 </button>2156 </div>2157 </Show>2158 </div>2159 );2160 }21612162 function renderFeedback() {2163 return (2164 <div class="peach-settings-body">2165 <div class="peach-settings-group peach-feedback-group">2166 <Show2167 when={feedbackState() === 'sent'}2168 fallback={2169 <>2170 <p class="peach-feedback-intro">2171 Help us improve Peach. Every report goes directly to our inbox.2172 </p>21732174 <p class="peach-settings-sub-label">TYPE</p>2175 <div class="peach-feedback-chips">2176 <For each={FEEDBACK_CATEGORIES}>2177 {(cat) => {2178 const selected = () => fbCategory() === cat;2179 return (2180 <button2181 type="button"2182 class={`peach-feedback-chip ${selected() ? "is-active" : ""}`}2183 aria-pressed={selected()}2184 onClick={() => setFbCategory(cat)}2185 >2186 {cat}2187 </button>2188 );2189 }}2190 </For>2191 </div>21922193 <p class="peach-settings-sub-label">MESSAGE</p>2194 <textarea2195 class="peach-feedback-textarea"2196 placeholder="Describe the issue or idea..."2197 maxLength={MAX_FEEDBACK_MESSAGE_LENGTH}2198 value={fbMessage()}2199 onInput={(e) => setFbMessage(e.currentTarget.value)}2200 />22012202 <p class="peach-feedback-meta">2203 Version {version} · will be included automatically2204 </p>22052206 <button2207 type="button"2208 class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"2209 disabled={!fbMessage().trim() || feedbackState() === 'sending'}2210 onClick={() => void handleSubmitFeedback()}2211 >2212 <Switch>2213 <Match when={feedbackState() === 'idle'}>Send</Match>2214 <Match when={feedbackState() === 'sending'}>Sending...</Match>2215 <Match when={feedbackState() === 'error'}>Failed - try again</Match>2216 </Switch>2217 </button>22182219 <p class="peach-feedback-anon">2220 Sent anonymously · no account required2221 </p>2222 </>2223 }2224 >2225 <div class="peach-feedback-thanks" role="status" aria-live="polite">2226 <p class="peach-feedback-thanks-title">Thank you</p>2227 <p>2228 Your note is in the queue. Someone will process it soon.2229 </p>2230 </div>2231 </Show>2232 </div>2233 </div>2234 );2235 }22362237 const bodies: StackBodies = {2238 protection: renderSecurity(),2239 vault: renderVault(),2240 sync: renderSync(),2241 appearance: renderAppearance(),2242 shortcuts: renderShortcuts(),2243 pro: renderLicense(),2244 feedback: renderFeedback(),2245 };22462247 return (2248 <Show2249 when={showNewRecoveryWords()}2250 fallback={2251 <div2252 class="peach-overlay-view peach-settings-overlay"2253 classList={{ "ov-enter-right": !props.isExiting, "ov-exit-right": !!props.isExiting }}2254 style="display:flex;flex-direction:column"2255 >2256 <StackDeck2257 active={activeCard()}2258 onActiveChange={setActiveCard}2259 onBack={props.onBack}2260 cards={cards()}2261 bodies={bodies}2262 version={version}2263 isFanning={isFanning()}2264 celebratingCardId={justActivated() ? "pro" : null}2265 isPro={isPro()}2266 />2267 </div>2268 }2269 >2270 <RecoveryWordDisplayView onDone={() => setShowNewRecoveryWords(false)} />2271 </Show>2272 );2273}