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 .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "")94 .replace(/&/g, "&")95 .replace(/</g, "<")96 .replace(/>/g, ">")97 .replace(/"/g, """)98 .replace(/'/g, "'");99}100101type DataCollectionPermissionApi = {102 permissions?: {103 getAll?: () => Promise<{ data_collection?: string[] }>;104 request?: (permissions: { data_collection: string[] }) => Promise<boolean>;105 };106};107108async function requestFeedbackDataPermission(): Promise<boolean> {109 const api = ((globalThis as { browser?: DataCollectionPermissionApi }).browser ??110 (typeof chrome !== "undefined" ? chrome : undefined)) as DataCollectionPermissionApi | undefined;111 if (!api?.permissions?.getAll) return true;112113 try {114 const permissions = await api.permissions.getAll();115 if (!("data_collection" in permissions)) return true;116 if (permissions.data_collection?.includes(FEEDBACK_DATA_PERMISSION)) return true;117 return await (api.permissions.request?.({ data_collection: [FEEDBACK_DATA_PERMISSION] }) ?? Promise.resolve(false));118 } catch {119 return false;120 }121}122123function formatActivatedSince(issued: string | undefined): string {124 if (!issued) return "Active";125 try {126 const d = new Date(issued);127 if (Number.isNaN(d.getTime())) return "Active";128 return `Active since ${d.toLocaleDateString(undefined, { month: "long", year: "numeric" })}`;129 } catch {130 return "Active";131 }132}133134function initialSectionToCard(value: string | null | undefined): CardId | null {135 if (!value) return null;136 if (value === "security") return "protection";137 if (value === "license") return "pro";138 if (139 value === "protection" ||140 value === "vault" ||141 value === "sync" ||142 value === "appearance" ||143 value === "shortcuts" ||144 value === "pro" ||145 value === "feedback"146 ) {147 return value;148 }149 return null;150}151152export function SettingsView(props: SettingsProps) {153 const manifest = chrome.runtime.getManifest();154 const version = manifest.version;155 const initial = props.initialSection;156 const [activeCard, setActiveCard] = createSignal<CardId>(157 props.upgradeHighlight ? "pro" : initialSectionToCard(initial) ?? "pro"158 );159160 createEffect(() => { props.onCardChange?.(activeCard()); });161162 const [justActivated, setJustActivated] = createSignal(false);163 const [isFanning, setIsFanning] = createSignal(false);164165 function triggerCelebration() {166 if (isFanning()) return;167 setJustActivated(false);168 setIsFanning(true);169 setActiveCard("pro");170 const fanOrder: CardId[] = ["protection", "vault", "sync", "appearance", "shortcuts", "feedback"];171 fanOrder.forEach((id, i) => {172 window.setTimeout(() => setActiveCard(id), (i + 1) * PRO_CELEBRATION_CARD_STEP_MS);173 });174 window.setTimeout(() => {175 setActiveCard("pro");176 setIsFanning(false);177 setJustActivated(true);178 window.setTimeout(() => setJustActivated(false), PRO_CELEBRATION_THANK_YOU_MS);179 }, (fanOrder.length + 1) * PRO_CELEBRATION_CARD_STEP_MS);180 }181182 function handleProCelebrationClick(e: MouseEvent) {183 e.stopPropagation();184 triggerCelebration();185 }186187 const [checkingBreaches, setCheckingBreaches] = createSignal(false);188 const [breachResult, setBreachResult] = createSignal<string | null>(null);189 const [licenseInfo, setLicenseInfo] = createSignal<LicenseInfo | null>(null);190 const [licenseKeyInput, setLicenseKeyInput] = createSignal("");191 const [licenseError, setLicenseError] = createSignal<string | null>(null);192 const [, setLicenseSuccess] = createSignal<string | null>(null);193 const [activatingLicense, setActivatingLicense] = createSignal(false);194195 const isPro = () => props.isPro ?? false;196 const cards = createMemo<StackCardSpec[]>(() => [197 {198 id: "pro",199 num: "I · FOREVER",200 title: "Peach Pro",201 lede: isPro() ? "active · paid once, yours forever" : "$39 once · yours forever",202 bgVar: "--stack-pro-bg",203 fgVar: "--stack-pro-fg",204 icon: "spark",205 onIconClick: isPro() ? triggerCelebration : undefined,206 },207 {208 id: "protection",209 num: "II · PROTECT",210 title: "Protection",211 lede: `${props.breachMonitoring ? "breaches watched" : "breach watch off"} · ${props.lockOnClose ? "locks on close" : "manual lock"}`,212 bgVar: "--stack-protect-bg",213 fgVar: "--stack-protect-fg",214 icon: "shield",215 },216 {217 id: "vault",218 num: "III · KEEP",219 title: "Vault",220 lede: `${props.entriesCount} ${props.entriesCount === 1 ? "entry" : "entries"} · backups, history, cleanup`,221 bgVar: "--stack-vault-bg",222 fgVar: "--stack-vault-fg",223 icon: "lock",224 },225 {226 id: "sync",227 num: "IV · ECHO",228 title: "Sync",229 lede: isPro() ? "your devices, one vault" : "Pro · encrypted device sync",230 bgVar: "--stack-sync-bg",231 fgVar: "--stack-sync-fg",232 icon: "sync",233 },234 {235 id: "appearance",236 num: "V · DRESS",237 title: "Appearance",238 lede: `${props.theme} · ${props.accentColor} · ${props.textSize}`,239 bgVar: "--stack-appear-bg",240 fgVar: "--stack-appear-fg",241 icon: "sun",242 },243 {244 id: "shortcuts",245 num: "VI · KEYS",246 title: "Shortcuts",247 lede: `${SHORTCUT_DESCRIPTORS.length} bindings · what your fingers know`,248 bgVar: "--stack-shortcut-bg",249 fgVar: "--stack-shortcut-fg",250 icon: "keys",251 },252 {253 id: "feedback",254 num: "VII · ECHO",255 title: "Feedback",256 lede: `version ${version} · tell us what would make it better`,257 bgVar: "--stack-feedback-bg",258 fgVar: "--stack-feedback-fg",259 icon: "bubble",260 },261 ]);262 const [biometricSupported, setBiometricSupported] = createSignal(false);263 const [biometricStored, setBiometricStored] = createSignal(false);264 const [biometricBusy, setBiometricBusy] = createSignal(false);265 const [biometricError, setBiometricError] = createSignal<string | null>(null);266 const [passkeyState, setPasskeyState] = createSignal<PasskeyProviderState | null>(null);267 const [passkeySaving, setPasskeySaving] = createSignal(false);268269 270 271 interface PendingVouched { deviceId: string; deviceName: string }272 const [pendingVouched, setPendingVouched] = createSignal<PendingVouched[]>([]);273 const [vouchedBusy, setVouchedBusy] = createSignal<string | null>(null);274 275 const [pairedDevices, setPairedDevices] = createSignal<PairedDevice[]>([]);276 const [confirmUnpair, setConfirmUnpair] = createSignal<string | null>(null);277 const [syncStatus, setSyncStatus] = createSignal<"idle" | "syncing" | "error">("idle");278 const [syncFlash, setSyncFlash] = createSignal<string | null>(null);279 280 281 const [syncSummary, setSyncSummary] = createSignal<SyncOutcomeSummary>({});282 const syncLight = () =>283 syncStatus() === "syncing"284 ? "busy"285 : syncIndicator(syncSummary(), pairedDevices().length);286 const syncLightLabel = () => syncIndicatorLabel(syncLight(), syncSummary());287 const lastSyncLine = () => {288 const s = syncSummary();289 if (!s.outcome) return "No sync attempted yet";290 return `${syncRelativeTime(s.finishedAt)} · ${describeSyncOutcome(s)}`;291 };292 async function loadSyncState() {293 const r = (await sendMessage({ type: "GET_SYNC_STATE" })) as {294 ok: boolean;295 data?: SyncOutcomeSummary;296 };297 if (r.ok && r.data) {298 setSyncSummary(r.data);299 setSyncStatus(r.data.status ?? "idle");300 }301 }302303 const STALE_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000;304 function syncRelativeTime(when: number | undefined): string {305 if (!when) return "Not synced yet";306 const elapsed = Date.now() - when;307 if (elapsed < 60_000) return "Just now";308 const minutes = Math.floor(elapsed / 60_000);309 if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"} ago`;310 const hours = Math.floor(minutes / 60);311 if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;312 const days = Math.floor(hours / 24);313 return days < 7 ? `${days} day${days === 1 ? "" : "s"} ago` : "Not seen recently";314 }315 function isSyncStale(d: PairedDevice): boolean {316 return d.lastSyncedAt !== undefined && Date.now() - d.lastSyncedAt > STALE_THRESHOLD_MS;317 }318 function deviceTypeLabel(t: PairedDevice["deviceType"]): string {319 switch (t) {320 case "android": return "Android";321 case "ios": return "iPhone";322 case "extension": return "Browser";323 default: return "Device";324 }325 }326327 async function loadPairedDevices() {328 if (!isPro()) return;329 const r = (await sendMessage({ type: "LIST_PAIRED_DEVICES" })) as {330 ok: boolean;331 data?: { devices: PairedDevice[] };332 };333 if (r.ok) setPairedDevices(r.data?.devices ?? []);334 }335 async function unpairDevice(deviceId: string) {336 await sendMessage({ type: "UNPAIR_DEVICE", payload: { deviceId } });337 setConfirmUnpair(null);338 await loadPairedDevices();339 }340 async function syncNow() {341 setSyncStatus("syncing");342 setSyncFlash(null);343 const res = (await sendMessage({344 type: "TRIGGER_SYNC",345 payload: { reason: "manual", force: true },346 })) as { ok: boolean; error?: string };347 if (!res.ok) {348 setSyncStatus("error");349 setSyncSummary({ status: "error", outcome: "run_failed", detail: res.error });350 return;351 }352 353 354 355 let s: SyncOutcomeSummary = { status: "syncing" };356 for (let i = 0; i < 40 && s.status === "syncing"; i++) {357 await new Promise((r) => setTimeout(r, 250));358 const state = (await sendMessage({ type: "GET_SYNC_STATE" })) as {359 ok: boolean;360 data?: SyncOutcomeSummary;361 };362 s = state.ok ? state.data ?? { status: "idle" } : { status: "error", outcome: "run_failed" };363 }364 setSyncSummary(s);365 setSyncStatus(s.status ?? "idle");366 367 368 setSyncFlash(describeSyncOutcome(s));369 setTimeout(() => setSyncFlash(null), 4000);370 await loadPairedDevices();371 }372 373 const [rotateConfirmOpen, setRotateConfirmOpen] = createSignal(false);374 const [rotating, setRotating] = createSignal(false);375 const [rotateError, setRotateError] = createSignal<string | null>(null);376 const [showSyncConnection, setShowSyncConnection] = createSignal(false);377 const [showSyncRecovery, setShowSyncRecovery] = createSignal(false);378379 async function loadPendingVouched() {380 const r = (await sendMessage({ type: "GET_PENDING_VOUCHED_DEVICES" })) as {381 ok: boolean;382 data?: PendingVouched[];383 };384 if (r.ok) setPendingVouched(r.data ?? []);385 }386 async function confirmVouched(deviceId: string) {387 setVouchedBusy(deviceId);388 try {389 await sendMessage({ type: "CONFIRM_VOUCHED_DEVICE", payload: { deviceId } });390 await loadPendingVouched();391 } finally {392 setVouchedBusy(null);393 }394 }395 async function rejectVouched(deviceId: string) {396 setVouchedBusy(deviceId);397 try {398 await sendMessage({ type: "REJECT_VOUCHED_DEVICE", payload: { deviceId } });399 await loadPendingVouched();400 } finally {401 setVouchedBusy(null);402 }403 }404 async function doRotateSyncIdentity() {405 setRotateError(null);406 setRotating(true);407 try {408 const r = (await sendMessage({ type: "ROTATE_SYNC_IDENTITY" })) as {409 ok: boolean;410 error?: string;411 };412 if (!r.ok) {413 setRotateError(r.error ?? "Rotation failed.");414 return;415 }416 setRotateConfirmOpen(false);417 418 419 setShowNewRecoveryWords(true);420 } finally {421 setRotating(false);422 }423 }424425 const [dedupCount, setDedupCount] = createSignal<number | null>(null);426 const [dedupRunning, setDedupRunning] = createSignal(false);427 const [dedupDone, setDedupDone] = createSignal<number | null>(null);428429 const [declinedCardCount, setDeclinedCardCount] = createSignal(0);430431 432 433 434 const [historySnapshots, setHistorySnapshots] = createSignal<VaultSnapshot[]>([]);435 const [historyLoading, setHistoryLoading] = createSignal(false);436 const [historyRestoring, setHistoryRestoring] = createSignal<string | null>(null);437 const [historyShowAll, setHistoryShowAll] = createSignal(false);438439 async function loadVaultHistory() {440 setHistoryLoading(true);441 try {442 const res = await sendMessage({ type: "GET_VAULT_HISTORY" });443 const list = (res as { ok: boolean; data?: VaultSnapshot[] }).data ?? [];444 setHistorySnapshots(list);445 } finally {446 setHistoryLoading(false);447 }448 }449450 async function handleRestoreVaultSnapshot(id: string) {451 if (historyRestoring()) return;452 setHistoryRestoring(id);453 const res = await sendMessage({454 type: "RESTORE_VAULT_SNAPSHOT",455 payload: { snapshotId: id },456 });457 setHistoryRestoring(null);458 if ((res as { ok: boolean }).ok) {459 460 461 props.onBack();462 }463 }464465 function formatSnapshotTime(savedAt: number): string {466 const diff = Date.now() - savedAt;467 if (diff < 60_000) return "Just now";468 if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;469 const d = new Date(savedAt);470 const isToday = new Date().toDateString() === d.toDateString();471 const isYest =472 new Date(Date.now() - 86_400_000).toDateString() === d.toDateString();473 const t = d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });474 if (isToday) return `Today ${t}`;475 if (isYest) return `Yesterday ${t}`;476 return `${d.toLocaleDateString([], { month: "short", day: "numeric" })} ${t}`;477 }478479 function reasonLabel(reason: VaultSnapshot["reason"]): string {480 return (481 {482 local_save: "saved",483 sync_received: "synced",484 import: "imported",485 restore: "restored",486 } as const487 )[reason] ?? reason;488 }489490 const [showVaultAdvanced, setShowVaultAdvanced] = createSignal(false);491492 const [showChangePw, setShowChangePw] = createSignal(false);493 const [changePwCurrent, setChangePwCurrent] = createSignal("");494 const [changePwNew, setChangePwNew] = createSignal("");495 const [changePwConfirm, setChangePwConfirm] = createSignal("");496 const [changePwSaving, setChangePwSaving] = createSignal(false);497 const [changePwError, setChangePwError] = createSignal<string | null>(null);498 const [changePwSuccess] = createSignal(false);499 const [showNewRecoveryWords, setShowNewRecoveryWords] = createSignal(false);500501 502 const [showViewWords, setShowViewWords] = createSignal(false);503 const [viewWordsPassword, setViewWordsPassword] = createSignal("");504 const [viewWordsError, setViewWordsError] = createSignal<string | null>(null);505 const [viewedWords, setViewedWords] = createSignal<string[] | null>(null);506 const [viewWordsBusy, setViewWordsBusy] = createSignal(false);507508 async function handleViewRecoveryWords() {509 setViewWordsError(null);510 if (!viewWordsPassword()) {511 setViewWordsError("Enter your master password.");512 return;513 }514 setViewWordsBusy(true);515 try {516 const r = (await sendMessage({517 type: "GET_RECOVERY_WORDS",518 payload: { password: viewWordsPassword() },519 })) as { ok: boolean; data?: string[]; error?: string };520 if (r.ok) {521 setViewedWords(r.data ?? []);522 setViewWordsPassword("");523 return;524 }525 switch (r.error) {526 case "wrong_password":527 setViewWordsError("Incorrect password");528 break;529 case "password_required":530 setViewWordsError("Enter your master password.");531 break;532 case "recovery_not_setup":533 setViewWordsError("Recovery words aren't set up for this vault yet.");534 break;535 default:536 setViewWordsError("Couldn't retrieve recovery words.");537 }538 } finally {539 setViewWordsBusy(false);540 }541 }542 function closeViewWords() {543 setShowViewWords(false);544 setViewWordsPassword("");545 setViewWordsError(null);546 setViewedWords(null);547 }548549 const [duressEnabled, setDuressEnabled] = createSignal(false);550 const [showDuressSetup, setShowDuressSetup] = createSignal(false);551 const [duressPassword, setDuressPassword] = createSignal("");552 const [duressConfirm, setDuressConfirm] = createSignal("");553 const [duressMainPassword, setDuressMainPassword] = createSignal("");554 const [duressSaving, setDuressSaving] = createSignal(false);555 const [duressError, setDuressError] = createSignal<string | null>(null);556 const [duressSuccess, setDuressSuccess] = createSignal<string | null>(null);557558 const [canaryEntries, setCanaryEntries] = createSignal<Array<{id: string; title: string; canary: Canary}>>([]);559560 const [canaryTestingId, setCanaryTestingId] = createSignal<string | null>(null);561 const [canaryTestResult, setCanaryTestResult] = createSignal<{id: string; ok: boolean} | null>(null);562563 const [fbCategory, setFbCategory] = createSignal("Bug report");564 const [fbMessage, setFbMessage] = createSignal("");565 type FeedbackState = 'idle' | 'sending' | 'sent' | 'error';566 const [feedbackState, setFeedbackState] = createSignal<FeedbackState>('idle');567568 createEffect(() => {569 if (props.upgradeHighlight) {570 setActiveCard("pro");571 }572 });573574 onMount(async () => {575 const [licenseRes, passkeyRes, duressRes, settingsRes, keyRes] = await Promise.all([576 sendMessage({ type: "GET_LICENSE" }),577 sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }),578 sendMessage({ type: "IS_DURESS_ENABLED" }),579 sendMessage({ type: "GET_SETTINGS" }),580 sendMessage({ type: "GET_LICENSE_KEY" }),581 ]);582583 584 585 void loadVaultHistory();586587 588 589 void loadPendingVouched();590 void loadPairedDevices();591 void loadSyncState();592 const onBroadcast = (msg: unknown) => {593 const m = msg as { type?: string; payload?: unknown } | null;594 if (m?.type === "VOUCHED_DEVICE_PENDING") {595 void loadPendingVouched();596 }597 if (m?.type === "SYNC_STATE_CHANGED") {598 const payload = (m.payload ?? {}) as SyncOutcomeSummary;599 setSyncSummary(payload);600 setSyncStatus(payload.status ?? "idle");601 }602 };603 chrome.runtime?.onMessage?.addListener(onBroadcast);604 onCleanup(() => {605 try { chrome.runtime?.onMessage?.removeListener(onBroadcast); } catch { }606 });607608 if ((licenseRes as { ok: boolean }).ok) {609 setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null);610 }611612 if ((passkeyRes as { ok: boolean }).ok) {613 setPasskeyState(614 (passkeyRes as { ok: boolean; data?: PasskeyProviderState }).data ?? null615 );616 }617618 if ((duressRes as { ok: boolean }).ok) {619 setDuressEnabled((duressRes as { ok: boolean; data?: boolean }).data ?? false);620 }621622 if ((settingsRes as { ok: boolean }).ok) {623 const sData = (settingsRes as { ok: boolean; data?: { declinedCardFingerprints?: string[] } }).data;624 setDeclinedCardCount(sData?.declinedCardFingerprints?.length ?? 0);625 }626627 if ((keyRes as { ok: boolean }).ok) {628 setLicenseKey((keyRes as { ok: boolean; data?: string }).data ?? null);629 }630631 const entriesRes = await sendMessage({ type: "GET_ENTRIES" });632 if ((entriesRes as { ok: boolean }).ok) {633 const entries = (entriesRes as { ok: boolean; data?: Array<{id: string; title: string; canary?: Canary}> }).data ?? [];634 setCanaryEntries(635 entries636 .filter((e) => e.canary?.webhookUrl)637 .map((e) => ({ id: e.id, title: e.title, canary: e.canary! }))638 );639 }640641642 if (isNativePlatform()) {643 const biometric = nativePlugin<{ isAvailable: () => Promise<{ available: boolean }> }>("BiometricPlugin");644 const keystore = nativePlugin<{ hasMasterKey: () => Promise<{ exists: boolean }> }>("KeystorePlugin");645 try {646 if (biometric) {647 const a = await biometric.isAvailable();648 setBiometricSupported(a.available);649 }650 if (keystore) {651 const k = await keystore.hasMasterKey();652 setBiometricStored(k.exists);653 }654 } catch { }655 }656 });657658 async function handleEnableBiometrics() {659 setBiometricBusy(true);660 setBiometricError(null);661 try {662 const keyRes = (await sendMessage({ type: "GET_DERIVED_KEY" })) as {663 ok: boolean;664 data?: { masterKeyHex: string };665 error?: string;666 };667 if (!keyRes.ok || !keyRes.data?.masterKeyHex) {668 setBiometricError(keyRes.error === "vault_locked"669 ? "unlock the vault first"670 : "couldn't read derived key");671 return;672 }673 const keystore = nativePlugin<{ storeMasterKey: (p: { masterKeyHex: string }) => Promise<void> }>("KeystorePlugin");674 if (!keystore) {675 setBiometricError("biometric plugin unavailable");676 return;677 }678 await keystore.storeMasterKey({ masterKeyHex: keyRes.data.masterKeyHex });679 await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: true } });680 setBiometricStored(true);681 } catch (e) {682 setBiometricError(e instanceof Error ? e.message : "failed to enable biometrics");683 } finally {684 setBiometricBusy(false);685 }686 }687688 async function handleDisableBiometrics() {689 setBiometricBusy(true);690 setBiometricError(null);691 try {692 const keystore = nativePlugin<{ clearMasterKey: () => Promise<void> }>("KeystorePlugin");693 if (keystore) await keystore.clearMasterKey();694 await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: false } });695 setBiometricStored(false);696 } catch (e) {697 setBiometricError(e instanceof Error ? e.message : "failed to disable biometrics");698 } finally {699 setBiometricBusy(false);700 }701 }702703 async function handleChangePassword() {704 setChangePwError(null);705 if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; }706 if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; }707 if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; }708 if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; }709 setChangePwSaving(true);710 const res = await sendMessage({711 type: "CHANGE_PASSWORD",712 payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() },713 }) as { ok: boolean; error?: string };714 if (res.ok) {715 setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");716 setShowChangePw(false);717 718 719 720 721 await chrome.storage?.local?.set?.({ peach_recovery_pending: "1" });722 setShowNewRecoveryWords(true);723 } else {724 setChangePwError(res.error ?? "failed to change password");725 }726 setChangePwSaving(false);727 }728729 const [copied, setCopied] = createSignal(false);730731 async function copyLicenseKey(key: string) {732 await navigator.clipboard.writeText(key);733 setCopied(true);734 setTimeout(() => setCopied(false), 2000);735 }736737 async function handleActivateLicense() {738 const key = licenseKeyInput().trim();739 if (!key) return;740 setActivatingLicense(true);741 setLicenseError(null);742 setLicenseSuccess(null);743 const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } });744 setActivatingLicense(false);745 if ((res as { ok: boolean }).ok) {746 const data = (res as { ok: boolean; data?: LicenseInfo }).data;747 if (data?.valid) {748 setLicenseInfo(data);749 setLicenseKey(key);750 setLicenseSuccess("License activated!");751 setLicenseKeyInput("");752 } else {753 setLicenseError("Invalid license key");754 }755 } else {756 setLicenseError("Activation failed");757 }758 }759760 async function handleTogglePasskeys() {761 const current = passkeyState();762 if (!current || !current.supported) return;763 setPasskeySaving(true);764 const res = await sendMessage({765 type: "SET_PASSKEY_PROVIDER_ENABLED",766 payload: { enabled: !current.enabled },767 });768 setPasskeySaving(false);769 if ((res as { ok: boolean }).ok) {770 setPasskeyState(771 (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null772 );773 }774 }775776 async function handleCheckAllBreaches() {777 setCheckingBreaches(true);778 setBreachResult(null);779 const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });780 setCheckingBreaches(false);781 if ((res as { ok: boolean }).ok) {782 const data = (res as { ok: boolean; data?: Record<string, number> }).data ?? {};783 const breached = Object.values(data).filter((count) => count > 0).length;784 setBreachResult(785 breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean"786 );787 props.onBreachCheckDone();788 }789 }790791792 async function handleSetupDuress() {793 const mainPw = duressMainPassword().trim();794 const duressPw = duressPassword().trim();795 const confirmPw = duressConfirm().trim();796797 if (!mainPw) { setDuressError("enter your main password to confirm"); return; }798 if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; }799 if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; }800 if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; }801802 setDuressSaving(true);803 setDuressError(null);804805 const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };806 if (!verifyRes.ok || !verifyRes.data) {807 setDuressError("incorrect main password");808 setDuressSaving(false);809 return;810 }811812 const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string };813 setDuressSaving(false);814 if (res.ok) {815 setDuressEnabled(true);816 setShowDuressSetup(false);817 setDuressPassword("");818 setDuressConfirm("");819 setDuressMainPassword("");820 setDuressSuccess("Duress password set. If entered, a decoy vault will be shown.");821 setTimeout(() => setDuressSuccess(null), 5000);822 } else {823 setDuressError(824 res.error === "session_expired"825 ? "Vault session expired — lock and unlock Peach, then try again"826 : res.error ?? "failed to set duress password"827 );828 }829 }830831 async function handleClearDeclinedCards() {832 await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } });833 setDeclinedCardCount(0);834 }835836 async function handleCountDuplicates() {837 setDedupRunning(true);838 setDedupDone(null);839 const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number };840 setDedupRunning(false);841 if (res.ok) setDedupCount(res.data ?? 0);842 }843844 async function handleDeduplicate() {845 setDedupRunning(true);846 const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number };847 setDedupRunning(false);848 if (res.ok) {849 setDedupDone(res.data ?? 0);850 setDedupCount(0);851 }852 }853854 async function handleDisableDuress() {855 const mainPw = duressMainPassword().trim();856 if (!mainPw) { setDuressError("enter your main password to confirm"); return; }857858 setDuressSaving(true);859 setDuressError(null);860861 const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };862 if (!verifyRes.ok || !verifyRes.data) {863 setDuressError("incorrect main password");864 setDuressSaving(false);865 return;866 }867868 const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string };869 setDuressSaving(false);870 if (res.ok) {871 setDuressEnabled(false);872 setShowDuressSetup(false);873 setDuressMainPassword("");874 setDuressSuccess("Duress password disabled.");875 setTimeout(() => setDuressSuccess(null), 4000);876 } else {877 setDuressError(res.error ?? "failed to disable duress password");878 }879 }880881 async function handleSubmitFeedback() {882 const message = sanitizeFeedbackMessage(fbMessage());883 if (!message) return;884885 setFeedbackState('sending');886887 try {888 const hasDataPermission = await requestFeedbackDataPermission();889 if (!hasDataPermission) {890 setFeedbackState('idle');891 return;892 }893894 const category = FEEDBACK_CATEGORIES.includes(fbCategory())895 ? fbCategory()896 : FEEDBACK_CATEGORIES[0];897 const res = await fetch('https://api.web3forms.com/submit', {898 method: 'POST',899 headers: { 'Content-Type': 'application/json' },900 body: JSON.stringify({901 access_key: WEB3FORMS_KEY,902 subject: `Peach Feedback - ${category}`,903 message,904 from_name: 'Peach User',905 906 botcheck: '',907 }),908 });909910 if (!res.ok) throw new Error(`HTTP ${res.status}`);911 const data = await res.json() as { success: boolean; message?: string };912 if (!data.success) throw new Error(data.message ?? 'unknown error');913914 setFeedbackState('sent');915 setFbMessage('');916 setFbCategory(FEEDBACK_CATEGORIES[0]);917 } catch (err) {918 console.error('[feedback]', err);919 setFeedbackState('error');920 setTimeout(() => setFeedbackState('idle'), 4000);921 }922 }923924 function renderSecurity() {925 return (926 <div class="peach-settings-body">927 <div class="peach-settings-group">928 <p class="peach-settings-sub-label">LOCKING</p>929 <div class="peach-settings-row">930 <span>Lock on close</span>931 <button932 class={`peach-toggle ${props.lockOnClose ? "is-on" : ""}`}933 onClick={() => void props.onLockOnCloseChange(!props.lockOnClose)}934 aria-label="Toggle lock on close"935 >936 <span class="peach-toggle-knob"></span>937 </button>938 </div>939 <div class="peach-settings-row">940 <span>Auto-lock after</span>941 <select942 class="peach-settings-select"943 value={String(props.autoLockMinutes)}944 onChange={(e) => void props.onAutoLockMinutesChange(Number(e.currentTarget.value))}945 >946 <option value="1">1 minute</option>947 <option value="5">5 minutes</option>948 <option value="15">15 minutes</option>949 <option value="30">30 minutes</option>950 <option value="0">Never</option>951 </select>952 </div>953954 <Show when={isNativePlatform() && biometricSupported()}>955 <p class="peach-settings-sub-label">BIOMETRICS</p>956 <div class="peach-settings-row">957 <span>Unlock with biometrics</span>958 <button959 class={`peach-toggle ${biometricStored() ? "is-on" : ""}`}960 onClick={() => void (biometricStored() ? handleDisableBiometrics() : handleEnableBiometrics())}961 disabled={biometricBusy()}962 aria-label="Toggle biometric unlock"963 >964 <span class="peach-toggle-knob"></span>965 </button>966 </div>967 <Show when={biometricError()}>968 <div class="peach-settings-row peach-settings-row--meta">969 <span class="peach-hint peach-hint-error">{biometricError()}</span>970 </div>971 </Show>972 </Show>973974 <p class="peach-settings-sub-label">BREACH PROTECTION</p>975 <div class="peach-settings-row">976 <span>Breach monitoring</span>977 <button978 class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}979 onClick={() => void props.onBreachMonitoringChange(!props.breachMonitoring)}980 aria-label="Toggle breach monitoring"981 >982 <span class="peach-toggle-knob"></span>983 </button>984 </div>985 <div class="peach-settings-row peach-settings-row--meta">986 <span>Check all passwords</span>987 <button988 class="peach-btn peach-btn--subtle peach-btn--sm"989 onClick={() => void handleCheckAllBreaches()}990 disabled={checkingBreaches()}991 >992 {checkingBreaches() ? "checking..." : "run"}993 </button>994 </div>995 <Show when={breachResult()}>996 <div class="peach-settings-row peach-settings-row--meta">997 <span>Last check</span>998 <span class="peach-settings-value">{breachResult()}</span>999 </div>1000 </Show>10011002 <p class="peach-settings-sub-label">MASTER PASSWORD</p>1003 <div class="peach-settings-row peach-settings-row--meta">1004 <span>Master password</span>1005 <button1006 class="peach-btn peach-btn--subtle peach-btn--sm"1007 onClick={() => { setShowChangePw((v) => !v); setChangePwError(null); }}1008 >1009 {showChangePw() ? "cancel" : "change"}1010 </button>1011 </div>1012 <Show when={showChangePw()}>1013 <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1014 <input1015 class="peach-settings-input"1016 type="password"1017 placeholder="current password"1018 value={changePwCurrent()}1019 onInput={(e) => setChangePwCurrent((e.target as HTMLInputElement).value)}1020 autocomplete="current-password"1021 />1022 <input1023 class="peach-settings-input"1024 type="password"1025 placeholder="new password (min 12 chars)"1026 value={changePwNew()}1027 onInput={(e) => setChangePwNew((e.target as HTMLInputElement).value)}1028 autocomplete="new-password"1029 />1030 <input1031 class="peach-settings-input"1032 type="password"1033 placeholder="confirm new password"1034 value={changePwConfirm()}1035 onInput={(e) => setChangePwConfirm((e.target as HTMLInputElement).value)}1036 autocomplete="new-password"1037 onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }}1038 />1039 <Show when={changePwError()}>1040 <p class="peach-hint peach-hint-error" style="margin:0">{changePwError()}</p>1041 </Show>1042 <Show when={changePwSuccess()}>1043 <p class="peach-hint" style="margin:0;color:var(--peach-accent)">password changed ✓</p>1044 </Show>1045 <button1046 class="peach-btn peach-btn--accent peach-btn--sm"1047 style="align-self:flex-start"1048 onClick={() => void handleChangePassword()}1049 disabled={changePwSaving() || !changePwCurrent() || !changePwNew() || !changePwConfirm()}1050 >1051 {changePwSaving() ? "saving…" : "save new password"}1052 </button>1053 </div>1054 </Show>10551056 {}1057 <div class="peach-settings-row peach-settings-row--meta">1058 <span>Recovery words</span>1059 <button1060 class="peach-btn peach-btn--subtle peach-btn--sm"1061 onClick={() => {1062 if (showViewWords()) { closeViewWords(); } else { setShowViewWords(true); }1063 }}1064 >1065 {showViewWords() ? "cancel" : "view"}1066 </button>1067 </div>1068 <Show when={showViewWords()}>1069 <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1070 <Show1071 when={viewedWords()}1072 fallback={1073 <>1074 <p class="peach-hint" style="margin:0;color:var(--peach-t2)">1075 Enter your master password to reveal your recovery words.1076 </p>1077 <input1078 class="peach-settings-input"1079 type="password"1080 placeholder="master password"1081 value={viewWordsPassword()}1082 onInput={(e) => setViewWordsPassword((e.target as HTMLInputElement).value)}1083 autocomplete="current-password"1084 onKeyDown={(e) => { if (e.key === "Enter") void handleViewRecoveryWords(); }}1085 />1086 <Show when={viewWordsError()}>1087 <p class="peach-hint peach-hint-error" style="margin:0">{viewWordsError()}</p>1088 </Show>1089 <button1090 class="peach-btn peach-btn--accent peach-btn--sm"1091 style="align-self:flex-start"1092 onClick={() => void handleViewRecoveryWords()}1093 disabled={viewWordsBusy() || !viewWordsPassword()}1094 >1095 {viewWordsBusy() ? "checking…" : "reveal recovery words"}1096 </button>1097 </>1098 }1099 >1100 <div class="peach-recovery-words-grid">1101 <For each={viewedWords()!}>1102 {(word, i) => (1103 <span class="peach-recovery-word"><b>{i() + 1}</b> {word}</span>1104 )}1105 </For>1106 </div>1107 <button1108 class="peach-btn peach-btn--subtle peach-btn--sm"1109 style="align-self:flex-start"1110 onClick={closeViewWords}1111 >done</button>1112 </Show>1113 </div>1114 </Show>11151116 <p class="peach-settings-sub-label">1117 DURESS PASSWORD1118 <Show when={!isPro()}>1119 <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>1120 </Show>1121 </p>1122 <div class="peach-settings-row peach-settings-row--top">1123 <div class="peach-settings-row-main">1124 <span>1125 Duress password1126 <Show when={!isPro()}>1127 <span class="peach-pro-badge">Pro</span>1128 </Show>1129 </span>1130 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">1131 If entered under coercion, shows a decoy empty vault instead of your real one.1132 </span>1133 </div>1134 <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>1135 {duressEnabled() ? "on" : "off"}1136 </div>1137 </div>11381139 <Show when={duressEnabled() && !showDuressSetup()}>1140 <div class="peach-settings-row peach-settings-row--meta">1141 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1142 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.1143 </span>1144 </div>1145 </Show>11461147 <Show when={duressSuccess()}>1148 <div class="peach-settings-row peach-settings-row--meta">1149 <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>1150 </div>1151 </Show>11521153 <Show when={!showDuressSetup()}>1154 <div class="peach-settings-row peach-settings-row--meta">1155 <button1156 class="peach-btn peach-btn--subtle peach-btn--sm"1157 onClick={() => {1158 1159 1160 1161 1162 1163 if (!isPro() && !duressEnabled()) {1164 props.onProGate?.("duress_vault");1165 setActiveCard("pro");1166 return;1167 }1168 setShowDuressSetup(true);1169 setDuressError(null);1170 setDuressSuccess(null);1171 setDuressPassword("");1172 setDuressConfirm("");1173 setDuressMainPassword("");1174 }}1175 >1176 {duressEnabled() ? "Change / disable" : "Set up"}1177 </button>1178 </div>1179 </Show>11801181 <Show when={showDuressSetup()}>1182 <div class="peach-duress-form">1183 <label class="peach-label">main password (to confirm)</label>1184 <input1185 class="peach-settings-input"1186 type="password"1187 placeholder="your current master password"1188 autocomplete="current-password"1189 value={duressMainPassword()}1190 onInput={(e) => setDuressMainPassword(e.currentTarget.value)}1191 />1192 <Show when={!duressEnabled()}>1193 <label class="peach-label">new duress password</label>1194 <input1195 class="peach-settings-input"1196 type="password"1197 placeholder="duress password (min 8 chars)"1198 autocomplete="new-password"1199 value={duressPassword()}1200 onInput={(e) => setDuressPassword(e.currentTarget.value)}1201 />1202 <label class="peach-label">confirm duress password</label>1203 <input1204 class="peach-settings-input"1205 type="password"1206 placeholder="confirm duress password"1207 autocomplete="new-password"1208 value={duressConfirm()}1209 onInput={(e) => setDuressConfirm(e.currentTarget.value)}1210 />1211 </Show>12121213 <Show when={duressError()}>1214 <p class="peach-hint peach-hint-error">{duressError()}</p>1215 </Show>12161217 <div class="peach-duress-actions">1218 <button1219 class="peach-btn peach-btn--subtle peach-btn--sm"1220 onClick={() => { setShowDuressSetup(false); setDuressError(null); }}1221 >1222 cancel1223 </button>1224 <Show when={duressEnabled()}>1225 <button1226 class="peach-btn peach-btn--ghost peach-btn--sm"1227 style="color:var(--peach-breach)"1228 onClick={() => void handleDisableDuress()}1229 disabled={duressSaving()}1230 >1231 {duressSaving() ? "disabling..." : "disable duress"}1232 </button>1233 </Show>1234 <Show when={!duressEnabled()}>1235 <button1236 class="peach-btn peach-btn--accent peach-btn--sm"1237 onClick={() => void handleSetupDuress()}1238 disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}1239 >1240 {duressSaving() ? "saving..." : "save"}1241 </button>1242 </Show>1243 </div>1244 </div>1245 </Show>12461247 <p class="peach-settings-sub-label">1248 CANARY ENTRIES1249 <Show when={!isPro()}>1250 <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>1251 </Show>1252 </p>1253 <div class="peach-settings-row peach-settings-row--top">1254 <div class="peach-settings-row-main">1255 <span>1256 Canary entries1257 <Show when={!isPro()}>1258 <span class="peach-pro-badge">Pro</span>1259 </Show>1260 </span>1261 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1262 Silently pings a webhook when an entry is accessed. Configure canary on any entry in your vault.1263 </span>1264 </div>1265 <span class="peach-settings-value" style="flex-shrink:0">1266 {canaryEntries().length > 0 ? `${canaryEntries().length} active` : "none"}1267 </span>1268 </div>1269 <Show when={canaryEntries().length > 0}>1270 <For each={canaryEntries()}>1271 {(entry) => (1272 <div class="peach-settings-row peach-settings-row--meta">1273 <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0">{entry.title}</span>1274 <button1275 class="peach-btn peach-btn--subtle peach-btn--sm"1276 onClick={() => void handleTestCanary(entry)}1277 disabled={canaryTestingId() === entry.id}1278 style="flex-shrink:0"1279 >1280 {canaryTestingId() === entry.id1281 ? "sending…"1282 : canaryTestResult()?.id === entry.id1283 ? (canaryTestResult()!.ok ? "sent ✓" : "failed ✗")1284 : "test"}1285 </button>1286 </div>1287 )}1288 </For>1289 </Show>12901291 <p class="peach-settings-sub-label">PASSKEYS</p>1292 <div class="peach-settings-row peach-settings-row--top">1293 <div class="peach-settings-row-main">1294 <span>Intercept passkey requests</span>1295 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">1296 Peach can handle passkey sign-in and creation on supported Chromium browsers.1297 </span>1298 </div>1299 <button1300 class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}1301 onClick={() => void handleTogglePasskeys()}1302 aria-label="Toggle passkey interception"1303 disabled={!passkeyState()?.supported || passkeySaving()}1304 >1305 <span class="peach-toggle-knob"></span>1306 </button>1307 </div>1308 <div class="peach-settings-row peach-settings-row--meta">1309 <span>Status</span>1310 <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>1311 </div>1312 <div class="peach-settings-row peach-settings-row--meta">1313 <span>Browser</span>1314 <span class="peach-settings-value">1315 {passkeyState()?.browserLabel ?? "Unknown"}1316 {passkeyState()?.supported ? " ✓" : " (interception unavailable)"}1317 </span>1318 </div>1319 <div class="peach-info-notice" style="margin-top:8px">1320 Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.1321 </div>1322 <Show when={passkeyState()?.conflict}>1323 <div class="peach-settings-row peach-settings-row--meta">1324 <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>1325 </div>1326 </Show>1327 </div>1328 </div>1329 );1330 }13311332 function renderVault() {1333 return (1334 <div class="peach-settings-body">1335 <div class="peach-settings-group">1336 <p class="peach-settings-sub-label">ENTRIES</p>1337 <div class="peach-settings-row peach-settings-row--meta">1338 <span>Entries</span>1339 <span class="peach-settings-value">{props.entriesCount}</span>1340 </div>1341 <div class="peach-settings-row peach-settings-row--meta">1342 <span>Duplicates</span>1343 <Show when={dedupDone() !== null} fallback={1344 <button1345 class="peach-btn peach-btn--subtle peach-btn--sm"1346 onClick={() => void handleCountDuplicates()}1347 disabled={dedupRunning()}1348 >1349 {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}1350 </button>1351 }>1352 <span class="peach-settings-value" style="color:var(--peach-sync-ok)">1353 {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}1354 </span>1355 </Show>1356 </div>1357 <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>1358 <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">1359 <div class="peach-restore-warning">1360 {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.1361 </div>1362 <div style="display:flex;gap:6px">1363 <button1364 class="peach-btn peach-btn--subtle peach-btn--sm"1365 onClick={() => setDedupCount(null)}1366 >1367 cancel1368 </button>1369 <button1370 class="peach-btn peach-btn--accent peach-btn--sm"1371 onClick={() => void handleDeduplicate()}1372 disabled={dedupRunning()}1373 >1374 {dedupRunning() ? "removing..." : "remove duplicates"}1375 </button>1376 </div>1377 </div>1378 </Show>13791380 <p class="peach-settings-sub-label">MAINTENANCE</p>1381 <div class="peach-settings-row peach-settings-row--meta">1382 <span>Trash auto-delete</span>1383 <select1384 class="peach-settings-select"1385 value={String(props.trashRetentionDays)}1386 onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}1387 >1388 <option value="7">7 days</option>1389 <option value="30">30 days</option>1390 <option value="90">90 days</option>1391 <option value="0">Never</option>1392 </select>1393 </div>1394 <div class="peach-settings-row peach-settings-row--meta">1395 <span>Advanced Vault Health</span>1396 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>1397 open1398 </button>1399 </div>1400 <Show when={declinedCardCount() > 0}>1401 <div class="peach-settings-row peach-settings-row--meta">1402 <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>1403 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>1404 Clear1405 </button>1406 </div>1407 </Show>14081409 <p class="peach-settings-sub-label">DATA</p>1410 <div class="peach-settings-row peach-settings-row--meta">1411 <span>Import passwords</span>1412 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>1413 open1414 </button>1415 </div>1416 <div class="peach-settings-row peach-settings-row--meta">1417 <span>Export backup</span>1418 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onExport}>1419 open1420 </button>1421 </div>1422 <div class="peach-settings-row peach-settings-row--meta">1423 <span>1424 Peach Codex1425 <Show when={!isPro()}>1426 <span class="peach-pro-badge">Pro</span>1427 </Show>1428 </span>1429 <button1430 class="peach-btn peach-btn--subtle peach-btn--sm"1431 onClick={() => props.onOpenCodexGenerate?.()}1432 >1433 open1434 </button>1435 </div>1436 {1437}1438 <div class="peach-settings-row peach-settings-row--meta">1439 <span>Restore from Codex</span>1440 <button1441 class="peach-btn peach-btn--subtle peach-btn--sm"1442 onClick={() => props.onOpenCodexRestore?.()}1443 >1444 open1445 </button>1446 </div>14471448 <span class="peach-settings-sub-label">VAULT HISTORY</span>1449 <Show when={historyLoading()}>1450 <p class="peach-settings-hint">Loading…</p>1451 </Show>1452 <Show when={!historyLoading() && historySnapshots().length === 0}>1453 <p class="peach-settings-hint">1454 No snapshots yet. Peach saves automatically before every change —1455 saves, syncs, and imports.1456 </p>1457 </Show>1458 <Show when={!historyLoading() && historySnapshots().length > 0}>1459 <p class="peach-settings-hint">1460 Restoring locks your vault. Unlock with the password that1461 encrypted that snapshot to confirm.1462 </p>1463 <div class="peach-history-list">1464 <For each={historySnapshots().slice(0, historyShowAll() ? undefined : 3)}>1465 {(snap) => (1466 <div class="peach-history-row">1467 <div class="peach-history-meta">1468 <span class="peach-history-time">1469 {formatSnapshotTime(snap.savedAt)}1470 </span>1471 <span class="peach-history-sub">1472 {[1473 snap.entryCount !== null1474 ? `up to ${snap.entryCount} ${snap.entryCount === 1 ? "entry" : "entries"}`1475 : null,1476 reasonLabel(snap.reason),1477 ]1478 .filter(Boolean)1479 .join(" · ")}1480 </span>1481 </div>1482 <button1483 class={`peach-btn peach-btn--subtle peach-btn--sm peach-history-btn${1484 historyRestoring() === snap.id ? " is-busy" : ""1485 }`}1486 onClick={() => void handleRestoreVaultSnapshot(snap.id)}1487 disabled={historyRestoring() !== null}1488 >1489 <Show when={historyRestoring() === snap.id} fallback="Restore">1490 …1491 </Show>1492 </button>1493 </div>1494 )}1495 </For>1496 </div>1497 <Show when={historySnapshots().length > 3}>1498 <button1499 class="peach-settings-advanced-toggle"1500 onClick={() => setHistoryShowAll((v) => !v)}1501 aria-expanded={historyShowAll()}1502 >1503 <span class="peach-settings-hint" style="margin:0">1504 {historyShowAll() ? "show less" : `${historySnapshots().length - 3} older`}1505 </span>1506 <span class="peach-settings-advanced-chevron">1507 {historyShowAll() ? "▲" : "▼"}1508 </span>1509 </button>1510 </Show>1511 <p class="peach-history-footer">1512 {historySnapshots().length} of 10 snapshots1513 </p>1514 </Show>15151516 <button1517 class="peach-settings-advanced-toggle"1518 onClick={() => setShowVaultAdvanced((v) => !v)}1519 aria-expanded={showVaultAdvanced()}1520 >1521 <span class="peach-settings-sub-label" style="margin:0">ADVANCED</span>1522 <span class="peach-settings-advanced-chevron">1523 {showVaultAdvanced() ? "▲" : "▼"}1524 </span>1525 </button>15261527 <Show when={showVaultAdvanced()}>1528 <div class="peach-danger-card">1529 <div class="peach-danger-card-head">1530 <span class="peach-danger-card-glyph" aria-hidden="true">⟳</span>1531 <h3 class="peach-danger-card-title">Replace vault from backup</h3>1532 </div>1533 <p class="peach-danger-card-copy">1534 Wipes the current vault and restores from a backup file.1535 Recoverable only if your backup is good.1536 </p>1537 <button1538 class="peach-danger-card-button"1539 onClick={() => props.onOpenFileImport?.()}1540 >1541 Choose backup file1542 </button>1543 </div>15441545 <Show when={props.onOpenPanic}>1546 <div class="peach-danger-card peach-danger-card--hazard">1547 <div class="peach-danger-card-head">1548 <span class="peach-danger-card-glyph" aria-hidden="true">⚠</span>1549 <h3 class="peach-danger-card-title">Destroy vault</h3>1550 </div>1551 <p class="peach-danger-card-copy">1552 Permanently wipes every entry, setting, and license on this1553 device. There is no recovery.1554 </p>1555 <button1556 class="peach-danger-card-button"1557 onClick={() => props.onOpenPanic?.()}1558 >1559 Begin destruction1560 </button>1561 </div>1562 </Show>1563 </Show>1564 </div>1565 </div>1566 );1567 }15681569 function renderSync() {1570 return (1571 <div class="peach-settings-body">1572 <div class="peach-settings-group">1573 <p class="peach-settings-sub-label" style="margin-top:0">SYNC STATUS</p>1574 <div1575 class="peach-settings-row peach-settings-row--meta"1576 style="align-items:center;gap:8px"1577 role="status"1578 aria-label={syncLightLabel()}1579 title={syncLightLabel()}1580 >1581 <span class={`stk-pip stk-pip--sync is-${syncLight()}`} aria-hidden="true" />1582 <span class="peach-settings-value" style="color:var(--peach-t2);line-height:1.5;flex:1;min-width:0;overflow-wrap:anywhere">1583 {syncFlash() ?? lastSyncLine()}1584 </span>1585 </div>1586 <Show when={pairedDevices().length > 0}>1587 <div class="peach-settings-row peach-settings-row--meta">1588 <span class="peach-settings-value" style="color:var(--peach-t2)">1589 {(syncSummary().entriesSent ?? 0) + (syncSummary().entriesReceived ?? 0) > 01590 ? `${syncSummary().entriesSent ?? 0} sent · ${syncSummary().entriesReceived ?? 0} received`1591 : ""}1592 </span>1593 <button1594 class="peach-btn peach-btn--subtle peach-btn--sm"1595 aria-label="Sync this vault now"1596 disabled={syncStatus() === "syncing"}1597 onClick={() => void syncNow()}1598 >{syncStatus() === "syncing" ? "syncing…" : "sync now"}</button>1599 </div>1600 </Show>16011602 <p class="peach-settings-sub-label">ADD A DEVICE</p>1603 <button1604 class="peach-sync-pair-option"1605 onClick={() => props.onOpenSyncPairing?.("code")}1606 >1607 <span class="peach-sync-pair-option-icon" aria-hidden="true">1608 <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">1609 <rect x="6" y="2.5" width="8" height="15" rx="1.6" />1610 <path d="M9 15 L11 15" />1611 </svg>1612 </span>1613 <span class="peach-sync-pair-option-main">1614 <span class="peach-sync-pair-option-title">Show a code</span>1615 <span class="peach-sync-pair-option-sub">Scan it with Peach on your phone, then confirm one 8-digit code</span>1616 </span>1617 <span class="peach-sync-pair-option-chevron" aria-hidden="true">›</span>1618 </button>1619 <Show when={!isPro()}>1620 <p class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2);margin:8px 2px 0">1621 At least one device needs Peach Pro — pair with a Pro device and it carries over to here.1622 </p>1623 </Show>16241625 <Show when={pairedDevices().length > 0}>1626 <p class="peach-settings-sub-label">PAIRED DEVICES</p>1627 <For each={pairedDevices()}>1628 {(d) => (1629 <div class="peach-settings-row peach-settings-row--top">1630 <div class="peach-settings-row-main">1631 <span>{d.deviceName ?? "Unnamed device"}</span>1632 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1633 {deviceTypeLabel(d.deviceType)} · {syncRelativeTime(d.lastSyncedAt)}{isSyncStale(d) ? " · not seen recently" : ""}1634 </span>1635 </div>1636 <Show1637 when={confirmUnpair() === d.deviceId}1638 fallback={1639 <button1640 class="peach-btn peach-btn--subtle peach-btn--sm"1641 onClick={() => setConfirmUnpair(d.deviceId)}1642 >remove</button>1643 }1644 >1645 {16461647164816491650}1651 <div class="peach-unpair-confirm">1652 <p class="peach-unpair-confirm-note">1653 Stops this device receiving future changes. It doesn't1654 lock it out of what it already has — if it's lost or1655 stolen, also change your master password and generate1656 new recovery words.1657 </p>1658 <div style="display:flex;gap:6px;justify-content:flex-end">1659 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => setConfirmUnpair(null)}>cancel</button>1660 <button1661 class="peach-btn peach-btn--sm"1662 style="background:var(--peach-breach);border-color:var(--peach-breach)"1663 onClick={() => void unpairDevice(d.deviceId)}1664 >remove</button>1665 </div>1666 </div>1667 </Show>1668 </div>1669 )}1670 </For>1671 </Show>16721673 <Show when={pendingVouched().length > 0}>1674 <p class="peach-settings-sub-label">1675 PENDING DEVICES1676 <span class="peach-pro-badge" style="background:#C8A840;color:#1a1a1a">{pendingVouched().length}</span>1677 </p>1678 <For each={pendingVouched()}>1679 {(dev) => (1680 <div class="peach-settings-row peach-settings-row--top">1681 <div class="peach-settings-row-main">1682 <span>{dev.deviceName}</span>1683 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1684 Was added by another of your devices. Allow it to sync with this vault?1685 </span>1686 </div>1687 <div style="display:flex;gap:6px;flex-shrink:0">1688 <button1689 class="peach-btn peach-btn--sm"1690 disabled={vouchedBusy() === dev.deviceId}1691 onClick={() => void confirmVouched(dev.deviceId)}1692 >Allow</button>1693 <button1694 class="peach-btn peach-btn--subtle peach-btn--sm"1695 disabled={vouchedBusy() === dev.deviceId}1696 onClick={() => void rejectVouched(dev.deviceId)}1697 >Reject</button>1698 </div>1699 </div>1700 )}1701 </For>1702 </Show>17031704 <button1705 class="peach-settings-advanced-toggle peach-sync-disclosure-toggle"1706 onClick={() => setShowSyncConnection((value) => !value)}1707 aria-expanded={showSyncConnection()}1708 aria-controls="peach-sync-connection"1709 >1710 <span>1711 <span class="peach-settings-sub-label" style="margin:0">CONNECTION</span>1712 <span class="peach-sync-disclosure-summary">Relay and privacy choices</span>1713 </span>1714 <span class="peach-settings-advanced-chevron">1715 {showSyncConnection() ? "▲" : "▼"}1716 </span>1717 </button>1718 <Show when={showSyncConnection()}>1719 <div id="peach-sync-connection" class="peach-sync-disclosure-content">1720 <SyncModeSelector />1721 </div>1722 </Show>17231724 <button1725 class="peach-settings-advanced-toggle peach-sync-disclosure-toggle"1726 onClick={() => setShowSyncRecovery((value) => !value)}1727 aria-expanded={showSyncRecovery()}1728 aria-controls="peach-sync-recovery"1729 >1730 <span>1731 <span class="peach-settings-sub-label" style="margin:0">RECOVERY & SECURITY</span>1732 <span class="peach-sync-disclosure-summary">For a lost or stolen device</span>1733 </span>1734 <span class="peach-settings-advanced-chevron">1735 {showSyncRecovery() ? "▲" : "▼"}1736 </span>1737 </button>1738 <Show when={showSyncRecovery()}>1739 <div id="peach-sync-recovery" class="peach-sync-disclosure-content">1740 <Show1741 when={rotateConfirmOpen()}1742 fallback={1743 <div class="peach-settings-row peach-settings-row--top">1744 <div class="peach-settings-row-main">1745 <span>Rotate sync identity</span>1746 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1747 Disconnects every device and replaces the keys used for sync.1748 </span>1749 </div>1750 <button1751 class="peach-btn peach-btn--subtle peach-btn--sm"1752 style="color:var(--peach-breach);border-color:color-mix(in srgb,var(--peach-breach) 40%,transparent)"1753 onClick={() => { setRotateError(null); setRotateConfirmOpen(true); }}1754 >rotate</button>1755 </div>1756 }1757 >1758 <div class="peach-info-notice" style="margin-top:8px;border-color:color-mix(in srgb,var(--peach-breach) 40%,transparent)">1759 This disconnects every paired device and changes your 12 recovery words. You'll be shown the new words next.1760 <Show when={rotateError()}>1761 <div style="color:var(--peach-breach);margin-top:6px">{rotateError()}</div>1762 </Show>1763 <div style="display:flex;gap:6px;margin-top:10px">1764 <button1765 class="peach-btn peach-btn--subtle peach-btn--sm"1766 disabled={rotating()}1767 onClick={() => setRotateConfirmOpen(false)}1768 >Cancel</button>1769 <button1770 class="peach-btn peach-btn--sm"1771 style="background:var(--peach-breach);border-color:var(--peach-breach)"1772 disabled={rotating()}1773 onClick={() => void doRotateSyncIdentity()}1774 >{rotating() ? "Rotating…" : "Rotate sync identity"}</button>1775 </div>1776 </div>1777 </Show>1778 </div>1779 </Show>1780 </div>1781 </div>1782 );1783 }17841785 function renderAppearance() {1786 const sizeOptions: Array<{ id: "medium" | "large"; label: string }> = [1787 { id: "medium", label: "Default" },1788 { id: "large", label: "Large" },1789 ];1790 return (1791 <div class="peach-settings-body">1792 <div class="peach-settings-group">1793 <p class="peach-settings-sub-label">THEME</p>1794 <div class="peach-settings-row peach-settings-row--meta">1795 <span>Theme</span>1796 <button1797 class="peach-btn peach-btn--subtle peach-btn--sm"1798 onClick={() =>1799 props.onThemeChange(props.theme === "dark" ? "light" : "dark")1800 }1801 >1802 {props.theme === "dark" ? "Switch to light" : "Switch to dark"}1803 </button>1804 </div>18051806 <p class="peach-settings-sub-label">ACCENT</p>1807 <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">1808 <span>Accent</span>1809 <div class="peach-accent-picker">1810 <For each={ACCENT_COLORS}>1811 {(color) => (1812 <button1813 class="peach-accent-swatch peach-swatch"1814 classList={{ "is-active": props.accentColor === color.name }}1815 style={{ background: color.hex }}1816 onClick={() => void props.onAccentChange(color.name)}1817 aria-label={color.name}1818 title={color.name}1819 />1820 )}1821 </For>1822 </div>1823 </div>18241825 <p class="peach-settings-sub-label">TEXT SIZE</p>1826 <div class="peach-settings-row peach-settings-row--meta">1827 <span>Size</span>1828 <div class="peach-pill-group">1829 <For each={sizeOptions}>1830 {(opt) => (1831 <button1832 class="peach-btn peach-btn--ghost peach-btn--sm"1833 classList={{ "is-active": props.textSize === opt.id }}1834 onClick={() => void props.onTextSizeChange(opt.id)}1835 >1836 {opt.label}1837 </button>1838 )}1839 </For>1840 </div>1841 </div>18421843 <p class="peach-settings-sub-label">MOTION</p>1844 <div class="peach-settings-row peach-settings-row--meta">1845 <span>Reduce motion</span>1846 <button1847 class="peach-btn peach-btn--subtle peach-btn--sm"1848 onClick={() => void props.onReduceMotionChange(!props.reduceMotion)}1849 >1850 {props.reduceMotion ? "On" : "Off"}1851 </button>1852 </div>1853 </div>1854 </div>1855 );1856 }18571858 function renderShortcuts() {1859 const [capturingAction, setCapturingAction] = createSignal<ShortcutAction | null>(null);1860 const [conflictMsg, setConflictMsg] = createSignal<string | null>(null);18611862 function startCapture(action: ShortcutAction) {1863 setConflictMsg(null);1864 setCapturingAction(action);1865 }1866 function cancelCapture() {1867 setCapturingAction(null);1868 setConflictMsg(null);1869 }1870 function commitBinding(action: ShortcutAction, binding: Keybinding) {1871 const conflict = findConflict(props.shortcuts, binding, action);1872 if (conflict) {1873 const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict);1874 setConflictMsg(`Already bound to "${other?.label ?? conflict}"`);1875 return;1876 }1877 const next = { ...props.shortcuts, [action]: binding };1878 setCapturingAction(null);1879 setConflictMsg(null);1880 void props.onShortcutsChange(next);1881 }1882 function resetAction(action: ShortcutAction) {1883 const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] };1884 void props.onShortcutsChange(next);1885 }1886 function resetAll() {1887 void props.onShortcutsChange({ ...DEFAULT_BINDINGS });1888 }18891890 1891 1892 function handleCaptureKey(e: KeyboardEvent) {1893 e.preventDefault();1894 e.stopPropagation();1895 if (e.key === "Escape") { cancelCapture(); return; }1896 const binding = keybindingFromEvent(e);1897 if (!binding) return; 1898 const action = capturingAction();1899 if (action) commitBinding(action, binding);1900 }19011902 return (1903 <div class="peach-settings-body">1904 <div class="peach-settings-group peach-shortcuts-group">1905 <p class="peach-settings-sub-label">ENTRY VIEW</p>1906 <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry")}>1907 {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1908 </For>1909 </div>19101911 <div class="peach-settings-group peach-shortcuts-group">1912 <p class="peach-settings-sub-label">ANYWHERE IN POPUP</p>1913 <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global")}>1914 {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1915 </For>1916 </div>19171918 <Show when={conflictMsg()}>1919 <div class="peach-shortcuts-conflict">{conflictMsg()}</div>1920 </Show>19211922 <div class="peach-settings-group" style="display:flex;flex-direction:column;gap:8px">1923 <div class="peach-settings-row peach-settings-row--meta">1924 <span>Inline autofill on page</span>1925 <a1926 class="peach-settings-link"1927 href="chrome://extensions/shortcuts"1928 onClick={(e) => {1929 e.preventDefault();1930 chrome.tabs.create({ url: "chrome://extensions/shortcuts" });1931 }}1932 >1933 ⌘⇧P — customize ↗1934 </a>1935 </div>1936 <button1937 class="peach-btn peach-btn--subtle peach-btn--sm"1938 style="align-self:flex-start"1939 onClick={resetAll}1940 >1941 Reset all to defaults1942 </button>1943 </div>1944 </div>1945 );19461947 function renderRow(1948 desc: typeof SHORTCUT_DESCRIPTORS[number],1949 capturing: () => ShortcutAction | null,1950 onStart: (a: ShortcutAction) => void,1951 onCancel: () => void,1952 onReset: (a: ShortcutAction) => void,1953 onKey: (e: KeyboardEvent) => void,1954 ) {1955 const isActive = () => capturing() === desc.action;1956 const binding = () => props.shortcuts[desc.action];1957 const isCustom = () => {1958 const b = binding();1959 const d = DEFAULT_BINDINGS[desc.action];1960 return b.key !== d.key1961 || Boolean(b.ctrl) !== Boolean(d.ctrl)1962 || Boolean(b.meta) !== Boolean(d.meta)1963 || Boolean(b.shift) !== Boolean(d.shift)1964 || Boolean(b.alt) !== Boolean(d.alt);1965 };1966 return (1967 <div class="peach-shortcut-row">1968 <span class="peach-shortcut-row-label">{desc.label}</span>1969 <button1970 class="peach-shortcut-chip"1971 classList={{ "is-capturing": isActive(), "is-custom": isCustom() }}1972 onClick={() => isActive() ? onCancel() : onStart(desc.action)}1973 onKeyDown={(e) => { if (isActive()) onKey(e); }}1974 ref={(el) => {1975 1976 if (el) {1977 queueMicrotask(() => { if (isActive()) el.focus(); });1978 }1979 }}1980 >1981 {isActive() ? "Press a key…" : formatKeybinding(binding())}1982 </button>1983 <Show when={isCustom() && !isActive()}>1984 <button1985 class="peach-shortcut-reset"1986 title="Reset to default"1987 onClick={() => onReset(desc.action)}1988 >1989 ↺1990 </button>1991 </Show>1992 </div>1993 );1994 }1995 }19961997 const PRO_FEATURES: Array<{1998 feature: ProFeature;1999 name: string;2000 desc: string;2001 }> = [2002 { feature: "sync", name: "Peach Sync", desc: "Encrypted · peer-to-peer" },2003 { feature: "codex", name: "Peach Codex", desc: "Restore from paper" },2004 { feature: "passkeys", name: "Passkeys", desc: "Zero-knowledge WebAuthn" },2005 { feature: "advanced_health", name: "Advanced Vault Health", desc: "Reuse · age · exposure" },2006 ];20072008 const PRO_SECURITY_FEATURES: Array<{2009 feature: ProFeature;2010 name: string;2011 desc: string;2012 }> = [2013 { feature: "canary_entry", name: "Canary entries", desc: "Detects vault compromise" },2014 { feature: "duress_vault", name: "Duress vault", desc: "Decoy under coercion" },2015 ];20162017 const [licenseKey, setLicenseKey] = createSignal<string | null>(null);2018 const [showKey, setShowKey] = createSignal(false);20192020 const [deactivating, setDeactivating] = createSignal(false);20212022 async function handleDeactivateLicense() {2023 setDeactivating(true);2024 await sendMessage({ type: "DEACTIVATE_LICENSE" });2025 setLicenseInfo(null);2026 await props.onLicenseChange?.();2027 setDeactivating(false);2028 }20292030 async function handleTestCanary(entry: {id: string; title: string; canary: Canary}) {2031 setCanaryTestingId(entry.id);2032 setCanaryTestResult(null);2033 const res = await sendMessage({2034 type: "FIRE_CANARY_PING",2035 payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger: "test" },2036 }) as { ok: boolean };2037 setCanaryTestResult({ id: entry.id, ok: res.ok });2038 setCanaryTestingId(null);2039 setTimeout(() => setCanaryTestResult((r) => r?.id === entry.id ? null : r), 3000);2040 }20412042 function renderLicense() {2043 async function onActivate() {2044 await handleActivateLicense();2045 if ((licenseInfo()?.valid ?? false) && licenseInfo()?.plan === "pro") {2046 await props.onLicenseChange?.();2047 }2048 }20492050 return (2051 <div class="peach-settings-body">2052 <Show2053 when={isPro()}2054 fallback={2055 <div class="peach-pro-upgrade">2056 <p class="peach-pro-eyebrow">PEACH PRO · ONE-TIME</p>20572058 <h2 class="peach-pro-headline">2059 Every<em>thing,</em><br />every<em>where.</em>2060 </h2>20612062 <p class="peach-pro-subtitle">2063 One key unlocks every Pro feature — for the lifetime of the app.2064 </p>20652066 <div class="peach-pro-grid">2067 <For each={PRO_FEATURES}>2068 {(item, i) => (2069 <div2070 class="peach-pro-grid-item"2071 classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}2072 >2073 <span class="peach-pro-grid-num">{String(i() + 1).padStart(2, "0")}</span>2074 <span class="peach-pro-grid-name">{item.name}</span>2075 <span class="peach-pro-grid-desc">{item.desc}</span>2076 </div>2077 )}2078 </For>2079 </div>20802081 <p class="peach-pro-section-label">ADVANCED SECURITY</p>2082 <div class="peach-pro-grid">2083 <For each={PRO_SECURITY_FEATURES}>2084 {(item, i) => (2085 <div2086 class="peach-pro-grid-item"2087 classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}2088 >2089 <span class="peach-pro-grid-num">{String(PRO_FEATURES.length + i() + 1).padStart(2, "0")}</span>2090 <span class="peach-pro-grid-name">{item.name}</span>2091 <span class="peach-pro-grid-desc">{item.desc}</span>2092 </div>2093 )}2094 </For>2095 </div>20962097 <div class="peach-pro-upgrade-actions">2098 <p class="peach-pro-pricing">2099 $39 <span class="peach-pro-pricing-sub">once · yours forever</span>2100 </p>2101 <a2102 href="https://buy.polar.sh/polar_cl_IXSFIPojx67rcOTksn8LkH5mnU3osPHtYqBIl1eyuIz"2103 target="_blank"2104 rel="noopener noreferrer"2105 class="peach-pro-upgrade-cta"2106 >2107 Get Peach Pro2108 </a>2109 <div class="peach-pro-key-divider"><span>ALREADY HAVE A KEY</span></div>2110 <div class="peach-pro-key-inline">2111 <input2112 class="peach-pro-key-inline-input"2113 type="text"2114 placeholder="PEACH-XXXX-XXXX-XXXX"2115 value={licenseKeyInput()}2116 onInput={(e) => setLicenseKeyInput(e.currentTarget.value)}2117 onKeyDown={(e) => { if (e.key === "Enter") void onActivate(); }}2118 autocomplete="off"2119 spellcheck={false}2120 />2121 <button2122 class="peach-pro-key-apply"2123 onClick={() => void onActivate()}2124 disabled={activatingLicense() || !licenseKeyInput().trim()}2125 >2126 {activatingLicense() ? "…" : "APPLY"}2127 </button>2128 </div>2129 <Show when={licenseError()}>2130 <p class="peach-pro-key-error">{licenseError()}</p>2131 </Show>2132 </div>2133 </div>2134 }2135 >2136 <div class="peach-pro-active">2137 <div class="peach-pro-active-hero">2138 <button2139 class="peach-pro-active-mark-wrap"2140 type="button"2141 aria-label="Celebrate"2142 onClick={handleProCelebrationClick}2143 >2144 <PeachMark class="peach-pro-active-mark" />2145 <Show when={justActivated()}>2146 <div class="peach-pro-sparks" aria-hidden="true">2147 <span class="peach-pro-spark" style="--spark-x:0px;--spark-y:-54px;--spark-delay:0ms">✦</span>2148 <span class="peach-pro-spark" style="--spark-x:38px;--spark-y:-38px;--spark-delay:50ms">✦</span>2149 <span class="peach-pro-spark" style="--spark-x:54px;--spark-y:0px;--spark-delay:110ms">·</span>2150 <span class="peach-pro-spark" style="--spark-x:38px;--spark-y:38px;--spark-delay:30ms">✦</span>2151 <span class="peach-pro-spark" style="--spark-x:0px;--spark-y:54px;--spark-delay:80ms">·</span>2152 <span class="peach-pro-spark" style="--spark-x:-38px;--spark-y:38px;--spark-delay:130ms">✦</span>2153 <span class="peach-pro-spark" style="--spark-x:-54px;--spark-y:0px;--spark-delay:55ms">·</span>2154 <span class="peach-pro-spark" style="--spark-x:-38px;--spark-y:-38px;--spark-delay:95ms">✦</span>2155 </div>2156 </Show>2157 </button>2158 <h2 class="peach-pro-active-title">Peach Pro</h2>2159 <p class="peach-pro-active-since">{formatActivatedSince(licenseInfo()?.issued)}</p>2160 <Show when={justActivated()}>2161 <p class="peach-pro-thankyou-text" aria-live="polite">thank you ✦</p>2162 </Show>2163 </div>21642165 <Show when={licenseKey()}>2166 <div class="peach-settings-license">2167 <span class="peach-settings-label">LICENSE KEY</span>2168 <div class="peach-settings-license-row">2169 <Show2170 when={showKey()}2171 fallback={2172 <span class="peach-settings-license-key peach-settings-license-key--masked" aria-label="License key hidden">2173 ••••••••••••••••••••••••••••2174 </span>2175 }2176 >2177 <span class="peach-settings-license-key">{licenseKey()!}</span>2178 </Show>2179 <div class="peach-settings-license-actions">2180 <button2181 class="peach-settings-license-copy"2182 title="Copy license key"2183 onClick={() => copyLicenseKey(licenseKey()!)}2184 >2185 {copied() ? "✓" : "copy"}2186 </button>2187 <button2188 class="peach-settings-license-toggle"2189 aria-label={showKey() ? "Hide license key" : "Reveal license key"}2190 title={showKey() ? "Hide" : "Reveal"}2191 onClick={() => setShowKey(v => !v)}2192 >2193 <Show2194 when={showKey()}2195 fallback={2196 <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">2197 <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />2198 <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />2199 </svg>2200 }2201 >2202 <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">2203 <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />2204 <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />2205 <line x1="2" y1="2" x2="12" y2="12" />2206 </svg>2207 </Show>2208 </button>2209 </div>2210 </div>2211 </div>2212 </Show>22132214 <div class="peach-pro-active-features">2215 <For each={[...PRO_FEATURES, ...PRO_SECURITY_FEATURES]}>2216 {(item) => (2217 <div class="peach-pro-active-feature">2218 <span class="peach-pro-active-feature-mark">✦</span>2219 <span class="peach-pro-active-feature-name">{item.name}</span>2220 </div>2221 )}2222 </For>2223 </div>22242225 <button2226 class="peach-pro-deactivate"2227 onClick={() => void handleDeactivateLicense()}2228 disabled={deactivating()}2229 >2230 {deactivating() ? "Deactivating…" : "Deactivate"}2231 </button>2232 </div>2233 </Show>2234 </div>2235 );2236 }22372238 function renderFeedback() {2239 return (2240 <div class="peach-settings-body">2241 <div class="peach-settings-group peach-feedback-group">2242 <Show2243 when={feedbackState() === 'sent'}2244 fallback={2245 <>2246 <p class="peach-feedback-intro">2247 Help us improve Peach. Every report goes directly to our inbox.2248 </p>22492250 <p class="peach-settings-sub-label">TYPE</p>2251 <div class="peach-feedback-chips">2252 <For each={FEEDBACK_CATEGORIES}>2253 {(cat) => {2254 const selected = () => fbCategory() === cat;2255 return (2256 <button2257 type="button"2258 class={`peach-feedback-chip ${selected() ? "is-active" : ""}`}2259 aria-pressed={selected()}2260 onClick={() => setFbCategory(cat)}2261 >2262 {cat}2263 </button>2264 );2265 }}2266 </For>2267 </div>22682269 <p class="peach-settings-sub-label">MESSAGE</p>2270 <textarea2271 class="peach-feedback-textarea"2272 placeholder="Describe the issue or idea..."2273 maxLength={MAX_FEEDBACK_MESSAGE_LENGTH}2274 value={fbMessage()}2275 onInput={(e) => setFbMessage(e.currentTarget.value)}2276 />22772278 <p class="peach-feedback-meta">2279 Version {version} · will be included automatically2280 </p>22812282 <button2283 type="button"2284 class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"2285 disabled={!fbMessage().trim() || feedbackState() === 'sending'}2286 onClick={() => void handleSubmitFeedback()}2287 >2288 <Switch>2289 <Match when={feedbackState() === 'idle'}>Send</Match>2290 <Match when={feedbackState() === 'sending'}>Sending...</Match>2291 <Match when={feedbackState() === 'error'}>Failed - try again</Match>2292 </Switch>2293 </button>22942295 <p class="peach-feedback-anon">2296 Sent anonymously · no account required2297 </p>2298 </>2299 }2300 >2301 <div class="peach-feedback-thanks" role="status" aria-live="polite">2302 <p class="peach-feedback-thanks-title">Thank you</p>2303 <p>2304 Your note is in the queue. Someone will process it soon.2305 </p>2306 </div>2307 </Show>2308 </div>2309 </div>2310 );2311 }23122313 const bodies: StackBodies = {2314 protection: renderSecurity(),2315 vault: renderVault(),2316 sync: renderSync(),2317 appearance: renderAppearance(),2318 shortcuts: renderShortcuts(),2319 pro: renderLicense(),2320 feedback: renderFeedback(),2321 };23222323 return (2324 <Show2325 when={showNewRecoveryWords()}2326 fallback={2327 <div2328 class="peach-overlay-view peach-settings-overlay"2329 classList={{ "ov-enter-right": !props.isExiting, "ov-exit-right": !!props.isExiting }}2330 style="display:flex;flex-direction:column"2331 >2332 <StackDeck2333 active={activeCard()}2334 onActiveChange={setActiveCard}2335 onBack={props.onBack}2336 cards={cards()}2337 bodies={bodies}2338 version={version}2339 isFanning={isFanning()}2340 celebratingCardId={justActivated() ? "pro" : null}2341 isPro={isPro()}2342 />2343 </div>2344 }2345 >2346 <RecoveryWordDisplayView onDone={() => setShowNewRecoveryWords(false)} />2347 </Show>2348 );2349}