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 { isNativePlatform, nativePlugin } from "../../shared/native";33import { WEB3FORMS_KEY } from "../../shared/config";3435interface LicenseInfo {36 valid: boolean;37 plan: "free" | "pro";38 issued?: string;39}4041interface SettingsProps {42 entriesCount: number;43 breachMonitoring: boolean;44 lockOnClose: boolean;45 autoLockMinutes: number;46 trashRetentionDays: number;47 theme: "dark" | "light";48 accentColor: AccentColorName;49 textSize: "small" | "medium" | "large";50 reduceMotion: boolean;51 isPro?: boolean;52 initialSection?: string | null;53 upgradeHighlight?: ProFeature | null;54 onBreachMonitoringChange: (enabled: boolean) => void | Promise<void>;55 onLockOnCloseChange: (enabled: boolean) => void | Promise<void>;56 onAutoLockMinutesChange: (minutes: number) => void | Promise<void>;57 onTrashRetentionChange: (days: number) => void | Promise<void>;58 onThemeChange: (theme: "dark" | "light") => void;59 onAccentChange: (name: AccentColorName) => void | Promise<void>;60 onTextSizeChange: (size: "small" | "medium" | "large") => void | Promise<void>;61 onReduceMotionChange: (enabled: boolean) => void | Promise<void>;62 shortcuts: Record<ShortcutAction, Keybinding>;63 onShortcutsChange: (active: Record<ShortcutAction, Keybinding>) => void | Promise<void>;64 onLicenseChange?: () => void | Promise<void>;65 isExiting?: boolean;66 onBack: () => void;67 onImport: () => void;68 onExport: () => void;69 onBreachCheckDone: () => void;70 onOpenHealth?: () => void;71 onOpenSyncPairing?: (method?: "code") => void;72 onOpenFileImport?: () => void;73 onOpenPanic?: () => void;74 onOpenCodexGenerate?: () => void;75 onOpenCodexRestore?: () => void;76 777879 onProGate?: (feature: ProFeature) => void;80 onCardChange?: (id: CardId) => void;81}8283const FEEDBACK_CATEGORIES = ["Bug report", "Feature request", "Question", "Other"];84const FEEDBACK_DATA_PERMISSION = "technicalAndInteraction";85const MAX_FEEDBACK_MESSAGE_LENGTH = 4000;86const PRO_CELEBRATION_CARD_STEP_MS = 150;87const PRO_CELEBRATION_THANK_YOU_MS = 3200;8889function sanitizeFeedbackMessage(value: string): string {90 return value91 .trim()92 .slice(0, MAX_FEEDBACK_MESSAGE_LENGTH)93 94 .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "")95 .replace(/&/g, "&")96 .replace(/</g, "<")97 .replace(/>/g, ">")98 .replace(/"/g, """)99 .replace(/'/g, "'");100}101102type DataCollectionPermissionApi = {103 permissions?: {104 getAll?: () => Promise<{ data_collection?: string[] }>;105 request?: (permissions: { data_collection: string[] }) => Promise<boolean>;106 };107};108109async function requestFeedbackDataPermission(): Promise<boolean> {110 const api = ((globalThis as { browser?: DataCollectionPermissionApi }).browser ??111 (typeof chrome !== "undefined" ? chrome : undefined)) as DataCollectionPermissionApi | undefined;112 if (!api?.permissions?.getAll) return true;113114 try {115 const permissions = await api.permissions.getAll();116 if (!("data_collection" in permissions)) return true;117 if (permissions.data_collection?.includes(FEEDBACK_DATA_PERMISSION)) return true;118 return await (api.permissions.request?.({ data_collection: [FEEDBACK_DATA_PERMISSION] }) ?? Promise.resolve(false));119 } catch {120 return false;121 }122}123124function formatActivatedSince(issued: string | undefined): string {125 if (!issued) return "Active";126 try {127 const d = new Date(issued);128 if (Number.isNaN(d.getTime())) return "Active";129 return `Active since ${d.toLocaleDateString(undefined, { month: "long", year: "numeric" })}`;130 } catch {131 return "Active";132 }133}134135function initialSectionToCard(value: string | null | undefined): CardId | null {136 if (!value) return null;137 if (value === "security") return "protection";138 if (value === "license") return "pro";139 if (140 value === "protection" ||141 value === "vault" ||142 value === "sync" ||143 value === "appearance" ||144 value === "shortcuts" ||145 value === "pro" ||146 value === "feedback"147 ) {148 return value;149 }150 return null;151}152153export function SettingsView(props: SettingsProps) {154 const manifest = chrome.runtime.getManifest();155 const version = manifest.version;156 const initial = props.initialSection;157 const [activeCard, setActiveCard] = createSignal<CardId>(158 props.upgradeHighlight ? "pro" : initialSectionToCard(initial) ?? "pro"159 );160161 createEffect(() => { props.onCardChange?.(activeCard()); });162163 const [justActivated, setJustActivated] = createSignal(false);164 const [isFanning, setIsFanning] = createSignal(false);165166 function triggerCelebration() {167 if (isFanning()) return;168 setJustActivated(false);169 setIsFanning(true);170 setActiveCard("pro");171 const fanOrder: CardId[] = ["protection", "vault", "sync", "appearance", "shortcuts", "feedback"];172 fanOrder.forEach((id, i) => {173 window.setTimeout(() => setActiveCard(id), (i + 1) * PRO_CELEBRATION_CARD_STEP_MS);174 });175 window.setTimeout(() => {176 setActiveCard("pro");177 setIsFanning(false);178 setJustActivated(true);179 window.setTimeout(() => setJustActivated(false), PRO_CELEBRATION_THANK_YOU_MS);180 }, (fanOrder.length + 1) * PRO_CELEBRATION_CARD_STEP_MS);181 }182183 function handleProCelebrationClick(e: MouseEvent) {184 e.stopPropagation();185 triggerCelebration();186 }187188 const [checkingBreaches, setCheckingBreaches] = createSignal(false);189 const [breachResult, setBreachResult] = createSignal<string | null>(null);190 const [licenseInfo, setLicenseInfo] = createSignal<LicenseInfo | null>(null);191 const [licenseKeyInput, setLicenseKeyInput] = createSignal("");192 const [licenseError, setLicenseError] = createSignal<string | null>(null);193 const [, setLicenseSuccess] = createSignal<string | null>(null);194 const [activatingLicense, setActivatingLicense] = createSignal(false);195196 const isPro = () => props.isPro ?? false;197 const cards = createMemo<StackCardSpec[]>(() => [198 {199 id: "pro",200 num: "I · FOREVER",201 title: "Peach Pro",202 lede: isPro() ? "active · paid once, yours forever" : "$39 once · yours forever",203 bgVar: "--stack-pro-bg",204 fgVar: "--stack-pro-fg",205 icon: "spark",206 onIconClick: isPro() ? triggerCelebration : undefined,207 },208 {209 id: "protection",210 num: "II · PROTECT",211 title: "Protection",212 lede: `${props.breachMonitoring ? "breaches watched" : "breach watch off"} · ${props.lockOnClose ? "locks on close" : "manual lock"}`,213 bgVar: "--stack-protect-bg",214 fgVar: "--stack-protect-fg",215 icon: "shield",216 },217 {218 id: "vault",219 num: "III · KEEP",220 title: "Vault",221 lede: `${props.entriesCount} ${props.entriesCount === 1 ? "entry" : "entries"} · backups, history, cleanup`,222 bgVar: "--stack-vault-bg",223 fgVar: "--stack-vault-fg",224 icon: "lock",225 },226 {227 id: "sync",228 num: "IV · ECHO",229 title: "Sync",230 lede: isPro() ? "your devices, one vault" : "Pro · encrypted device sync",231 bgVar: "--stack-sync-bg",232 fgVar: "--stack-sync-fg",233 icon: "sync",234 },235 {236 id: "appearance",237 num: "V · DRESS",238 title: "Appearance",239 lede: `${props.theme} · ${props.accentColor} · ${props.textSize}`,240 bgVar: "--stack-appear-bg",241 fgVar: "--stack-appear-fg",242 icon: "sun",243 },244 {245 id: "shortcuts",246 num: "VI · KEYS",247 title: "Shortcuts",248 lede: `${SHORTCUT_DESCRIPTORS.length} bindings · what your fingers know`,249 bgVar: "--stack-shortcut-bg",250 fgVar: "--stack-shortcut-fg",251 icon: "keys",252 },253 {254 id: "feedback",255 num: "VII · ECHO",256 title: "Feedback",257 lede: `version ${version} · tell us what would make it better`,258 bgVar: "--stack-feedback-bg",259 fgVar: "--stack-feedback-fg",260 icon: "bubble",261 },262 ]);263 const [biometricSupported, setBiometricSupported] = createSignal(false);264 const [biometricStored, setBiometricStored] = createSignal(false);265 const [biometricBusy, setBiometricBusy] = createSignal(false);266 const [biometricError, setBiometricError] = createSignal<string | null>(null);267 const [passkeyState, setPasskeyState] = createSignal<PasskeyProviderState | null>(null);268 const [passkeySaving, setPasskeySaving] = createSignal(false);269270 271 272 interface PendingVouched { deviceId: string; deviceName: string }273 const [pendingVouched, setPendingVouched] = createSignal<PendingVouched[]>([]);274 const [vouchedBusy, setVouchedBusy] = createSignal<string | null>(null);275 276 const [pairedDevices, setPairedDevices] = createSignal<PairedDevice[]>([]);277 const [confirmUnpair, setConfirmUnpair] = createSignal<string | null>(null);278 const [syncStatus, setSyncStatus] = createSignal<"idle" | "syncing" | "error">("idle");279 const [syncFlash, setSyncFlash] = createSignal<string | null>(null);280 281 282 const [syncSummary, setSyncSummary] = createSignal<SyncOutcomeSummary>({});283 const syncLight = () =>284 syncStatus() === "syncing"285 ? "busy"286 : syncIndicator(syncSummary(), pairedDevices().length);287 const syncLightLabel = () => syncIndicatorLabel(syncLight(), syncSummary());288 const lastSyncLine = () => {289 const s = syncSummary();290 if (!s.outcome) return "No sync attempted yet";291 return `${syncRelativeTime(s.finishedAt)} · ${describeSyncOutcome(s)}`;292 };293 async function loadSyncState() {294 const r = (await sendMessage({ type: "GET_SYNC_STATE" })) as {295 ok: boolean;296 data?: SyncOutcomeSummary;297 };298 if (r.ok && r.data) {299 setSyncSummary(r.data);300 setSyncStatus(r.data.status ?? "idle");301 }302 }303304 const STALE_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000;305 function syncRelativeTime(when: number | undefined): string {306 if (!when) return "Not synced yet";307 const elapsed = Date.now() - when;308 if (elapsed < 60_000) return "Just now";309 const minutes = Math.floor(elapsed / 60_000);310 if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"} ago`;311 const hours = Math.floor(minutes / 60);312 if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;313 const days = Math.floor(hours / 24);314 return days < 7 ? `${days} day${days === 1 ? "" : "s"} ago` : "Not seen recently";315 }316 function isSyncStale(d: PairedDevice): boolean {317 return d.lastSyncedAt !== undefined && Date.now() - d.lastSyncedAt > STALE_THRESHOLD_MS;318 }319 function deviceTypeLabel(t: PairedDevice["deviceType"]): string {320 switch (t) {321 case "android": return "Android";322 case "ios": return "iPhone";323 case "extension": return "Browser";324 default: return "Device";325 }326 }327328 async function loadPairedDevices() {329 if (!isPro()) return;330 const r = (await sendMessage({ type: "LIST_PAIRED_DEVICES" })) as {331 ok: boolean;332 data?: { devices: PairedDevice[] };333 };334 if (r.ok) setPairedDevices(r.data?.devices ?? []);335 }336 async function unpairDevice(deviceId: string) {337 await sendMessage({ type: "UNPAIR_DEVICE", payload: { deviceId } });338 setConfirmUnpair(null);339 await loadPairedDevices();340 }341 async function syncNow() {342 setSyncStatus("syncing");343 setSyncFlash(null);344 const res = (await sendMessage({345 type: "TRIGGER_SYNC",346 payload: { reason: "manual", force: true },347 })) as { ok: boolean; error?: string };348 if (!res.ok) {349 setSyncStatus("error");350 setSyncSummary({ status: "error", outcome: "run_failed", detail: res.error });351 return;352 }353 354 355 356 let s: SyncOutcomeSummary = { status: "syncing" };357 for (let i = 0; i < 40 && s.status === "syncing"; i++) {358 await new Promise((r) => setTimeout(r, 250));359 const state = (await sendMessage({ type: "GET_SYNC_STATE" })) as {360 ok: boolean;361 data?: SyncOutcomeSummary;362 };363 s = state.ok ? state.data ?? { status: "idle" } : { status: "error", outcome: "run_failed" };364 }365 setSyncSummary(s);366 setSyncStatus(s.status ?? "idle");367 368 369 setSyncFlash(describeSyncOutcome(s));370 setTimeout(() => setSyncFlash(null), 4000);371 await loadPairedDevices();372 }373 374 const [rotateConfirmOpen, setRotateConfirmOpen] = createSignal(false);375 const [rotating, setRotating] = createSignal(false);376 const [rotateError, setRotateError] = createSignal<string | null>(null);377 const [showSyncConnection, setShowSyncConnection] = createSignal(false);378 const [showSyncRecovery, setShowSyncRecovery] = createSignal(false);379380 async function loadPendingVouched() {381 const r = (await sendMessage({ type: "GET_PENDING_VOUCHED_DEVICES" })) as {382 ok: boolean;383 data?: PendingVouched[];384 };385 if (r.ok) setPendingVouched(r.data ?? []);386 }387 async function confirmVouched(deviceId: string) {388 setVouchedBusy(deviceId);389 try {390 await sendMessage({ type: "CONFIRM_VOUCHED_DEVICE", payload: { deviceId } });391 await loadPendingVouched();392 } finally {393 setVouchedBusy(null);394 }395 }396 async function rejectVouched(deviceId: string) {397 setVouchedBusy(deviceId);398 try {399 await sendMessage({ type: "REJECT_VOUCHED_DEVICE", payload: { deviceId } });400 await loadPendingVouched();401 } finally {402 setVouchedBusy(null);403 }404 }405 async function doRotateSyncIdentity() {406 setRotateError(null);407 setRotating(true);408 try {409 const r = (await sendMessage({ type: "ROTATE_SYNC_IDENTITY" })) as {410 ok: boolean;411 error?: string;412 };413 if (!r.ok) {414 setRotateError(r.error ?? "Rotation failed.");415 return;416 }417 setRotateConfirmOpen(false);418 419 420 setShowNewRecoveryWords(true);421 } finally {422 setRotating(false);423 }424 }425426 const [dedupCount, setDedupCount] = createSignal<number | null>(null);427 const [dedupRunning, setDedupRunning] = createSignal(false);428 const [dedupDone, setDedupDone] = createSignal<number | null>(null);429430 const [declinedCardCount, setDeclinedCardCount] = createSignal(0);431432 433 434 435 const [historySnapshots, setHistorySnapshots] = createSignal<VaultSnapshot[]>([]);436 const [historyLoading, setHistoryLoading] = createSignal(false);437 const [historyRestoring, setHistoryRestoring] = createSignal<string | null>(null);438 const [historyShowAll, setHistoryShowAll] = createSignal(false);439440 async function loadVaultHistory() {441 setHistoryLoading(true);442 try {443 const res = await sendMessage({ type: "GET_VAULT_HISTORY" });444 const list = (res as { ok: boolean; data?: VaultSnapshot[] }).data ?? [];445 setHistorySnapshots(list);446 } finally {447 setHistoryLoading(false);448 }449 }450451 async function handleRestoreVaultSnapshot(id: string) {452 if (historyRestoring()) return;453 setHistoryRestoring(id);454 const res = await sendMessage({455 type: "RESTORE_VAULT_SNAPSHOT",456 payload: { snapshotId: id },457 });458 setHistoryRestoring(null);459 if ((res as { ok: boolean }).ok) {460 461 462 props.onBack();463 }464 }465466 function formatSnapshotTime(savedAt: number): string {467 const diff = Date.now() - savedAt;468 if (diff < 60_000) return "Just now";469 if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;470 const d = new Date(savedAt);471 const isToday = new Date().toDateString() === d.toDateString();472 const isYest =473 new Date(Date.now() - 86_400_000).toDateString() === d.toDateString();474 const t = d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });475 if (isToday) return `Today ${t}`;476 if (isYest) return `Yesterday ${t}`;477 return `${d.toLocaleDateString([], { month: "short", day: "numeric" })} ${t}`;478 }479480 function reasonLabel(reason: VaultSnapshot["reason"]): string {481 return (482 {483 local_save: "saved",484 sync_received: "synced",485 import: "imported",486 restore: "restored",487 } as const488 )[reason] ?? reason;489 }490491 const [showVaultAdvanced, setShowVaultAdvanced] = createSignal(false);492493 const [showChangePw, setShowChangePw] = createSignal(false);494 const [changePwCurrent, setChangePwCurrent] = createSignal("");495 const [changePwNew, setChangePwNew] = createSignal("");496 const [changePwConfirm, setChangePwConfirm] = createSignal("");497 const [changePwSaving, setChangePwSaving] = createSignal(false);498 const [changePwError, setChangePwError] = createSignal<string | null>(null);499 const [changePwSuccess] = createSignal(false);500 const [showNewRecoveryWords, setShowNewRecoveryWords] = createSignal(false);501502 503 const [showViewWords, setShowViewWords] = createSignal(false);504 const [viewWordsPassword, setViewWordsPassword] = createSignal("");505 const [viewWordsError, setViewWordsError] = createSignal<string | null>(null);506 const [viewedWords, setViewedWords] = createSignal<string[] | null>(null);507 const [viewWordsBusy, setViewWordsBusy] = createSignal(false);508509 async function handleViewRecoveryWords() {510 setViewWordsError(null);511 if (!viewWordsPassword()) {512 setViewWordsError("Enter your master password.");513 return;514 }515 setViewWordsBusy(true);516 try {517 const r = (await sendMessage({518 type: "GET_RECOVERY_WORDS",519 payload: { password: viewWordsPassword() },520 })) as { ok: boolean; data?: string[]; error?: string };521 if (r.ok) {522 setViewedWords(r.data ?? []);523 setViewWordsPassword("");524 return;525 }526 switch (r.error) {527 case "wrong_password":528 setViewWordsError("Incorrect password");529 break;530 case "password_required":531 setViewWordsError("Enter your master password.");532 break;533 case "recovery_not_setup":534 setViewWordsError("Recovery words aren't set up for this vault yet.");535 break;536 default:537 setViewWordsError("Couldn't retrieve recovery words.");538 }539 } finally {540 setViewWordsBusy(false);541 }542 }543 function closeViewWords() {544 setShowViewWords(false);545 setViewWordsPassword("");546 setViewWordsError(null);547 setViewedWords(null);548 }549550 const [duressEnabled, setDuressEnabled] = createSignal(false);551 const [showDuressSetup, setShowDuressSetup] = createSignal(false);552 const [duressPassword, setDuressPassword] = createSignal("");553 const [duressConfirm, setDuressConfirm] = createSignal("");554 const [duressMainPassword, setDuressMainPassword] = createSignal("");555 const [duressSaving, setDuressSaving] = createSignal(false);556 const [duressError, setDuressError] = createSignal<string | null>(null);557 const [duressSuccess, setDuressSuccess] = createSignal<string | null>(null);558559 const [canaryEntries, setCanaryEntries] = createSignal<Array<{id: string; title: string; canary: Canary}>>([]);560561 const [canaryTestingId, setCanaryTestingId] = createSignal<string | null>(null);562 const [canaryTestResult, setCanaryTestResult] = createSignal<{id: string; ok: boolean} | null>(null);563564 const [fbCategory, setFbCategory] = createSignal("Bug report");565 const [fbMessage, setFbMessage] = createSignal("");566 type FeedbackState = 'idle' | 'sending' | 'sent' | 'error';567 const [feedbackState, setFeedbackState] = createSignal<FeedbackState>('idle');568569 createEffect(() => {570 if (props.upgradeHighlight) {571 setActiveCard("pro");572 }573 });574575 onMount(async () => {576 const [licenseRes, passkeyRes, duressRes, settingsRes, keyRes] = await Promise.all([577 sendMessage({ type: "GET_LICENSE" }),578 sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }),579 sendMessage({ type: "IS_DURESS_ENABLED" }),580 sendMessage({ type: "GET_SETTINGS" }),581 sendMessage({ type: "GET_LICENSE_KEY" }),582 ]);583584 585 586 void loadVaultHistory();587588 589 590 void loadPendingVouched();591 void loadPairedDevices();592 void loadSyncState();593 const onBroadcast = (msg: unknown) => {594 const m = msg as { type?: string; payload?: unknown } | null;595 if (m?.type === "VOUCHED_DEVICE_PENDING") {596 void loadPendingVouched();597 }598 if (m?.type === "SYNC_STATE_CHANGED") {599 const payload = (m.payload ?? {}) as SyncOutcomeSummary;600 setSyncSummary(payload);601 setSyncStatus(payload.status ?? "idle");602 }603 };604 chrome.runtime?.onMessage?.addListener(onBroadcast);605 onCleanup(() => {606 try { chrome.runtime?.onMessage?.removeListener(onBroadcast); } catch { }607 });608609 if ((licenseRes as { ok: boolean }).ok) {610 setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null);611 }612613 if ((passkeyRes as { ok: boolean }).ok) {614 setPasskeyState(615 (passkeyRes as { ok: boolean; data?: PasskeyProviderState }).data ?? null616 );617 }618619 if ((duressRes as { ok: boolean }).ok) {620 setDuressEnabled((duressRes as { ok: boolean; data?: boolean }).data ?? false);621 }622623 if ((settingsRes as { ok: boolean }).ok) {624 const sData = (settingsRes as { ok: boolean; data?: { declinedCardFingerprints?: string[] } }).data;625 setDeclinedCardCount(sData?.declinedCardFingerprints?.length ?? 0);626 }627628 if ((keyRes as { ok: boolean }).ok) {629 setLicenseKey((keyRes as { ok: boolean; data?: string }).data ?? null);630 }631632 const entriesRes = await sendMessage({ type: "GET_ENTRIES" });633 if ((entriesRes as { ok: boolean }).ok) {634 const entries = (entriesRes as { ok: boolean; data?: Array<{id: string; title: string; canary?: Canary}> }).data ?? [];635 setCanaryEntries(636 entries637 .filter((e) => e.canary?.webhookUrl)638 .map((e) => ({ id: e.id, title: e.title, canary: e.canary! }))639 );640 }641642643 if (isNativePlatform()) {644 const biometric = nativePlugin<{ isAvailable: () => Promise<{ available: boolean }> }>("BiometricPlugin");645 const keystore = nativePlugin<{ hasMasterKey: () => Promise<{ exists: boolean }> }>("KeystorePlugin");646 try {647 if (biometric) {648 const a = await biometric.isAvailable();649 setBiometricSupported(a.available);650 }651 if (keystore) {652 const k = await keystore.hasMasterKey();653 setBiometricStored(k.exists);654 }655 } catch { }656 }657 });658659 async function handleEnableBiometrics() {660 setBiometricBusy(true);661 setBiometricError(null);662 try {663 const keyRes = (await sendMessage({ type: "GET_DERIVED_KEY" })) as {664 ok: boolean;665 data?: { masterKeyHex: string };666 error?: string;667 };668 if (!keyRes.ok || !keyRes.data?.masterKeyHex) {669 setBiometricError(keyRes.error === "vault_locked"670 ? "unlock the vault first"671 : "couldn't read derived key");672 return;673 }674 const keystore = nativePlugin<{ storeMasterKey: (p: { masterKeyHex: string }) => Promise<void> }>("KeystorePlugin");675 if (!keystore) {676 setBiometricError("biometric plugin unavailable");677 return;678 }679 await keystore.storeMasterKey({ masterKeyHex: keyRes.data.masterKeyHex });680 await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: true } });681 setBiometricStored(true);682 } catch (e) {683 setBiometricError(e instanceof Error ? e.message : "failed to enable biometrics");684 } finally {685 setBiometricBusy(false);686 }687 }688689 async function handleDisableBiometrics() {690 setBiometricBusy(true);691 setBiometricError(null);692 try {693 const keystore = nativePlugin<{ clearMasterKey: () => Promise<void> }>("KeystorePlugin");694 if (keystore) await keystore.clearMasterKey();695 await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: false } });696 setBiometricStored(false);697 } catch (e) {698 setBiometricError(e instanceof Error ? e.message : "failed to disable biometrics");699 } finally {700 setBiometricBusy(false);701 }702 }703704 async function handleChangePassword() {705 setChangePwError(null);706 if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; }707 if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; }708 if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; }709 if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; }710 setChangePwSaving(true);711 const res = await sendMessage({712 type: "CHANGE_PASSWORD",713 payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() },714 }) as { ok: boolean; error?: string };715 if (res.ok) {716 setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");717 setShowChangePw(false);718 719 720 721 722 await chrome.storage?.local?.set?.({ peach_recovery_pending: "1" });723 setShowNewRecoveryWords(true);724 } else {725 setChangePwError(res.error ?? "failed to change password");726 }727 setChangePwSaving(false);728 }729730 const [copied, setCopied] = createSignal(false);731732 async function copyLicenseKey(key: string) {733 await navigator.clipboard.writeText(key);734 setCopied(true);735 setTimeout(() => setCopied(false), 2000);736 }737738 async function handleActivateLicense() {739 const key = licenseKeyInput().trim();740 if (!key) return;741 setActivatingLicense(true);742 setLicenseError(null);743 setLicenseSuccess(null);744 const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } });745 setActivatingLicense(false);746 if ((res as { ok: boolean }).ok) {747 const data = (res as { ok: boolean; data?: LicenseInfo }).data;748 if (data?.valid) {749 setLicenseInfo(data);750 setLicenseKey(key);751 setLicenseSuccess("License activated!");752 setLicenseKeyInput("");753 } else {754 setLicenseError("Invalid license key");755 }756 } else {757 setLicenseError("Activation failed");758 }759 }760761 async function handleTogglePasskeys() {762 const current = passkeyState();763 if (!current || !current.supported) return;764 setPasskeySaving(true);765 const res = await sendMessage({766 type: "SET_PASSKEY_PROVIDER_ENABLED",767 payload: { enabled: !current.enabled },768 });769 setPasskeySaving(false);770 if ((res as { ok: boolean }).ok) {771 setPasskeyState(772 (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null773 );774 }775 }776777 async function handleCheckAllBreaches() {778 setCheckingBreaches(true);779 setBreachResult(null);780 const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });781 setCheckingBreaches(false);782 if ((res as { ok: boolean }).ok) {783 const data = (res as { ok: boolean; data?: Record<string, number> }).data ?? {};784 const breached = Object.values(data).filter((count) => count > 0).length;785 setBreachResult(786 breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean"787 );788 props.onBreachCheckDone();789 }790 }791792793 async function handleSetupDuress() {794 const mainPw = duressMainPassword().trim();795 const duressPw = duressPassword().trim();796 const confirmPw = duressConfirm().trim();797798 if (!mainPw) { setDuressError("enter your main password to confirm"); return; }799 if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; }800 if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; }801 if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; }802803 setDuressSaving(true);804 setDuressError(null);805806 const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };807 if (!verifyRes.ok || !verifyRes.data) {808 setDuressError("incorrect main password");809 setDuressSaving(false);810 return;811 }812813 const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string };814 setDuressSaving(false);815 if (res.ok) {816 setDuressEnabled(true);817 setShowDuressSetup(false);818 setDuressPassword("");819 setDuressConfirm("");820 setDuressMainPassword("");821 setDuressSuccess("Duress password set. If entered, a decoy vault will be shown.");822 setTimeout(() => setDuressSuccess(null), 5000);823 } else {824 setDuressError(825 res.error === "session_expired"826 ? "Vault session expired — lock and unlock Peach, then try again"827 : res.error ?? "failed to set duress password"828 );829 }830 }831832 async function handleClearDeclinedCards() {833 await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } });834 setDeclinedCardCount(0);835 }836837 async function handleCountDuplicates() {838 setDedupRunning(true);839 setDedupDone(null);840 const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number };841 setDedupRunning(false);842 if (res.ok) setDedupCount(res.data ?? 0);843 }844845 async function handleDeduplicate() {846 setDedupRunning(true);847 const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number };848 setDedupRunning(false);849 if (res.ok) {850 setDedupDone(res.data ?? 0);851 setDedupCount(0);852 }853 }854855 async function handleDisableDuress() {856 const mainPw = duressMainPassword().trim();857 if (!mainPw) { setDuressError("enter your main password to confirm"); return; }858859 setDuressSaving(true);860 setDuressError(null);861862 const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };863 if (!verifyRes.ok || !verifyRes.data) {864 setDuressError("incorrect main password");865 setDuressSaving(false);866 return;867 }868869 const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string };870 setDuressSaving(false);871 if (res.ok) {872 setDuressEnabled(false);873 setShowDuressSetup(false);874 setDuressMainPassword("");875 setDuressSuccess("Duress password disabled.");876 setTimeout(() => setDuressSuccess(null), 4000);877 } else {878 setDuressError(res.error ?? "failed to disable duress password");879 }880 }881882 async function handleSubmitFeedback() {883 const message = sanitizeFeedbackMessage(fbMessage());884 if (!message) return;885886 setFeedbackState('sending');887888 try {889 const hasDataPermission = await requestFeedbackDataPermission();890 if (!hasDataPermission) {891 setFeedbackState('idle');892 return;893 }894895 const category = FEEDBACK_CATEGORIES.includes(fbCategory())896 ? fbCategory()897 : FEEDBACK_CATEGORIES[0];898 const res = await fetch('https://api.web3forms.com/submit', {899 method: 'POST',900 headers: { 'Content-Type': 'application/json' },901 body: JSON.stringify({902 access_key: WEB3FORMS_KEY,903 subject: `Peach Feedback - ${category}`,904 message,905 from_name: 'Peach User',906 907 botcheck: '',908 }),909 });910911 if (!res.ok) throw new Error(`HTTP ${res.status}`);912 const data = await res.json() as { success: boolean; message?: string };913 if (!data.success) throw new Error(data.message ?? 'unknown error');914915 setFeedbackState('sent');916 setFbMessage('');917 setFbCategory(FEEDBACK_CATEGORIES[0]);918 } catch (err) {919 console.error('[feedback]', err);920 setFeedbackState('error');921 setTimeout(() => setFeedbackState('idle'), 4000);922 }923 }924925 function renderSecurity() {926 return (927 <div class="peach-settings-body">928 <div class="peach-settings-group">929 <p class="peach-settings-sub-label">LOCKING</p>930 <div class="peach-settings-row">931 <span>Lock on close</span>932 <button933 class={`peach-toggle ${props.lockOnClose ? "is-on" : ""}`}934 onClick={() => void props.onLockOnCloseChange(!props.lockOnClose)}935 aria-label="Toggle lock on close"936 >937 <span class="peach-toggle-knob"></span>938 </button>939 </div>940 <div class="peach-settings-row">941 <span>Auto-lock after</span>942 <select943 class="peach-settings-select"944 value={String(props.autoLockMinutes)}945 onChange={(e) => void props.onAutoLockMinutesChange(Number(e.currentTarget.value))}946 >947 <option value="1">1 minute</option>948 <option value="5">5 minutes</option>949 <option value="15">15 minutes</option>950 <option value="30">30 minutes</option>951 <option value="0">Never</option>952 </select>953 </div>954955 <Show when={isNativePlatform() && biometricSupported()}>956 <p class="peach-settings-sub-label">BIOMETRICS</p>957 <div class="peach-settings-row">958 <span>Unlock with biometrics</span>959 <button960 class={`peach-toggle ${biometricStored() ? "is-on" : ""}`}961 onClick={() => void (biometricStored() ? handleDisableBiometrics() : handleEnableBiometrics())}962 disabled={biometricBusy()}963 aria-label="Toggle biometric unlock"964 >965 <span class="peach-toggle-knob"></span>966 </button>967 </div>968 <Show when={biometricError()}>969 <div class="peach-settings-row peach-settings-row--meta">970 <span class="peach-hint peach-hint-error">{biometricError()}</span>971 </div>972 </Show>973 </Show>974975 <p class="peach-settings-sub-label">BREACH PROTECTION</p>976 <div class="peach-settings-row">977 <span>Breach monitoring</span>978 <button979 class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}980 onClick={() => void props.onBreachMonitoringChange(!props.breachMonitoring)}981 aria-label="Toggle breach monitoring"982 >983 <span class="peach-toggle-knob"></span>984 </button>985 </div>986 <div class="peach-settings-row peach-settings-row--meta">987 <span>Check all passwords</span>988 <button989 class="peach-btn peach-btn--subtle peach-btn--sm"990 onClick={() => void handleCheckAllBreaches()}991 disabled={checkingBreaches()}992 >993 {checkingBreaches() ? "checking..." : "run"}994 </button>995 </div>996 <Show when={breachResult()}>997 <div class="peach-settings-row peach-settings-row--meta">998 <span>Last check</span>999 <span class="peach-settings-value">{breachResult()}</span>1000 </div>1001 </Show>10021003 <p class="peach-settings-sub-label">MASTER PASSWORD</p>1004 <div class="peach-settings-row peach-settings-row--meta">1005 <span>Master password</span>1006 <button1007 class="peach-btn peach-btn--subtle peach-btn--sm"1008 onClick={() => { setShowChangePw((v) => !v); setChangePwError(null); }}1009 >1010 {showChangePw() ? "cancel" : "change"}1011 </button>1012 </div>1013 <Show when={showChangePw()}>1014 <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1015 <input1016 class="peach-settings-input"1017 type="password"1018 placeholder="current password"1019 value={changePwCurrent()}1020 onInput={(e) => setChangePwCurrent((e.target as HTMLInputElement).value)}1021 autocomplete="current-password"1022 />1023 <input1024 class="peach-settings-input"1025 type="password"1026 placeholder="new password (min 12 chars)"1027 value={changePwNew()}1028 onInput={(e) => setChangePwNew((e.target as HTMLInputElement).value)}1029 autocomplete="new-password"1030 />1031 <input1032 class="peach-settings-input"1033 type="password"1034 placeholder="confirm new password"1035 value={changePwConfirm()}1036 onInput={(e) => setChangePwConfirm((e.target as HTMLInputElement).value)}1037 autocomplete="new-password"1038 onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }}1039 />1040 <Show when={changePwError()}>1041 <p class="peach-hint peach-hint-error" style="margin:0">{changePwError()}</p>1042 </Show>1043 <Show when={changePwSuccess()}>1044 <p class="peach-hint" style="margin:0;color:var(--peach-accent)">password changed ✓</p>1045 </Show>1046 <button1047 class="peach-btn peach-btn--accent peach-btn--sm"1048 style="align-self:flex-start"1049 onClick={() => void handleChangePassword()}1050 disabled={changePwSaving() || !changePwCurrent() || !changePwNew() || !changePwConfirm()}1051 >1052 {changePwSaving() ? "saving…" : "save new password"}1053 </button>1054 </div>1055 </Show>10561057 {}1058 <div class="peach-settings-row peach-settings-row--meta">1059 <span>Recovery words</span>1060 <button1061 class="peach-btn peach-btn--subtle peach-btn--sm"1062 onClick={() => {1063 if (showViewWords()) { closeViewWords(); } else { setShowViewWords(true); }1064 }}1065 >1066 {showViewWords() ? "cancel" : "view"}1067 </button>1068 </div>1069 <Show when={showViewWords()}>1070 <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1071 <Show1072 when={viewedWords()}1073 fallback={1074 <>1075 <p class="peach-hint" style="margin:0;color:var(--peach-t2)">1076 Enter your master password to reveal your recovery words.1077 </p>1078 <input1079 class="peach-settings-input"1080 type="password"1081 placeholder="master password"1082 value={viewWordsPassword()}1083 onInput={(e) => setViewWordsPassword((e.target as HTMLInputElement).value)}1084 autocomplete="current-password"1085 onKeyDown={(e) => { if (e.key === "Enter") void handleViewRecoveryWords(); }}1086 />1087 <Show when={viewWordsError()}>1088 <p class="peach-hint peach-hint-error" style="margin:0">{viewWordsError()}</p>1089 </Show>1090 <button1091 class="peach-btn peach-btn--accent peach-btn--sm"1092 style="align-self:flex-start"1093 onClick={() => void handleViewRecoveryWords()}1094 disabled={viewWordsBusy() || !viewWordsPassword()}1095 >1096 {viewWordsBusy() ? "checking…" : "reveal recovery words"}1097 </button>1098 </>1099 }1100 >1101 <div class="peach-recovery-words-grid">1102 <For each={viewedWords()!}>1103 {(word, i) => (1104 <span class="peach-recovery-word"><b>{i() + 1}</b> {word}</span>1105 )}1106 </For>1107 </div>1108 <button1109 class="peach-btn peach-btn--subtle peach-btn--sm"1110 style="align-self:flex-start"1111 onClick={closeViewWords}1112 >done</button>1113 </Show>1114 </div>1115 </Show>11161117 <p class="peach-settings-sub-label">1118 DURESS PASSWORD1119 <Show when={!isPro()}>1120 <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>1121 </Show>1122 </p>1123 <div class="peach-settings-row peach-settings-row--top">1124 <div class="peach-settings-row-main">1125 <span>1126 Duress password1127 <Show when={!isPro()}>1128 <span class="peach-pro-badge">Pro</span>1129 </Show>1130 </span>1131 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">1132 If entered under coercion, shows a decoy empty vault instead of your real one.1133 </span>1134 </div>1135 <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>1136 {duressEnabled() ? "on" : "off"}1137 </div>1138 </div>11391140 <Show when={duressEnabled() && !showDuressSetup()}>1141 <div class="peach-settings-row peach-settings-row--meta">1142 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1143 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.1144 </span>1145 </div>1146 </Show>11471148 <Show when={duressSuccess()}>1149 <div class="peach-settings-row peach-settings-row--meta">1150 <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>1151 </div>1152 </Show>11531154 <Show when={!showDuressSetup()}>1155 <div class="peach-settings-row peach-settings-row--meta">1156 <button1157 class="peach-btn peach-btn--subtle peach-btn--sm"1158 onClick={() => {1159 1160 1161 1162 1163 1164 if (!isPro() && !duressEnabled()) {1165 props.onProGate?.("duress_vault");1166 setActiveCard("pro");1167 return;1168 }1169 setShowDuressSetup(true);1170 setDuressError(null);1171 setDuressSuccess(null);1172 setDuressPassword("");1173 setDuressConfirm("");1174 setDuressMainPassword("");1175 }}1176 >1177 {duressEnabled() ? "Change / disable" : "Set up"}1178 </button>1179 </div>1180 </Show>11811182 <Show when={showDuressSetup()}>1183 <div class="peach-duress-form">1184 <label class="peach-label">main password (to confirm)</label>1185 <input1186 class="peach-settings-input"1187 type="password"1188 placeholder="your current master password"1189 autocomplete="current-password"1190 value={duressMainPassword()}1191 onInput={(e) => setDuressMainPassword(e.currentTarget.value)}1192 />1193 <Show when={!duressEnabled()}>1194 <label class="peach-label">new duress password</label>1195 <input1196 class="peach-settings-input"1197 type="password"1198 placeholder="duress password (min 8 chars)"1199 autocomplete="new-password"1200 value={duressPassword()}1201 onInput={(e) => setDuressPassword(e.currentTarget.value)}1202 />1203 <label class="peach-label">confirm duress password</label>1204 <input1205 class="peach-settings-input"1206 type="password"1207 placeholder="confirm duress password"1208 autocomplete="new-password"1209 value={duressConfirm()}1210 onInput={(e) => setDuressConfirm(e.currentTarget.value)}1211 />1212 </Show>12131214 <Show when={duressError()}>1215 <p class="peach-hint peach-hint-error">{duressError()}</p>1216 </Show>12171218 <div class="peach-duress-actions">1219 <button1220 class="peach-btn peach-btn--subtle peach-btn--sm"1221 onClick={() => { setShowDuressSetup(false); setDuressError(null); }}1222 >1223 cancel1224 </button>1225 <Show when={duressEnabled()}>1226 <button1227 class="peach-btn peach-btn--ghost peach-btn--sm"1228 style="color:var(--peach-breach)"1229 onClick={() => void handleDisableDuress()}1230 disabled={duressSaving()}1231 >1232 {duressSaving() ? "disabling..." : "disable duress"}1233 </button>1234 </Show>1235 <Show when={!duressEnabled()}>1236 <button1237 class="peach-btn peach-btn--accent peach-btn--sm"1238 onClick={() => void handleSetupDuress()}1239 disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}1240 >1241 {duressSaving() ? "saving..." : "save"}1242 </button>1243 </Show>1244 </div>1245 </div>1246 </Show>12471248 <p class="peach-settings-sub-label">1249 CANARY ENTRIES1250 <Show when={!isPro()}>1251 <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>1252 </Show>1253 </p>1254 <div class="peach-settings-row peach-settings-row--top">1255 <div class="peach-settings-row-main">1256 <span>1257 Canary entries1258 <Show when={!isPro()}>1259 <span class="peach-pro-badge">Pro</span>1260 </Show>1261 </span>1262 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1263 Silently pings a webhook when an entry is accessed. Configure canary on any entry in your vault.1264 </span>1265 </div>1266 <span class="peach-settings-value" style="flex-shrink:0">1267 {canaryEntries().length > 0 ? `${canaryEntries().length} active` : "none"}1268 </span>1269 </div>1270 <Show when={canaryEntries().length > 0}>1271 <For each={canaryEntries()}>1272 {(entry) => (1273 <div class="peach-settings-row peach-settings-row--meta">1274 <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0">{entry.title}</span>1275 <button1276 class="peach-btn peach-btn--subtle peach-btn--sm"1277 onClick={() => void handleTestCanary(entry)}1278 disabled={canaryTestingId() === entry.id}1279 style="flex-shrink:0"1280 >1281 {canaryTestingId() === entry.id1282 ? "sending…"1283 : canaryTestResult()?.id === entry.id1284 ? (canaryTestResult()!.ok ? "sent ✓" : "failed ✗")1285 : "test"}1286 </button>1287 </div>1288 )}1289 </For>1290 </Show>12911292 <p class="peach-settings-sub-label">PASSKEYS</p>1293 <div class="peach-settings-row peach-settings-row--top">1294 <div class="peach-settings-row-main">1295 <span>Intercept passkey requests</span>1296 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">1297 Peach can handle passkey sign-in and creation on supported Chromium browsers.1298 </span>1299 </div>1300 <button1301 class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}1302 onClick={() => void handleTogglePasskeys()}1303 aria-label="Toggle passkey interception"1304 disabled={!passkeyState()?.supported || passkeySaving()}1305 >1306 <span class="peach-toggle-knob"></span>1307 </button>1308 </div>1309 <div class="peach-settings-row peach-settings-row--meta">1310 <span>Status</span>1311 <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>1312 </div>1313 <div class="peach-settings-row peach-settings-row--meta">1314 <span>Browser</span>1315 <span class="peach-settings-value">1316 {passkeyState()?.browserLabel ?? "Unknown"}1317 {passkeyState()?.supported ? " ✓" : " (interception unavailable)"}1318 </span>1319 </div>1320 <div class="peach-info-notice" style="margin-top:8px">1321 Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.1322 </div>1323 <Show when={passkeyState()?.conflict}>1324 <div class="peach-settings-row peach-settings-row--meta">1325 <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>1326 </div>1327 </Show>1328 </div>1329 </div>1330 );1331 }13321333 function renderVault() {1334 return (1335 <div class="peach-settings-body">1336 <div class="peach-settings-group">1337 <p class="peach-settings-sub-label">ENTRIES</p>1338 <div class="peach-settings-row peach-settings-row--meta">1339 <span>Entries</span>1340 <span class="peach-settings-value">{props.entriesCount}</span>1341 </div>1342 <div class="peach-settings-row peach-settings-row--meta">1343 <span>Duplicates</span>1344 <Show when={dedupDone() !== null} fallback={1345 <button1346 class="peach-btn peach-btn--subtle peach-btn--sm"1347 onClick={() => void handleCountDuplicates()}1348 disabled={dedupRunning()}1349 >1350 {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}1351 </button>1352 }>1353 <span class="peach-settings-value" style="color:var(--peach-sync-ok)">1354 {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}1355 </span>1356 </Show>1357 </div>1358 <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>1359 <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">1360 <div class="peach-restore-warning">1361 {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.1362 </div>1363 <div style="display:flex;gap:6px">1364 <button1365 class="peach-btn peach-btn--subtle peach-btn--sm"1366 onClick={() => setDedupCount(null)}1367 >1368 cancel1369 </button>1370 <button1371 class="peach-btn peach-btn--accent peach-btn--sm"1372 onClick={() => void handleDeduplicate()}1373 disabled={dedupRunning()}1374 >1375 {dedupRunning() ? "removing..." : "remove duplicates"}1376 </button>1377 </div>1378 </div>1379 </Show>13801381 <p class="peach-settings-sub-label">MAINTENANCE</p>1382 <div class="peach-settings-row peach-settings-row--meta">1383 <span>Trash auto-delete</span>1384 <select1385 class="peach-settings-select"1386 value={String(props.trashRetentionDays)}1387 onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}1388 >1389 <option value="7">7 days</option>1390 <option value="30">30 days</option>1391 <option value="90">90 days</option>1392 <option value="0">Never</option>1393 </select>1394 </div>1395 <div class="peach-settings-row peach-settings-row--meta">1396 <span>Advanced Vault Health</span>1397 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>1398 open1399 </button>1400 </div>1401 <Show when={declinedCardCount() > 0}>1402 <div class="peach-settings-row peach-settings-row--meta">1403 <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>1404 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>1405 Clear1406 </button>1407 </div>1408 </Show>14091410 <p class="peach-settings-sub-label">DATA</p>1411 <div class="peach-settings-row peach-settings-row--meta">1412 <span>Import passwords</span>1413 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>1414 open1415 </button>1416 </div>1417 <div class="peach-settings-row peach-settings-row--meta">1418 <span>Export backup</span>1419 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onExport}>1420 open1421 </button>1422 </div>1423 <div class="peach-settings-row peach-settings-row--meta">1424 <span>1425 Peach Codex1426 <Show when={!isPro()}>1427 <span class="peach-pro-badge">Pro</span>1428 </Show>1429 </span>1430 <button1431 class="peach-btn peach-btn--subtle peach-btn--sm"1432 onClick={() => props.onOpenCodexGenerate?.()}1433 >1434 open1435 </button>1436 </div>1437 {1438}1439 <div class="peach-settings-row peach-settings-row--meta">1440 <span>Restore from Codex</span>1441 <button1442 class="peach-btn peach-btn--subtle peach-btn--sm"1443 onClick={() => props.onOpenCodexRestore?.()}1444 >1445 open1446 </button>1447 </div>14481449 <span class="peach-settings-sub-label">VAULT HISTORY</span>1450 <Show when={historyLoading()}>1451 <p class="peach-settings-hint">Loading…</p>1452 </Show>1453 <Show when={!historyLoading() && historySnapshots().length === 0}>1454 <p class="peach-settings-hint">1455 No snapshots yet. Peach saves automatically before every change —1456 saves, syncs, and imports.1457 </p>1458 </Show>1459 <Show when={!historyLoading() && historySnapshots().length > 0}>1460 <p class="peach-settings-hint">1461 Restoring locks your vault. Unlock with the password that1462 encrypted that snapshot to confirm.1463 </p>1464 <div class="peach-history-list">1465 <For each={historySnapshots().slice(0, historyShowAll() ? undefined : 3)}>1466 {(snap) => (1467 <div class="peach-history-row">1468 <div class="peach-history-meta">1469 <span class="peach-history-time">1470 {formatSnapshotTime(snap.savedAt)}1471 </span>1472 <span class="peach-history-sub">1473 {[1474 snap.entryCount !== null1475 ? `up to ${snap.entryCount} ${snap.entryCount === 1 ? "entry" : "entries"}`1476 : null,1477 reasonLabel(snap.reason),1478 ]1479 .filter(Boolean)1480 .join(" · ")}1481 </span>1482 </div>1483 <button1484 class={`peach-btn peach-btn--subtle peach-btn--sm peach-history-btn${1485 historyRestoring() === snap.id ? " is-busy" : ""1486 }`}1487 onClick={() => void handleRestoreVaultSnapshot(snap.id)}1488 disabled={historyRestoring() !== null}1489 >1490 <Show when={historyRestoring() === snap.id} fallback="Restore">1491 …1492 </Show>1493 </button>1494 </div>1495 )}1496 </For>1497 </div>1498 <Show when={historySnapshots().length > 3}>1499 <button1500 class="peach-settings-advanced-toggle"1501 onClick={() => setHistoryShowAll((v) => !v)}1502 aria-expanded={historyShowAll()}1503 >1504 <span class="peach-settings-hint" style="margin:0">1505 {historyShowAll() ? "show less" : `${historySnapshots().length - 3} older`}1506 </span>1507 <span class="peach-settings-advanced-chevron">1508 {historyShowAll() ? "▲" : "▼"}1509 </span>1510 </button>1511 </Show>1512 <p class="peach-history-footer">1513 {historySnapshots().length} of 10 snapshots1514 </p>1515 </Show>15161517 <button1518 class="peach-settings-advanced-toggle"1519 onClick={() => setShowVaultAdvanced((v) => !v)}1520 aria-expanded={showVaultAdvanced()}1521 >1522 <span class="peach-settings-sub-label" style="margin:0">ADVANCED</span>1523 <span class="peach-settings-advanced-chevron">1524 {showVaultAdvanced() ? "▲" : "▼"}1525 </span>1526 </button>15271528 <Show when={showVaultAdvanced()}>1529 <div class="peach-danger-card">1530 <div class="peach-danger-card-head">1531 <span class="peach-danger-card-glyph" aria-hidden="true">⟳</span>1532 <h3 class="peach-danger-card-title">Replace vault from backup</h3>1533 </div>1534 <p class="peach-danger-card-copy">1535 Wipes the current vault and restores from a backup file.1536 Recoverable only if your backup is good.1537 </p>1538 <button1539 class="peach-danger-card-button"1540 onClick={() => props.onOpenFileImport?.()}1541 >1542 Choose backup file1543 </button>1544 </div>15451546 <Show when={props.onOpenPanic}>1547 <div class="peach-danger-card peach-danger-card--hazard">1548 <div class="peach-danger-card-head">1549 <span class="peach-danger-card-glyph" aria-hidden="true">⚠</span>1550 <h3 class="peach-danger-card-title">Destroy vault</h3>1551 </div>1552 <p class="peach-danger-card-copy">1553 Permanently wipes every entry, setting, and license on this1554 device. There is no recovery.1555 </p>1556 <button1557 class="peach-danger-card-button"1558 onClick={() => props.onOpenPanic?.()}1559 >1560 Begin destruction1561 </button>1562 </div>1563 </Show>1564 </Show>1565 </div>1566 </div>1567 );1568 }15691570 function renderSync() {1571 return (1572 <div class="peach-settings-body">1573 <div class="peach-settings-group">1574 <p class="peach-settings-sub-label" style="margin-top:0">SYNC STATUS</p>1575 <div1576 class="peach-settings-row peach-settings-row--meta"1577 style="align-items:center;gap:8px"1578 role="status"1579 aria-label={syncLightLabel()}1580 title={syncLightLabel()}1581 >1582 <span class={`stk-pip stk-pip--sync is-${syncLight()}`} aria-hidden="true" />1583 <span class="peach-settings-value" style="color:var(--peach-t2);line-height:1.5;flex:1;min-width:0;overflow-wrap:anywhere">1584 {syncFlash() ?? lastSyncLine()}1585 </span>1586 </div>1587 <Show when={pairedDevices().length > 0}>1588 <div class="peach-settings-row peach-settings-row--meta">1589 <span class="peach-settings-value" style="color:var(--peach-t2)">1590 {(syncSummary().entriesSent ?? 0) + (syncSummary().entriesReceived ?? 0) > 01591 ? `${syncSummary().entriesSent ?? 0} sent · ${syncSummary().entriesReceived ?? 0} received`1592 : ""}1593 </span>1594 <button1595 class="peach-btn peach-btn--subtle peach-btn--sm"1596 aria-label="Sync this vault now"1597 disabled={syncStatus() === "syncing"}1598 onClick={() => void syncNow()}1599 >{syncStatus() === "syncing" ? "syncing…" : "sync now"}</button>1600 </div>1601 </Show>16021603 <p class="peach-settings-sub-label">ADD A DEVICE</p>1604 <button1605 class="peach-sync-pair-option"1606 onClick={() => props.onOpenSyncPairing?.("code")}1607 >1608 <span class="peach-sync-pair-option-icon" aria-hidden="true">1609 <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">1610 <rect x="6" y="2.5" width="8" height="15" rx="1.6" />1611 <path d="M9 15 L11 15" />1612 </svg>1613 </span>1614 <span class="peach-sync-pair-option-main">1615 <span class="peach-sync-pair-option-title">Show a code</span>1616 <span class="peach-sync-pair-option-sub">Scan it with Peach on your phone, then confirm one 8-digit code</span>1617 </span>1618 <span class="peach-sync-pair-option-chevron" aria-hidden="true">›</span>1619 </button>1620 <Show when={!isPro()}>1621 <p class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2);margin:8px 2px 0">1622 At least one device needs Peach Pro — pair with a Pro device and it carries over to here.1623 </p>1624 </Show>16251626 <Show when={pairedDevices().length > 0}>1627 <p class="peach-settings-sub-label">PAIRED DEVICES</p>1628 <For each={pairedDevices()}>1629 {(d) => (1630 <div class="peach-settings-row peach-settings-row--top">1631 <div class="peach-settings-row-main">1632 <span>{d.deviceName ?? "Unnamed device"}</span>1633 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1634 {deviceTypeLabel(d.deviceType)} · {syncRelativeTime(d.lastSyncedAt)}{isSyncStale(d) ? " · not seen recently" : ""}1635 </span>1636 </div>1637 <Show1638 when={confirmUnpair() === d.deviceId}1639 fallback={1640 <button1641 class="peach-btn peach-btn--subtle peach-btn--sm"1642 onClick={() => setConfirmUnpair(d.deviceId)}1643 >remove</button>1644 }1645 >1646 {16471648164916501651}1652 <div class="peach-unpair-confirm">1653 <p class="peach-unpair-confirm-note">1654 Stops this device receiving future changes. It doesn't1655 lock it out of what it already has — if it's lost or1656 stolen, also change your master password and generate1657 new recovery words.1658 </p>1659 <div style="display:flex;gap:6px;justify-content:flex-end">1660 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => setConfirmUnpair(null)}>cancel</button>1661 <button1662 class="peach-btn peach-btn--sm"1663 style="background:var(--peach-breach);border-color:var(--peach-breach)"1664 onClick={() => void unpairDevice(d.deviceId)}1665 >remove</button>1666 </div>1667 </div>1668 </Show>1669 </div>1670 )}1671 </For>1672 </Show>16731674 <Show when={pendingVouched().length > 0}>1675 <p class="peach-settings-sub-label">1676 PENDING DEVICES1677 <span class="peach-pro-badge" style="background:#C8A840;color:#1a1a1a">{pendingVouched().length}</span>1678 </p>1679 <For each={pendingVouched()}>1680 {(dev) => (1681 <div class="peach-settings-row peach-settings-row--top">1682 <div class="peach-settings-row-main">1683 <span>{dev.deviceName}</span>1684 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1685 Was added by another of your devices. Allow it to sync with this vault?1686 </span>1687 </div>1688 <div style="display:flex;gap:6px;flex-shrink:0">1689 <button1690 class="peach-btn peach-btn--sm"1691 disabled={vouchedBusy() === dev.deviceId}1692 onClick={() => void confirmVouched(dev.deviceId)}1693 >Allow</button>1694 <button1695 class="peach-btn peach-btn--subtle peach-btn--sm"1696 disabled={vouchedBusy() === dev.deviceId}1697 onClick={() => void rejectVouched(dev.deviceId)}1698 >Reject</button>1699 </div>1700 </div>1701 )}1702 </For>1703 </Show>17041705 <button1706 class="peach-settings-advanced-toggle peach-sync-disclosure-toggle"1707 onClick={() => setShowSyncConnection((value) => !value)}1708 aria-expanded={showSyncConnection()}1709 aria-controls="peach-sync-connection"1710 >1711 <span>1712 <span class="peach-settings-sub-label" style="margin:0">CONNECTION</span>1713 <span class="peach-sync-disclosure-summary">Relay and privacy choices</span>1714 </span>1715 <span class="peach-settings-advanced-chevron">1716 {showSyncConnection() ? "▲" : "▼"}1717 </span>1718 </button>1719 <Show when={showSyncConnection()}>1720 <div id="peach-sync-connection" class="peach-sync-disclosure-content">1721 <SyncModeSelector />1722 </div>1723 </Show>17241725 <button1726 class="peach-settings-advanced-toggle peach-sync-disclosure-toggle"1727 onClick={() => setShowSyncRecovery((value) => !value)}1728 aria-expanded={showSyncRecovery()}1729 aria-controls="peach-sync-recovery"1730 >1731 <span>1732 <span class="peach-settings-sub-label" style="margin:0">RECOVERY & SECURITY</span>1733 <span class="peach-sync-disclosure-summary">For a lost or stolen device</span>1734 </span>1735 <span class="peach-settings-advanced-chevron">1736 {showSyncRecovery() ? "▲" : "▼"}1737 </span>1738 </button>1739 <Show when={showSyncRecovery()}>1740 <div id="peach-sync-recovery" class="peach-sync-disclosure-content">1741 <Show1742 when={rotateConfirmOpen()}1743 fallback={1744 <div class="peach-settings-row peach-settings-row--top">1745 <div class="peach-settings-row-main">1746 <span>Rotate sync identity</span>1747 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1748 Disconnects every device and replaces the keys used for sync.1749 </span>1750 </div>1751 <button1752 class="peach-btn peach-btn--subtle peach-btn--sm"1753 style="color:var(--peach-breach);border-color:color-mix(in srgb,var(--peach-breach) 40%,transparent)"1754 onClick={() => { setRotateError(null); setRotateConfirmOpen(true); }}1755 >rotate</button>1756 </div>1757 }1758 >1759 <div class="peach-info-notice" style="margin-top:8px;border-color:color-mix(in srgb,var(--peach-breach) 40%,transparent)">1760 This disconnects every paired device and changes your 12 recovery words. You'll be shown the new words next.1761 <Show when={rotateError()}>1762 <div style="color:var(--peach-breach);margin-top:6px">{rotateError()}</div>1763 </Show>1764 <div style="display:flex;gap:6px;margin-top:10px">1765 <button1766 class="peach-btn peach-btn--subtle peach-btn--sm"1767 disabled={rotating()}1768 onClick={() => setRotateConfirmOpen(false)}1769 >Cancel</button>1770 <button1771 class="peach-btn peach-btn--sm"1772 style="background:var(--peach-breach);border-color:var(--peach-breach)"1773 disabled={rotating()}1774 onClick={() => void doRotateSyncIdentity()}1775 >{rotating() ? "Rotating…" : "Rotate sync identity"}</button>1776 </div>1777 </div>1778 </Show>1779 </div>1780 </Show>1781 </div>1782 </div>1783 );1784 }17851786 function renderAppearance() {1787 const sizeOptions: Array<{ id: "medium" | "large"; label: string }> = [1788 { id: "medium", label: "Default" },1789 { id: "large", label: "Large" },1790 ];1791 return (1792 <div class="peach-settings-body">1793 <div class="peach-settings-group">1794 <p class="peach-settings-sub-label">THEME</p>1795 <div class="peach-settings-row peach-settings-row--meta">1796 <span>Theme</span>1797 <button1798 class="peach-btn peach-btn--subtle peach-btn--sm"1799 onClick={() =>1800 props.onThemeChange(props.theme === "dark" ? "light" : "dark")1801 }1802 >1803 {props.theme === "dark" ? "Switch to light" : "Switch to dark"}1804 </button>1805 </div>18061807 <p class="peach-settings-sub-label">ACCENT</p>1808 <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">1809 <span>Accent</span>1810 <div class="peach-accent-picker">1811 <For each={ACCENT_COLORS}>1812 {(color) => (1813 <button1814 class="peach-accent-swatch peach-swatch"1815 classList={{ "is-active": props.accentColor === color.name }}1816 style={{ background: color.hex }}1817 onClick={() => void props.onAccentChange(color.name)}1818 aria-label={color.name}1819 title={color.name}1820 />1821 )}1822 </For>1823 </div>1824 </div>18251826 <p class="peach-settings-sub-label">TEXT SIZE</p>1827 <div class="peach-settings-row peach-settings-row--meta">1828 <span>Size</span>1829 <div class="peach-pill-group">1830 <For each={sizeOptions}>1831 {(opt) => (1832 <button1833 class="peach-btn peach-btn--ghost peach-btn--sm"1834 classList={{ "is-active": props.textSize === opt.id }}1835 onClick={() => void props.onTextSizeChange(opt.id)}1836 >1837 {opt.label}1838 </button>1839 )}1840 </For>1841 </div>1842 </div>18431844 <p class="peach-settings-sub-label">MOTION</p>1845 <div class="peach-settings-row peach-settings-row--meta">1846 <span>Reduce motion</span>1847 <button1848 class="peach-btn peach-btn--subtle peach-btn--sm"1849 onClick={() => void props.onReduceMotionChange(!props.reduceMotion)}1850 >1851 {props.reduceMotion ? "On" : "Off"}1852 </button>1853 </div>1854 </div>1855 </div>1856 );1857 }18581859 function renderShortcuts() {1860 const [capturingAction, setCapturingAction] = createSignal<ShortcutAction | null>(null);1861 const [conflictMsg, setConflictMsg] = createSignal<string | null>(null);18621863 function startCapture(action: ShortcutAction) {1864 setConflictMsg(null);1865 setCapturingAction(action);1866 }1867 function cancelCapture() {1868 setCapturingAction(null);1869 setConflictMsg(null);1870 }1871 function commitBinding(action: ShortcutAction, binding: Keybinding) {1872 const conflict = findConflict(props.shortcuts, binding, action);1873 if (conflict) {1874 const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict);1875 setConflictMsg(`Already bound to "${other?.label ?? conflict}"`);1876 return;1877 }1878 const next = { ...props.shortcuts, [action]: binding };1879 setCapturingAction(null);1880 setConflictMsg(null);1881 void props.onShortcutsChange(next);1882 }1883 function resetAction(action: ShortcutAction) {1884 const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] };1885 void props.onShortcutsChange(next);1886 }1887 function resetAll() {1888 void props.onShortcutsChange({ ...DEFAULT_BINDINGS });1889 }18901891 1892 1893 function handleCaptureKey(e: KeyboardEvent) {1894 e.preventDefault();1895 e.stopPropagation();1896 if (e.key === "Escape") { cancelCapture(); return; }1897 const binding = keybindingFromEvent(e);1898 if (!binding) return; 1899 const action = capturingAction();1900 if (action) commitBinding(action, binding);1901 }19021903 return (1904 <div class="peach-settings-body">1905 <div class="peach-settings-group peach-shortcuts-group">1906 <p class="peach-settings-sub-label">ENTRY VIEW</p>1907 <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry")}>1908 {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1909 </For>1910 </div>19111912 <div class="peach-settings-group peach-shortcuts-group">1913 <p class="peach-settings-sub-label">ANYWHERE IN POPUP</p>1914 <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global")}>1915 {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1916 </For>1917 </div>19181919 <Show when={conflictMsg()}>1920 <div class="peach-shortcuts-conflict">{conflictMsg()}</div>1921 </Show>19221923 <div class="peach-settings-group" style="display:flex;flex-direction:column;gap:8px">1924 <div class="peach-settings-row peach-settings-row--meta">1925 <span>Inline autofill on page</span>1926 <a1927 class="peach-settings-link"1928 href="chrome://extensions/shortcuts"1929 onClick={(e) => {1930 e.preventDefault();1931 chrome.tabs.create({ url: "chrome://extensions/shortcuts" });1932 }}1933 >1934 ⌘⇧P — customize ↗1935 </a>1936 </div>1937 <button1938 class="peach-btn peach-btn--subtle peach-btn--sm"1939 style="align-self:flex-start"1940 onClick={resetAll}1941 >1942 Reset all to defaults1943 </button>1944 </div>1945 </div>1946 );19471948 function renderRow(1949 desc: typeof SHORTCUT_DESCRIPTORS[number],1950 capturing: () => ShortcutAction | null,1951 onStart: (a: ShortcutAction) => void,1952 onCancel: () => void,1953 onReset: (a: ShortcutAction) => void,1954 onKey: (e: KeyboardEvent) => void,1955 ) {1956 const isActive = () => capturing() === desc.action;1957 const binding = () => props.shortcuts[desc.action];1958 const isCustom = () => {1959 const b = binding();1960 const d = DEFAULT_BINDINGS[desc.action];1961 return b.key !== d.key1962 || Boolean(b.ctrl) !== Boolean(d.ctrl)1963 || Boolean(b.meta) !== Boolean(d.meta)1964 || Boolean(b.shift) !== Boolean(d.shift)1965 || Boolean(b.alt) !== Boolean(d.alt);1966 };1967 return (1968 <div class="peach-shortcut-row">1969 <span class="peach-shortcut-row-label">{desc.label}</span>1970 <button1971 class="peach-shortcut-chip"1972 classList={{ "is-capturing": isActive(), "is-custom": isCustom() }}1973 onClick={() => isActive() ? onCancel() : onStart(desc.action)}1974 onKeyDown={(e) => { if (isActive()) onKey(e); }}1975 ref={(el) => {1976 1977 if (el) {1978 queueMicrotask(() => { if (isActive()) el.focus(); });1979 }1980 }}1981 >1982 {isActive() ? "Press a key…" : formatKeybinding(binding())}1983 </button>1984 <Show when={isCustom() && !isActive()}>1985 <button1986 class="peach-shortcut-reset"1987 title="Reset to default"1988 onClick={() => onReset(desc.action)}1989 >1990 ↺1991 </button>1992 </Show>1993 </div>1994 );1995 }1996 }19971998 const PRO_FEATURES: Array<{1999 feature: ProFeature;2000 name: string;2001 desc: string;2002 }> = [2003 { feature: "sync", name: "Peach Sync", desc: "Encrypted · peer-to-peer" },2004 { feature: "codex", name: "Peach Codex", desc: "Restore from paper" },2005 { feature: "passkeys", name: "Passkeys", desc: "Zero-knowledge WebAuthn" },2006 { feature: "advanced_health", name: "Advanced Vault Health", desc: "Reuse · age · exposure" },2007 ];20082009 const PRO_SECURITY_FEATURES: Array<{2010 feature: ProFeature;2011 name: string;2012 desc: string;2013 }> = [2014 { feature: "canary_entry", name: "Canary entries", desc: "Detects vault compromise" },2015 { feature: "duress_vault", name: "Duress vault", desc: "Decoy under coercion" },2016 ];20172018 const [licenseKey, setLicenseKey] = createSignal<string | null>(null);2019 const [showKey, setShowKey] = createSignal(false);20202021 const [deactivating, setDeactivating] = createSignal(false);20222023 async function handleDeactivateLicense() {2024 setDeactivating(true);2025 await sendMessage({ type: "DEACTIVATE_LICENSE" });2026 setLicenseInfo(null);2027 await props.onLicenseChange?.();2028 setDeactivating(false);2029 }20302031 async function handleTestCanary(entry: {id: string; title: string; canary: Canary}) {2032 setCanaryTestingId(entry.id);2033 setCanaryTestResult(null);2034 const res = await sendMessage({2035 type: "FIRE_CANARY_PING",2036 payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger: "test" },2037 }) as { ok: boolean };2038 setCanaryTestResult({ id: entry.id, ok: res.ok });2039 setCanaryTestingId(null);2040 setTimeout(() => setCanaryTestResult((r) => r?.id === entry.id ? null : r), 3000);2041 }20422043 function renderLicense() {2044 async function onActivate() {2045 await handleActivateLicense();2046 if ((licenseInfo()?.valid ?? false) && licenseInfo()?.plan === "pro") {2047 await props.onLicenseChange?.();2048 }2049 }20502051 return (2052 <div class="peach-settings-body">2053 <Show2054 when={isPro()}2055 fallback={2056 <div class="peach-pro-upgrade">2057 <p class="peach-pro-eyebrow">PEACH PRO · ONE-TIME</p>20582059 <h2 class="peach-pro-headline">2060 Every<em>thing,</em><br />every<em>where.</em>2061 </h2>20622063 <p class="peach-pro-subtitle">2064 One key unlocks every Pro feature — for the lifetime of the app.2065 </p>20662067 <div class="peach-pro-grid">2068 <For each={PRO_FEATURES}>2069 {(item, i) => (2070 <div2071 class="peach-pro-grid-item"2072 classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}2073 >2074 <span class="peach-pro-grid-num">{String(i() + 1).padStart(2, "0")}</span>2075 <span class="peach-pro-grid-name">{item.name}</span>2076 <span class="peach-pro-grid-desc">{item.desc}</span>2077 </div>2078 )}2079 </For>2080 </div>20812082 <p class="peach-pro-section-label">ADVANCED SECURITY</p>2083 <div class="peach-pro-grid">2084 <For each={PRO_SECURITY_FEATURES}>2085 {(item, i) => (2086 <div2087 class="peach-pro-grid-item"2088 classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}2089 >2090 <span class="peach-pro-grid-num">{String(PRO_FEATURES.length + i() + 1).padStart(2, "0")}</span>2091 <span class="peach-pro-grid-name">{item.name}</span>2092 <span class="peach-pro-grid-desc">{item.desc}</span>2093 </div>2094 )}2095 </For>2096 </div>20972098 <div class="peach-pro-upgrade-actions">2099 <p class="peach-pro-pricing">2100 $39 <span class="peach-pro-pricing-sub">once · yours forever</span>2101 </p>2102 <a2103 href="https://buy.polar.sh/polar_cl_IXSFIPojx67rcOTksn8LkH5mnU3osPHtYqBIl1eyuIz"2104 target="_blank"2105 rel="noopener noreferrer"2106 class="peach-pro-upgrade-cta"2107 >2108 Get Peach Pro2109 </a>2110 <div class="peach-pro-key-divider"><span>ALREADY HAVE A KEY</span></div>2111 <div class="peach-pro-key-inline">2112 <input2113 class="peach-pro-key-inline-input"2114 type="text"2115 placeholder="PEACH-XXXX-XXXX-XXXX"2116 value={licenseKeyInput()}2117 onInput={(e) => setLicenseKeyInput(e.currentTarget.value)}2118 onKeyDown={(e) => { if (e.key === "Enter") void onActivate(); }}2119 autocomplete="off"2120 spellcheck={false}2121 />2122 <button2123 class="peach-pro-key-apply"2124 onClick={() => void onActivate()}2125 disabled={activatingLicense() || !licenseKeyInput().trim()}2126 >2127 {activatingLicense() ? "…" : "APPLY"}2128 </button>2129 </div>2130 <Show when={licenseError()}>2131 <p class="peach-pro-key-error">{licenseError()}</p>2132 </Show>2133 </div>2134 </div>2135 }2136 >2137 <div class="peach-pro-active">2138 <div class="peach-pro-active-hero">2139 <button2140 class="peach-pro-active-mark-wrap"2141 type="button"2142 aria-label="Celebrate"2143 onClick={handleProCelebrationClick}2144 >2145 <PeachMark class="peach-pro-active-mark" />2146 <Show when={justActivated()}>2147 <div class="peach-pro-sparks" aria-hidden="true">2148 <span class="peach-pro-spark" style="--spark-x:0px;--spark-y:-54px;--spark-delay:0ms">✦</span>2149 <span class="peach-pro-spark" style="--spark-x:38px;--spark-y:-38px;--spark-delay:50ms">✦</span>2150 <span class="peach-pro-spark" style="--spark-x:54px;--spark-y:0px;--spark-delay:110ms">·</span>2151 <span class="peach-pro-spark" style="--spark-x:38px;--spark-y:38px;--spark-delay:30ms">✦</span>2152 <span class="peach-pro-spark" style="--spark-x:0px;--spark-y:54px;--spark-delay:80ms">·</span>2153 <span class="peach-pro-spark" style="--spark-x:-38px;--spark-y:38px;--spark-delay:130ms">✦</span>2154 <span class="peach-pro-spark" style="--spark-x:-54px;--spark-y:0px;--spark-delay:55ms">·</span>2155 <span class="peach-pro-spark" style="--spark-x:-38px;--spark-y:-38px;--spark-delay:95ms">✦</span>2156 </div>2157 </Show>2158 </button>2159 <h2 class="peach-pro-active-title">Peach Pro</h2>2160 <p class="peach-pro-active-since">{formatActivatedSince(licenseInfo()?.issued)}</p>2161 <Show when={justActivated()}>2162 <p class="peach-pro-thankyou-text" aria-live="polite">thank you ✦</p>2163 </Show>2164 </div>21652166 <Show when={licenseKey()}>2167 <div class="peach-settings-license">2168 <span class="peach-settings-label">LICENSE KEY</span>2169 <div class="peach-settings-license-row">2170 <Show2171 when={showKey()}2172 fallback={2173 <span class="peach-settings-license-key peach-settings-license-key--masked" aria-label="License key hidden">2174 ••••••••••••••••••••••••••••2175 </span>2176 }2177 >2178 <span class="peach-settings-license-key">{licenseKey()!}</span>2179 </Show>2180 <div class="peach-settings-license-actions">2181 <button2182 class="peach-settings-license-copy"2183 title="Copy license key"2184 onClick={() => copyLicenseKey(licenseKey()!)}2185 >2186 {copied() ? "✓" : "copy"}2187 </button>2188 <button2189 class="peach-settings-license-toggle"2190 aria-label={showKey() ? "Hide license key" : "Reveal license key"}2191 title={showKey() ? "Hide" : "Reveal"}2192 onClick={() => setShowKey(v => !v)}2193 >2194 <Show2195 when={showKey()}2196 fallback={2197 <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">2198 <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />2199 <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />2200 </svg>2201 }2202 >2203 <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">2204 <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />2205 <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />2206 <line x1="2" y1="2" x2="12" y2="12" />2207 </svg>2208 </Show>2209 </button>2210 </div>2211 </div>2212 </div>2213 </Show>22142215 <div class="peach-pro-active-features">2216 <For each={[...PRO_FEATURES, ...PRO_SECURITY_FEATURES]}>2217 {(item) => (2218 <div class="peach-pro-active-feature">2219 <span class="peach-pro-active-feature-mark">✦</span>2220 <span class="peach-pro-active-feature-name">{item.name}</span>2221 </div>2222 )}2223 </For>2224 </div>22252226 <button2227 class="peach-pro-deactivate"2228 onClick={() => void handleDeactivateLicense()}2229 disabled={deactivating()}2230 >2231 {deactivating() ? "Deactivating…" : "Deactivate"}2232 </button>2233 </div>2234 </Show>2235 </div>2236 );2237 }22382239 function renderFeedback() {2240 return (2241 <div class="peach-settings-body">2242 <div class="peach-settings-group peach-feedback-group">2243 <Show2244 when={feedbackState() === 'sent'}2245 fallback={2246 <>2247 <p class="peach-feedback-intro">2248 Help us improve Peach. Every report goes directly to our inbox.2249 </p>22502251 <p class="peach-settings-sub-label">TYPE</p>2252 <div class="peach-feedback-chips">2253 <For each={FEEDBACK_CATEGORIES}>2254 {(cat) => {2255 const selected = () => fbCategory() === cat;2256 return (2257 <button2258 type="button"2259 class={`peach-feedback-chip ${selected() ? "is-active" : ""}`}2260 aria-pressed={selected()}2261 onClick={() => setFbCategory(cat)}2262 >2263 {cat}2264 </button>2265 );2266 }}2267 </For>2268 </div>22692270 <p class="peach-settings-sub-label">MESSAGE</p>2271 <textarea2272 class="peach-feedback-textarea"2273 placeholder="Describe the issue or idea..."2274 maxLength={MAX_FEEDBACK_MESSAGE_LENGTH}2275 value={fbMessage()}2276 onInput={(e) => setFbMessage(e.currentTarget.value)}2277 />22782279 <p class="peach-feedback-meta">2280 Version {version} · will be included automatically2281 </p>22822283 <button2284 type="button"2285 class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"2286 disabled={!fbMessage().trim() || feedbackState() === 'sending'}2287 onClick={() => void handleSubmitFeedback()}2288 >2289 <Switch>2290 <Match when={feedbackState() === 'idle'}>Send</Match>2291 <Match when={feedbackState() === 'sending'}>Sending...</Match>2292 <Match when={feedbackState() === 'error'}>Failed - try again</Match>2293 </Switch>2294 </button>22952296 <p class="peach-feedback-anon">2297 Sent anonymously · no account required2298 </p>2299 </>2300 }2301 >2302 <div class="peach-feedback-thanks" role="status" aria-live="polite">2303 <p class="peach-feedback-thanks-title">Thank you</p>2304 <p>2305 Your note is in the queue. Someone will process it soon.2306 </p>2307 </div>2308 </Show>2309 </div>2310 </div>2311 );2312 }23132314 const bodies: StackBodies = {2315 protection: renderSecurity(),2316 vault: renderVault(),2317 sync: renderSync(),2318 appearance: renderAppearance(),2319 shortcuts: renderShortcuts(),2320 pro: renderLicense(),2321 feedback: renderFeedback(),2322 };23232324 return (2325 <Show2326 when={showNewRecoveryWords()}2327 fallback={2328 <div2329 class="peach-overlay-view peach-settings-overlay"2330 classList={{ "ov-enter-right": !props.isExiting, "ov-exit-right": !!props.isExiting }}2331 style="display:flex;flex-direction:column"2332 >2333 <StackDeck2334 active={activeCard()}2335 onActiveChange={setActiveCard}2336 onBack={props.onBack}2337 cards={cards()}2338 bodies={bodies}2339 version={version}2340 isFanning={isFanning()}2341 celebratingCardId={justActivated() ? "pro" : null}2342 isPro={isPro()}2343 />2344 </div>2345 }2346 >2347 <RecoveryWordDisplayView onDone={() => setShowNewRecoveryWords(false)} />2348 </Show>2349 );2350}