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);376377 async function loadPendingVouched() {378 const r = (await sendMessage({ type: "GET_PENDING_VOUCHED_DEVICES" })) as {379 ok: boolean;380 data?: PendingVouched[];381 };382 if (r.ok) setPendingVouched(r.data ?? []);383 }384 async function confirmVouched(deviceId: string) {385 setVouchedBusy(deviceId);386 try {387 await sendMessage({ type: "CONFIRM_VOUCHED_DEVICE", payload: { deviceId } });388 await loadPendingVouched();389 } finally {390 setVouchedBusy(null);391 }392 }393 async function rejectVouched(deviceId: string) {394 setVouchedBusy(deviceId);395 try {396 await sendMessage({ type: "REJECT_VOUCHED_DEVICE", payload: { deviceId } });397 await loadPendingVouched();398 } finally {399 setVouchedBusy(null);400 }401 }402 async function doRotateSyncIdentity() {403 setRotateError(null);404 setRotating(true);405 try {406 const r = (await sendMessage({ type: "ROTATE_SYNC_IDENTITY" })) as {407 ok: boolean;408 error?: string;409 };410 if (!r.ok) {411 setRotateError(r.error ?? "Rotation failed.");412 return;413 }414 setRotateConfirmOpen(false);415 416 417 setShowNewRecoveryWords(true);418 } finally {419 setRotating(false);420 }421 }422423 const [dedupCount, setDedupCount] = createSignal<number | null>(null);424 const [dedupRunning, setDedupRunning] = createSignal(false);425 const [dedupDone, setDedupDone] = createSignal<number | null>(null);426427 const [declinedCardCount, setDeclinedCardCount] = createSignal(0);428429 430 431 432 const [historySnapshots, setHistorySnapshots] = createSignal<VaultSnapshot[]>([]);433 const [historyLoading, setHistoryLoading] = createSignal(false);434 const [historyRestoring, setHistoryRestoring] = createSignal<string | null>(null);435 const [historyShowAll, setHistoryShowAll] = createSignal(false);436437 async function loadVaultHistory() {438 setHistoryLoading(true);439 try {440 const res = await sendMessage({ type: "GET_VAULT_HISTORY" });441 const list = (res as { ok: boolean; data?: VaultSnapshot[] }).data ?? [];442 setHistorySnapshots(list);443 } finally {444 setHistoryLoading(false);445 }446 }447448 async function handleRestoreVaultSnapshot(id: string) {449 if (historyRestoring()) return;450 setHistoryRestoring(id);451 const res = await sendMessage({452 type: "RESTORE_VAULT_SNAPSHOT",453 payload: { snapshotId: id },454 });455 setHistoryRestoring(null);456 if ((res as { ok: boolean }).ok) {457 458 459 props.onBack();460 }461 }462463 function formatSnapshotTime(savedAt: number): string {464 const diff = Date.now() - savedAt;465 if (diff < 60_000) return "Just now";466 if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;467 const d = new Date(savedAt);468 const isToday = new Date().toDateString() === d.toDateString();469 const isYest =470 new Date(Date.now() - 86_400_000).toDateString() === d.toDateString();471 const t = d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });472 if (isToday) return `Today ${t}`;473 if (isYest) return `Yesterday ${t}`;474 return `${d.toLocaleDateString([], { month: "short", day: "numeric" })} ${t}`;475 }476477 function reasonLabel(reason: VaultSnapshot["reason"]): string {478 return (479 {480 local_save: "saved",481 sync_received: "synced",482 import: "imported",483 restore: "restored",484 } as const485 )[reason] ?? reason;486 }487488 const [showVaultAdvanced, setShowVaultAdvanced] = createSignal(false);489490 const [showChangePw, setShowChangePw] = createSignal(false);491 const [changePwCurrent, setChangePwCurrent] = createSignal("");492 const [changePwNew, setChangePwNew] = createSignal("");493 const [changePwConfirm, setChangePwConfirm] = createSignal("");494 const [changePwSaving, setChangePwSaving] = createSignal(false);495 const [changePwError, setChangePwError] = createSignal<string | null>(null);496 const [changePwSuccess] = createSignal(false);497 const [showNewRecoveryWords, setShowNewRecoveryWords] = createSignal(false);498499 500 const [showViewWords, setShowViewWords] = createSignal(false);501 const [viewWordsPassword, setViewWordsPassword] = createSignal("");502 const [viewWordsError, setViewWordsError] = createSignal<string | null>(null);503 const [viewedWords, setViewedWords] = createSignal<string[] | null>(null);504 const [viewWordsBusy, setViewWordsBusy] = createSignal(false);505506 async function handleViewRecoveryWords() {507 setViewWordsError(null);508 if (!viewWordsPassword()) {509 setViewWordsError("Enter your master password.");510 return;511 }512 setViewWordsBusy(true);513 try {514 const r = (await sendMessage({515 type: "GET_RECOVERY_WORDS",516 payload: { password: viewWordsPassword() },517 })) as { ok: boolean; data?: string[]; error?: string };518 if (r.ok) {519 setViewedWords(r.data ?? []);520 setViewWordsPassword("");521 return;522 }523 switch (r.error) {524 case "wrong_password":525 setViewWordsError("Incorrect password");526 break;527 case "password_required":528 setViewWordsError("Enter your master password.");529 break;530 case "recovery_not_setup":531 setViewWordsError("Recovery words aren't set up for this vault yet.");532 break;533 default:534 setViewWordsError("Couldn't retrieve recovery words.");535 }536 } finally {537 setViewWordsBusy(false);538 }539 }540 function closeViewWords() {541 setShowViewWords(false);542 setViewWordsPassword("");543 setViewWordsError(null);544 setViewedWords(null);545 }546547 const [duressEnabled, setDuressEnabled] = createSignal(false);548 const [showDuressSetup, setShowDuressSetup] = createSignal(false);549 const [duressPassword, setDuressPassword] = createSignal("");550 const [duressConfirm, setDuressConfirm] = createSignal("");551 const [duressMainPassword, setDuressMainPassword] = createSignal("");552 const [duressSaving, setDuressSaving] = createSignal(false);553 const [duressError, setDuressError] = createSignal<string | null>(null);554 const [duressSuccess, setDuressSuccess] = createSignal<string | null>(null);555556 const [canaryEntries, setCanaryEntries] = createSignal<Array<{id: string; title: string; canary: Canary}>>([]);557558 const [canaryTestingId, setCanaryTestingId] = createSignal<string | null>(null);559 const [canaryTestResult, setCanaryTestResult] = createSignal<{id: string; ok: boolean} | null>(null);560561 const [fbCategory, setFbCategory] = createSignal("Bug report");562 const [fbMessage, setFbMessage] = createSignal("");563 type FeedbackState = 'idle' | 'sending' | 'sent' | 'error';564 const [feedbackState, setFeedbackState] = createSignal<FeedbackState>('idle');565566 createEffect(() => {567 if (props.upgradeHighlight) {568 setActiveCard("pro");569 }570 });571572 onMount(async () => {573 const [licenseRes, passkeyRes, duressRes, settingsRes, keyRes] = await Promise.all([574 sendMessage({ type: "GET_LICENSE" }),575 sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }),576 sendMessage({ type: "IS_DURESS_ENABLED" }),577 sendMessage({ type: "GET_SETTINGS" }),578 sendMessage({ type: "GET_LICENSE_KEY" }),579 ]);580581 582 583 void loadVaultHistory();584585 586 587 void loadPendingVouched();588 void loadPairedDevices();589 void loadSyncState();590 const onBroadcast = (msg: unknown) => {591 const m = msg as { type?: string; payload?: unknown } | null;592 if (m?.type === "VOUCHED_DEVICE_PENDING") {593 void loadPendingVouched();594 }595 if (m?.type === "SYNC_STATE_CHANGED") {596 const payload = (m.payload ?? {}) as SyncOutcomeSummary;597 setSyncSummary(payload);598 setSyncStatus(payload.status ?? "idle");599 }600 };601 chrome.runtime?.onMessage?.addListener(onBroadcast);602 onCleanup(() => {603 try { chrome.runtime?.onMessage?.removeListener(onBroadcast); } catch { }604 });605606 if ((licenseRes as { ok: boolean }).ok) {607 setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null);608 }609610 if ((passkeyRes as { ok: boolean }).ok) {611 setPasskeyState(612 (passkeyRes as { ok: boolean; data?: PasskeyProviderState }).data ?? null613 );614 }615616 if ((duressRes as { ok: boolean }).ok) {617 setDuressEnabled((duressRes as { ok: boolean; data?: boolean }).data ?? false);618 }619620 if ((settingsRes as { ok: boolean }).ok) {621 const sData = (settingsRes as { ok: boolean; data?: { declinedCardFingerprints?: string[] } }).data;622 setDeclinedCardCount(sData?.declinedCardFingerprints?.length ?? 0);623 }624625 if ((keyRes as { ok: boolean }).ok) {626 setLicenseKey((keyRes as { ok: boolean; data?: string }).data ?? null);627 }628629 const entriesRes = await sendMessage({ type: "GET_ENTRIES" });630 if ((entriesRes as { ok: boolean }).ok) {631 const entries = (entriesRes as { ok: boolean; data?: Array<{id: string; title: string; canary?: Canary}> }).data ?? [];632 setCanaryEntries(633 entries634 .filter((e) => e.canary?.webhookUrl)635 .map((e) => ({ id: e.id, title: e.title, canary: e.canary! }))636 );637 }638639640 if (isNativePlatform()) {641 const biometric = nativePlugin<{ isAvailable: () => Promise<{ available: boolean }> }>("BiometricPlugin");642 const keystore = nativePlugin<{ hasMasterKey: () => Promise<{ exists: boolean }> }>("KeystorePlugin");643 try {644 if (biometric) {645 const a = await biometric.isAvailable();646 setBiometricSupported(a.available);647 }648 if (keystore) {649 const k = await keystore.hasMasterKey();650 setBiometricStored(k.exists);651 }652 } catch { }653 }654 });655656 async function handleEnableBiometrics() {657 setBiometricBusy(true);658 setBiometricError(null);659 try {660 const keyRes = (await sendMessage({ type: "GET_DERIVED_KEY" })) as {661 ok: boolean;662 data?: { masterKeyHex: string };663 error?: string;664 };665 if (!keyRes.ok || !keyRes.data?.masterKeyHex) {666 setBiometricError(keyRes.error === "vault_locked"667 ? "unlock the vault first"668 : "couldn't read derived key");669 return;670 }671 const keystore = nativePlugin<{ storeMasterKey: (p: { masterKeyHex: string }) => Promise<void> }>("KeystorePlugin");672 if (!keystore) {673 setBiometricError("biometric plugin unavailable");674 return;675 }676 await keystore.storeMasterKey({ masterKeyHex: keyRes.data.masterKeyHex });677 await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: true } });678 setBiometricStored(true);679 } catch (e) {680 setBiometricError(e instanceof Error ? e.message : "failed to enable biometrics");681 } finally {682 setBiometricBusy(false);683 }684 }685686 async function handleDisableBiometrics() {687 setBiometricBusy(true);688 setBiometricError(null);689 try {690 const keystore = nativePlugin<{ clearMasterKey: () => Promise<void> }>("KeystorePlugin");691 if (keystore) await keystore.clearMasterKey();692 await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: false } });693 setBiometricStored(false);694 } catch (e) {695 setBiometricError(e instanceof Error ? e.message : "failed to disable biometrics");696 } finally {697 setBiometricBusy(false);698 }699 }700701 async function handleChangePassword() {702 setChangePwError(null);703 if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; }704 if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; }705 if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; }706 if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; }707 setChangePwSaving(true);708 const res = await sendMessage({709 type: "CHANGE_PASSWORD",710 payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() },711 }) as { ok: boolean; error?: string };712 if (res.ok) {713 setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");714 setShowChangePw(false);715 716 717 718 719 await chrome.storage?.local?.set?.({ peach_recovery_pending: "1" });720 setShowNewRecoveryWords(true);721 } else {722 setChangePwError(res.error ?? "failed to change password");723 }724 setChangePwSaving(false);725 }726727 const [copied, setCopied] = createSignal(false);728729 async function copyLicenseKey(key: string) {730 await navigator.clipboard.writeText(key);731 setCopied(true);732 setTimeout(() => setCopied(false), 2000);733 }734735 async function handleActivateLicense() {736 const key = licenseKeyInput().trim();737 if (!key) return;738 setActivatingLicense(true);739 setLicenseError(null);740 setLicenseSuccess(null);741 const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } });742 setActivatingLicense(false);743 if ((res as { ok: boolean }).ok) {744 const data = (res as { ok: boolean; data?: LicenseInfo }).data;745 if (data?.valid) {746 setLicenseInfo(data);747 setLicenseKey(key);748 setLicenseSuccess("License activated!");749 setLicenseKeyInput("");750 } else {751 setLicenseError("Invalid license key");752 }753 } else {754 setLicenseError("Activation failed");755 }756 }757758 async function handleTogglePasskeys() {759 const current = passkeyState();760 if (!current || !current.supported) return;761 setPasskeySaving(true);762 const res = await sendMessage({763 type: "SET_PASSKEY_PROVIDER_ENABLED",764 payload: { enabled: !current.enabled },765 });766 setPasskeySaving(false);767 if ((res as { ok: boolean }).ok) {768 setPasskeyState(769 (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null770 );771 }772 }773774 async function handleCheckAllBreaches() {775 setCheckingBreaches(true);776 setBreachResult(null);777 const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });778 setCheckingBreaches(false);779 if ((res as { ok: boolean }).ok) {780 const data = (res as { ok: boolean; data?: Record<string, number> }).data ?? {};781 const breached = Object.values(data).filter((count) => count > 0).length;782 setBreachResult(783 breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean"784 );785 props.onBreachCheckDone();786 }787 }788789790 async function handleSetupDuress() {791 const mainPw = duressMainPassword().trim();792 const duressPw = duressPassword().trim();793 const confirmPw = duressConfirm().trim();794795 if (!mainPw) { setDuressError("enter your main password to confirm"); return; }796 if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; }797 if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; }798 if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; }799800 setDuressSaving(true);801 setDuressError(null);802803 const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };804 if (!verifyRes.ok || !verifyRes.data) {805 setDuressError("incorrect main password");806 setDuressSaving(false);807 return;808 }809810 const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string };811 setDuressSaving(false);812 if (res.ok) {813 setDuressEnabled(true);814 setShowDuressSetup(false);815 setDuressPassword("");816 setDuressConfirm("");817 setDuressMainPassword("");818 setDuressSuccess("Duress password set. If entered, a decoy vault will be shown.");819 setTimeout(() => setDuressSuccess(null), 5000);820 } else {821 setDuressError(822 res.error === "session_expired"823 ? "Vault session expired — lock and unlock Peach, then try again"824 : res.error ?? "failed to set duress password"825 );826 }827 }828829 async function handleClearDeclinedCards() {830 await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } });831 setDeclinedCardCount(0);832 }833834 async function handleCountDuplicates() {835 setDedupRunning(true);836 setDedupDone(null);837 const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number };838 setDedupRunning(false);839 if (res.ok) setDedupCount(res.data ?? 0);840 }841842 async function handleDeduplicate() {843 setDedupRunning(true);844 const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number };845 setDedupRunning(false);846 if (res.ok) {847 setDedupDone(res.data ?? 0);848 setDedupCount(0);849 }850 }851852 async function handleDisableDuress() {853 const mainPw = duressMainPassword().trim();854 if (!mainPw) { setDuressError("enter your main password to confirm"); return; }855856 setDuressSaving(true);857 setDuressError(null);858859 const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };860 if (!verifyRes.ok || !verifyRes.data) {861 setDuressError("incorrect main password");862 setDuressSaving(false);863 return;864 }865866 const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string };867 setDuressSaving(false);868 if (res.ok) {869 setDuressEnabled(false);870 setShowDuressSetup(false);871 setDuressMainPassword("");872 setDuressSuccess("Duress password disabled.");873 setTimeout(() => setDuressSuccess(null), 4000);874 } else {875 setDuressError(res.error ?? "failed to disable duress password");876 }877 }878879 async function handleSubmitFeedback() {880 const message = sanitizeFeedbackMessage(fbMessage());881 if (!message) return;882883 setFeedbackState('sending');884885 try {886 const hasDataPermission = await requestFeedbackDataPermission();887 if (!hasDataPermission) {888 setFeedbackState('idle');889 return;890 }891892 const category = FEEDBACK_CATEGORIES.includes(fbCategory())893 ? fbCategory()894 : FEEDBACK_CATEGORIES[0];895 const res = await fetch('https://api.web3forms.com/submit', {896 method: 'POST',897 headers: { 'Content-Type': 'application/json' },898 body: JSON.stringify({899 access_key: WEB3FORMS_KEY,900 subject: `Peach Feedback - ${category}`,901 message,902 from_name: 'Peach User',903 904 botcheck: '',905 }),906 });907908 if (!res.ok) throw new Error(`HTTP ${res.status}`);909 const data = await res.json() as { success: boolean; message?: string };910 if (!data.success) throw new Error(data.message ?? 'unknown error');911912 setFeedbackState('sent');913 setFbMessage('');914 setFbCategory(FEEDBACK_CATEGORIES[0]);915 } catch (err) {916 console.error('[feedback]', err);917 setFeedbackState('error');918 setTimeout(() => setFeedbackState('idle'), 4000);919 }920 }921922 function renderSecurity() {923 return (924 <div class="peach-settings-body">925 <div class="peach-settings-group">926 <p class="peach-settings-sub-label">LOCKING</p>927 <div class="peach-settings-row">928 <span>Lock on close</span>929 <button930 class={`peach-toggle ${props.lockOnClose ? "is-on" : ""}`}931 onClick={() => void props.onLockOnCloseChange(!props.lockOnClose)}932 aria-label="Toggle lock on close"933 >934 <span class="peach-toggle-knob"></span>935 </button>936 </div>937 <div class="peach-settings-row">938 <span>Auto-lock after</span>939 <select940 class="peach-settings-select"941 value={String(props.autoLockMinutes)}942 onChange={(e) => void props.onAutoLockMinutesChange(Number(e.currentTarget.value))}943 >944 <option value="1">1 minute</option>945 <option value="5">5 minutes</option>946 <option value="15">15 minutes</option>947 <option value="30">30 minutes</option>948 <option value="0">Never</option>949 </select>950 </div>951952 <Show when={isNativePlatform() && biometricSupported()}>953 <p class="peach-settings-sub-label">BIOMETRICS</p>954 <div class="peach-settings-row">955 <span>Unlock with biometrics</span>956 <button957 class={`peach-toggle ${biometricStored() ? "is-on" : ""}`}958 onClick={() => void (biometricStored() ? handleDisableBiometrics() : handleEnableBiometrics())}959 disabled={biometricBusy()}960 aria-label="Toggle biometric unlock"961 >962 <span class="peach-toggle-knob"></span>963 </button>964 </div>965 <Show when={biometricError()}>966 <div class="peach-settings-row peach-settings-row--meta">967 <span class="peach-hint peach-hint-error">{biometricError()}</span>968 </div>969 </Show>970 </Show>971972 <p class="peach-settings-sub-label">BREACH PROTECTION</p>973 <div class="peach-settings-row">974 <span>Breach monitoring</span>975 <button976 class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}977 onClick={() => void props.onBreachMonitoringChange(!props.breachMonitoring)}978 aria-label="Toggle breach monitoring"979 >980 <span class="peach-toggle-knob"></span>981 </button>982 </div>983 <div class="peach-settings-row peach-settings-row--meta">984 <span>Check all passwords</span>985 <button986 class="peach-btn peach-btn--subtle peach-btn--sm"987 onClick={() => void handleCheckAllBreaches()}988 disabled={checkingBreaches()}989 >990 {checkingBreaches() ? "checking..." : "run"}991 </button>992 </div>993 <Show when={breachResult()}>994 <div class="peach-settings-row peach-settings-row--meta">995 <span>Last check</span>996 <span class="peach-settings-value">{breachResult()}</span>997 </div>998 </Show>9991000 <p class="peach-settings-sub-label">MASTER PASSWORD</p>1001 <div class="peach-settings-row peach-settings-row--meta">1002 <span>Master password</span>1003 <button1004 class="peach-btn peach-btn--subtle peach-btn--sm"1005 onClick={() => { setShowChangePw((v) => !v); setChangePwError(null); }}1006 >1007 {showChangePw() ? "cancel" : "change"}1008 </button>1009 </div>1010 <Show when={showChangePw()}>1011 <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1012 <input1013 class="peach-settings-input"1014 type="password"1015 placeholder="current password"1016 value={changePwCurrent()}1017 onInput={(e) => setChangePwCurrent((e.target as HTMLInputElement).value)}1018 autocomplete="current-password"1019 />1020 <input1021 class="peach-settings-input"1022 type="password"1023 placeholder="new password (min 12 chars)"1024 value={changePwNew()}1025 onInput={(e) => setChangePwNew((e.target as HTMLInputElement).value)}1026 autocomplete="new-password"1027 />1028 <input1029 class="peach-settings-input"1030 type="password"1031 placeholder="confirm new password"1032 value={changePwConfirm()}1033 onInput={(e) => setChangePwConfirm((e.target as HTMLInputElement).value)}1034 autocomplete="new-password"1035 onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }}1036 />1037 <Show when={changePwError()}>1038 <p class="peach-hint peach-hint-error" style="margin:0">{changePwError()}</p>1039 </Show>1040 <Show when={changePwSuccess()}>1041 <p class="peach-hint" style="margin:0;color:var(--peach-accent)">password changed ✓</p>1042 </Show>1043 <button1044 class="peach-btn peach-btn--accent peach-btn--sm"1045 style="align-self:flex-start"1046 onClick={() => void handleChangePassword()}1047 disabled={changePwSaving() || !changePwCurrent() || !changePwNew() || !changePwConfirm()}1048 >1049 {changePwSaving() ? "saving…" : "save new password"}1050 </button>1051 </div>1052 </Show>10531054 {}1055 <div class="peach-settings-row peach-settings-row--meta">1056 <span>Recovery words</span>1057 <button1058 class="peach-btn peach-btn--subtle peach-btn--sm"1059 onClick={() => {1060 if (showViewWords()) { closeViewWords(); } else { setShowViewWords(true); }1061 }}1062 >1063 {showViewWords() ? "cancel" : "view"}1064 </button>1065 </div>1066 <Show when={showViewWords()}>1067 <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1068 <Show1069 when={viewedWords()}1070 fallback={1071 <>1072 <p class="peach-hint" style="margin:0;color:var(--peach-t2)">1073 Enter your master password to reveal your recovery words.1074 </p>1075 <input1076 class="peach-settings-input"1077 type="password"1078 placeholder="master password"1079 value={viewWordsPassword()}1080 onInput={(e) => setViewWordsPassword((e.target as HTMLInputElement).value)}1081 autocomplete="current-password"1082 onKeyDown={(e) => { if (e.key === "Enter") void handleViewRecoveryWords(); }}1083 />1084 <Show when={viewWordsError()}>1085 <p class="peach-hint peach-hint-error" style="margin:0">{viewWordsError()}</p>1086 </Show>1087 <button1088 class="peach-btn peach-btn--accent peach-btn--sm"1089 style="align-self:flex-start"1090 onClick={() => void handleViewRecoveryWords()}1091 disabled={viewWordsBusy() || !viewWordsPassword()}1092 >1093 {viewWordsBusy() ? "checking…" : "reveal recovery words"}1094 </button>1095 </>1096 }1097 >1098 <div class="peach-recovery-words-grid">1099 <For each={viewedWords()!}>1100 {(word, i) => (1101 <span class="peach-recovery-word"><b>{i() + 1}</b> {word}</span>1102 )}1103 </For>1104 </div>1105 <button1106 class="peach-btn peach-btn--subtle peach-btn--sm"1107 style="align-self:flex-start"1108 onClick={closeViewWords}1109 >done</button>1110 </Show>1111 </div>1112 </Show>11131114 <p class="peach-settings-sub-label">1115 DURESS PASSWORD1116 <Show when={!isPro()}>1117 <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>1118 </Show>1119 </p>1120 <div class="peach-settings-row peach-settings-row--top">1121 <div class="peach-settings-row-main">1122 <span>1123 Duress password1124 <Show when={!isPro()}>1125 <span class="peach-pro-badge">Pro</span>1126 </Show>1127 </span>1128 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">1129 If entered under coercion, shows a decoy empty vault instead of your real one.1130 </span>1131 </div>1132 <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>1133 {duressEnabled() ? "on" : "off"}1134 </div>1135 </div>11361137 <Show when={duressEnabled() && !showDuressSetup()}>1138 <div class="peach-settings-row peach-settings-row--meta">1139 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1140 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.1141 </span>1142 </div>1143 </Show>11441145 <Show when={duressSuccess()}>1146 <div class="peach-settings-row peach-settings-row--meta">1147 <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>1148 </div>1149 </Show>11501151 <Show when={!showDuressSetup()}>1152 <div class="peach-settings-row peach-settings-row--meta">1153 <button1154 class="peach-btn peach-btn--subtle peach-btn--sm"1155 onClick={() => {1156 1157 1158 1159 1160 1161 if (!isPro() && !duressEnabled()) {1162 props.onProGate?.("duress_vault");1163 setActiveCard("pro");1164 return;1165 }1166 setShowDuressSetup(true);1167 setDuressError(null);1168 setDuressSuccess(null);1169 setDuressPassword("");1170 setDuressConfirm("");1171 setDuressMainPassword("");1172 }}1173 >1174 {duressEnabled() ? "Change / disable" : "Set up"}1175 </button>1176 </div>1177 </Show>11781179 <Show when={showDuressSetup()}>1180 <div class="peach-duress-form">1181 <label class="peach-label">main password (to confirm)</label>1182 <input1183 class="peach-settings-input"1184 type="password"1185 placeholder="your current master password"1186 autocomplete="current-password"1187 value={duressMainPassword()}1188 onInput={(e) => setDuressMainPassword(e.currentTarget.value)}1189 />1190 <Show when={!duressEnabled()}>1191 <label class="peach-label">new duress password</label>1192 <input1193 class="peach-settings-input"1194 type="password"1195 placeholder="duress password (min 8 chars)"1196 autocomplete="new-password"1197 value={duressPassword()}1198 onInput={(e) => setDuressPassword(e.currentTarget.value)}1199 />1200 <label class="peach-label">confirm duress password</label>1201 <input1202 class="peach-settings-input"1203 type="password"1204 placeholder="confirm duress password"1205 autocomplete="new-password"1206 value={duressConfirm()}1207 onInput={(e) => setDuressConfirm(e.currentTarget.value)}1208 />1209 </Show>12101211 <Show when={duressError()}>1212 <p class="peach-hint peach-hint-error">{duressError()}</p>1213 </Show>12141215 <div class="peach-duress-actions">1216 <button1217 class="peach-btn peach-btn--subtle peach-btn--sm"1218 onClick={() => { setShowDuressSetup(false); setDuressError(null); }}1219 >1220 cancel1221 </button>1222 <Show when={duressEnabled()}>1223 <button1224 class="peach-btn peach-btn--ghost peach-btn--sm"1225 style="color:var(--peach-breach)"1226 onClick={() => void handleDisableDuress()}1227 disabled={duressSaving()}1228 >1229 {duressSaving() ? "disabling..." : "disable duress"}1230 </button>1231 </Show>1232 <Show when={!duressEnabled()}>1233 <button1234 class="peach-btn peach-btn--accent peach-btn--sm"1235 onClick={() => void handleSetupDuress()}1236 disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}1237 >1238 {duressSaving() ? "saving..." : "save"}1239 </button>1240 </Show>1241 </div>1242 </div>1243 </Show>12441245 <p class="peach-settings-sub-label">1246 CANARY ENTRIES1247 <Show when={!isPro()}>1248 <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>1249 </Show>1250 </p>1251 <div class="peach-settings-row peach-settings-row--top">1252 <div class="peach-settings-row-main">1253 <span>1254 Canary entries1255 <Show when={!isPro()}>1256 <span class="peach-pro-badge">Pro</span>1257 </Show>1258 </span>1259 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1260 Silently pings a webhook when an entry is accessed. Configure canary on any entry in your vault.1261 </span>1262 </div>1263 <span class="peach-settings-value" style="flex-shrink:0">1264 {canaryEntries().length > 0 ? `${canaryEntries().length} active` : "none"}1265 </span>1266 </div>1267 <Show when={canaryEntries().length > 0}>1268 <For each={canaryEntries()}>1269 {(entry) => (1270 <div class="peach-settings-row peach-settings-row--meta">1271 <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0">{entry.title}</span>1272 <button1273 class="peach-btn peach-btn--subtle peach-btn--sm"1274 onClick={() => void handleTestCanary(entry)}1275 disabled={canaryTestingId() === entry.id}1276 style="flex-shrink:0"1277 >1278 {canaryTestingId() === entry.id1279 ? "sending…"1280 : canaryTestResult()?.id === entry.id1281 ? (canaryTestResult()!.ok ? "sent ✓" : "failed ✗")1282 : "test"}1283 </button>1284 </div>1285 )}1286 </For>1287 </Show>12881289 <p class="peach-settings-sub-label">PASSKEYS</p>1290 <div class="peach-settings-row peach-settings-row--top">1291 <div class="peach-settings-row-main">1292 <span>Intercept passkey requests</span>1293 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">1294 Peach can handle passkey sign-in and creation on supported Chromium browsers.1295 </span>1296 </div>1297 <button1298 class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}1299 onClick={() => void handleTogglePasskeys()}1300 aria-label="Toggle passkey interception"1301 disabled={!passkeyState()?.supported || passkeySaving()}1302 >1303 <span class="peach-toggle-knob"></span>1304 </button>1305 </div>1306 <div class="peach-settings-row peach-settings-row--meta">1307 <span>Status</span>1308 <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>1309 </div>1310 <div class="peach-settings-row peach-settings-row--meta">1311 <span>Browser</span>1312 <span class="peach-settings-value">1313 {passkeyState()?.browserLabel ?? "Unknown"}1314 {passkeyState()?.supported ? " ✓" : " (interception unavailable)"}1315 </span>1316 </div>1317 <div class="peach-info-notice" style="margin-top:8px">1318 Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.1319 </div>1320 <Show when={passkeyState()?.conflict}>1321 <div class="peach-settings-row peach-settings-row--meta">1322 <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>1323 </div>1324 </Show>1325 </div>1326 </div>1327 );1328 }13291330 function renderVault() {1331 return (1332 <div class="peach-settings-body">1333 <div class="peach-settings-group">1334 <p class="peach-settings-sub-label">ENTRIES</p>1335 <div class="peach-settings-row peach-settings-row--meta">1336 <span>Entries</span>1337 <span class="peach-settings-value">{props.entriesCount}</span>1338 </div>1339 <div class="peach-settings-row peach-settings-row--meta">1340 <span>Duplicates</span>1341 <Show when={dedupDone() !== null} fallback={1342 <button1343 class="peach-btn peach-btn--subtle peach-btn--sm"1344 onClick={() => void handleCountDuplicates()}1345 disabled={dedupRunning()}1346 >1347 {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}1348 </button>1349 }>1350 <span class="peach-settings-value" style="color:var(--peach-sync-ok)">1351 {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}1352 </span>1353 </Show>1354 </div>1355 <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>1356 <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">1357 <div class="peach-restore-warning">1358 {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.1359 </div>1360 <div style="display:flex;gap:6px">1361 <button1362 class="peach-btn peach-btn--subtle peach-btn--sm"1363 onClick={() => setDedupCount(null)}1364 >1365 cancel1366 </button>1367 <button1368 class="peach-btn peach-btn--accent peach-btn--sm"1369 onClick={() => void handleDeduplicate()}1370 disabled={dedupRunning()}1371 >1372 {dedupRunning() ? "removing..." : "remove duplicates"}1373 </button>1374 </div>1375 </div>1376 </Show>13771378 <p class="peach-settings-sub-label">MAINTENANCE</p>1379 <div class="peach-settings-row peach-settings-row--meta">1380 <span>Trash auto-delete</span>1381 <select1382 class="peach-settings-select"1383 value={String(props.trashRetentionDays)}1384 onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}1385 >1386 <option value="7">7 days</option>1387 <option value="30">30 days</option>1388 <option value="90">90 days</option>1389 <option value="0">Never</option>1390 </select>1391 </div>1392 <div class="peach-settings-row peach-settings-row--meta">1393 <span>Advanced Vault Health</span>1394 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>1395 open1396 </button>1397 </div>1398 <Show when={declinedCardCount() > 0}>1399 <div class="peach-settings-row peach-settings-row--meta">1400 <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>1401 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>1402 Clear1403 </button>1404 </div>1405 </Show>14061407 <p class="peach-settings-sub-label">DATA</p>1408 <div class="peach-settings-row peach-settings-row--meta">1409 <span>Import passwords</span>1410 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>1411 open1412 </button>1413 </div>1414 <div class="peach-settings-row peach-settings-row--meta">1415 <span>Export backup</span>1416 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onExport}>1417 open1418 </button>1419 </div>1420 <div class="peach-settings-row peach-settings-row--meta">1421 <span>1422 Peach Codex1423 <Show when={!isPro()}>1424 <span class="peach-pro-badge">Pro</span>1425 </Show>1426 </span>1427 <button1428 class="peach-btn peach-btn--subtle peach-btn--sm"1429 onClick={() => props.onOpenCodexGenerate?.()}1430 >1431 open1432 </button>1433 </div>1434 {1435}1436 <div class="peach-settings-row peach-settings-row--meta">1437 <span>Restore from Codex</span>1438 <button1439 class="peach-btn peach-btn--subtle peach-btn--sm"1440 onClick={() => props.onOpenCodexRestore?.()}1441 >1442 open1443 </button>1444 </div>14451446 <span class="peach-settings-sub-label">VAULT HISTORY</span>1447 <Show when={historyLoading()}>1448 <p class="peach-settings-hint">Loading…</p>1449 </Show>1450 <Show when={!historyLoading() && historySnapshots().length === 0}>1451 <p class="peach-settings-hint">1452 No snapshots yet. Peach saves automatically before every change —1453 saves, syncs, and imports.1454 </p>1455 </Show>1456 <Show when={!historyLoading() && historySnapshots().length > 0}>1457 <p class="peach-settings-hint">1458 Restoring locks your vault. Unlock with the password that1459 encrypted that snapshot to confirm.1460 </p>1461 <div class="peach-history-list">1462 <For each={historySnapshots().slice(0, historyShowAll() ? undefined : 3)}>1463 {(snap) => (1464 <div class="peach-history-row">1465 <div class="peach-history-meta">1466 <span class="peach-history-time">1467 {formatSnapshotTime(snap.savedAt)}1468 </span>1469 <span class="peach-history-sub">1470 {[1471 snap.entryCount !== null1472 ? `up to ${snap.entryCount} ${snap.entryCount === 1 ? "entry" : "entries"}`1473 : null,1474 reasonLabel(snap.reason),1475 ]1476 .filter(Boolean)1477 .join(" · ")}1478 </span>1479 </div>1480 <button1481 class={`peach-btn peach-btn--subtle peach-btn--sm peach-history-btn${1482 historyRestoring() === snap.id ? " is-busy" : ""1483 }`}1484 onClick={() => void handleRestoreVaultSnapshot(snap.id)}1485 disabled={historyRestoring() !== null}1486 >1487 <Show when={historyRestoring() === snap.id} fallback="Restore">1488 …1489 </Show>1490 </button>1491 </div>1492 )}1493 </For>1494 </div>1495 <Show when={historySnapshots().length > 3}>1496 <button1497 class="peach-settings-advanced-toggle"1498 onClick={() => setHistoryShowAll((v) => !v)}1499 aria-expanded={historyShowAll()}1500 >1501 <span class="peach-settings-hint" style="margin:0">1502 {historyShowAll() ? "show less" : `${historySnapshots().length - 3} older`}1503 </span>1504 <span class="peach-settings-advanced-chevron">1505 {historyShowAll() ? "▲" : "▼"}1506 </span>1507 </button>1508 </Show>1509 <p class="peach-history-footer">1510 {historySnapshots().length} of 10 snapshots1511 </p>1512 </Show>15131514 <button1515 class="peach-settings-advanced-toggle"1516 onClick={() => setShowVaultAdvanced((v) => !v)}1517 aria-expanded={showVaultAdvanced()}1518 >1519 <span class="peach-settings-sub-label" style="margin:0">ADVANCED</span>1520 <span class="peach-settings-advanced-chevron">1521 {showVaultAdvanced() ? "▲" : "▼"}1522 </span>1523 </button>15241525 <Show when={showVaultAdvanced()}>1526 <div class="peach-danger-card">1527 <div class="peach-danger-card-head">1528 <span class="peach-danger-card-glyph" aria-hidden="true">⟳</span>1529 <h3 class="peach-danger-card-title">Replace vault from backup</h3>1530 </div>1531 <p class="peach-danger-card-copy">1532 Wipes the current vault and restores from a backup file.1533 Recoverable only if your backup is good.1534 </p>1535 <button1536 class="peach-danger-card-button"1537 onClick={() => props.onOpenFileImport?.()}1538 >1539 Choose backup file1540 </button>1541 </div>15421543 <Show when={props.onOpenPanic}>1544 <div class="peach-danger-card peach-danger-card--hazard">1545 <div class="peach-danger-card-head">1546 <span class="peach-danger-card-glyph" aria-hidden="true">⚠</span>1547 <h3 class="peach-danger-card-title">Destroy vault</h3>1548 </div>1549 <p class="peach-danger-card-copy">1550 Permanently wipes every entry, setting, and license on this1551 device. There is no recovery.1552 </p>1553 <button1554 class="peach-danger-card-button"1555 onClick={() => props.onOpenPanic?.()}1556 >1557 Begin destruction1558 </button>1559 </div>1560 </Show>1561 </Show>1562 </div>1563 </div>1564 );1565 }15661567 function renderSync() {1568 return (1569 <div class="peach-settings-body">1570 <div class="peach-settings-group">1571 <div class="peach-settings-row peach-settings-row--meta">1572 <span>Status</span>1573 <span class="peach-settings-value" style="display:inline-flex;align-items:center;gap:8px">1574 {isPro() ? "Pro" : "Free"}1575 <span class={`stk-pip ${isPro() ? "is-on" : ""}`} aria-hidden="true" />1576 </span>1577 </div>15781579 {}1580 <p class="peach-settings-sub-label" style="margin-top:10px">PAIR A DEVICE</p>1581 {15821583}1584 <button1585 class="peach-sync-pair-option"1586 onClick={() => props.onOpenSyncPairing?.("code")}1587 >1588 <span class="peach-sync-pair-option-icon" aria-hidden="true">1589 <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">1590 <rect x="6" y="2.5" width="8" height="15" rx="1.6" />1591 <path d="M9 15 L11 15" />1592 </svg>1593 </span>1594 <span class="peach-sync-pair-option-main">1595 <span class="peach-sync-pair-option-title">Show a code</span>1596 <span class="peach-sync-pair-option-sub">This browser shows a code; scan it with Peach on your phone</span>1597 </span>1598 <span class="peach-sync-pair-option-chevron" aria-hidden="true">›</span>1599 </button>1600 <Show when={!isPro()}>1601 <p class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2);margin:8px 2px 0">1602 At least one device needs Peach Pro — pair with a Pro device and it carries over to here.1603 </p>1604 </Show>16051606 {1607160816091610}1611 <p class="peach-settings-sub-label" style="margin-top:12px">HOW THIS VAULT SYNCS</p>1612 <SyncModeSelector />16131614 {16151616}1617 <div1618 class="peach-settings-row peach-settings-row--meta"1619 style="align-items:center;gap:8px"1620 role="status"1621 aria-label={syncLightLabel()}1622 title={syncLightLabel()}1623 >1624 <span class={`stk-pip stk-pip--sync is-${syncLight()}`} aria-hidden="true" />1625 <span class="peach-settings-value" style="color:var(--peach-t2);line-height:1.5;flex:1;min-width:0;overflow-wrap:anywhere">1626 {syncFlash() ?? lastSyncLine()}1627 </span>1628 </div>16291630 {}1631 <Show when={pairedDevices().length > 0}>1632 <p class="peach-settings-sub-label" style="margin-top:12px">PAIRED DEVICES</p>1633 <For each={pairedDevices()}>1634 {(d) => (1635 <div class="peach-settings-row peach-settings-row--top">1636 <div class="peach-settings-row-main">1637 <span>{d.deviceName ?? "Unnamed device"}</span>1638 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1639 {deviceTypeLabel(d.deviceType)} · {syncRelativeTime(d.lastSyncedAt)}{isSyncStale(d) ? " · not seen recently" : ""}1640 </span>1641 </div>1642 <Show1643 when={confirmUnpair() === d.deviceId}1644 fallback={1645 <button1646 class="peach-btn peach-btn--subtle peach-btn--sm"1647 onClick={() => setConfirmUnpair(d.deviceId)}1648 >remove</button>1649 }1650 >1651 {16521653165416551656}1657 <div class="peach-unpair-confirm">1658 <p class="peach-unpair-confirm-note">1659 Stops this device receiving future changes. It doesn't1660 lock it out of what it already has — if it's lost or1661 stolen, also change your master password and generate1662 new recovery words.1663 </p>1664 <div style="display:flex;gap:6px;justify-content:flex-end">1665 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => setConfirmUnpair(null)}>cancel</button>1666 <button1667 class="peach-btn peach-btn--sm"1668 style="background:var(--peach-breach);border-color:var(--peach-breach)"1669 onClick={() => void unpairDevice(d.deviceId)}1670 >remove</button>1671 </div>1672 </div>1673 </Show>1674 </div>1675 )}1676 </For>1677 <div class="peach-settings-row peach-settings-row--meta">1678 <span class="peach-settings-value" style="color:var(--peach-t2)">1679 {(syncSummary().entriesSent ?? 0) + (syncSummary().entriesReceived ?? 0) > 01680 ? `${syncSummary().entriesSent ?? 0} sent · ${syncSummary().entriesReceived ?? 0} received`1681 : ""}1682 </span>1683 <button1684 class="peach-btn peach-btn--subtle peach-btn--sm"1685 disabled={syncStatus() === "syncing"}1686 onClick={() => void syncNow()}1687 >{syncStatus() === "syncing" ? "syncing…" : "sync now"}</button>1688 </div>1689 </Show>1690 {}1691 <Show when={pendingVouched().length > 0}>1692 <p class="peach-settings-sub-label">1693 PENDING DEVICES1694 <span class="peach-pro-badge" style="background:#C8A840;color:#1a1a1a">{pendingVouched().length}</span>1695 </p>1696 <For each={pendingVouched()}>1697 {(dev) => (1698 <div class="peach-settings-row peach-settings-row--top">1699 <div class="peach-settings-row-main">1700 <span>{dev.deviceName}</span>1701 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1702 Was added by another of your devices. Allow it to sync with this vault?1703 </span>1704 </div>1705 <div style="display:flex;gap:6px;flex-shrink:0">1706 <button1707 class="peach-btn peach-btn--sm"1708 disabled={vouchedBusy() === dev.deviceId}1709 onClick={() => void confirmVouched(dev.deviceId)}1710 >Allow</button>1711 <button1712 class="peach-btn peach-btn--subtle peach-btn--sm"1713 disabled={vouchedBusy() === dev.deviceId}1714 onClick={() => void rejectVouched(dev.deviceId)}1715 >Reject</button>1716 </div>1717 </div>1718 )}1719 </For>1720 </Show>17211722 {}1723 <p class="peach-settings-sub-label" style="margin-top:12px">RECOVERY</p>1724 <Show1725 when={rotateConfirmOpen()}1726 fallback={1727 <div class="peach-settings-row peach-settings-row--top">1728 <div class="peach-settings-row-main">1729 <span>Rotate sync identity</span>1730 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1731 Use this if a paired device was lost or stolen. Re-keys the vault so the old device's key is useless.1732 </span>1733 </div>1734 <button1735 class="peach-btn peach-btn--subtle peach-btn--sm"1736 style="color:var(--peach-breach);border-color:color-mix(in srgb,var(--peach-breach) 40%,transparent)"1737 onClick={() => { setRotateError(null); setRotateConfirmOpen(true); }}1738 >rotate</button>1739 </div>1740 }1741 >1742 <div class="peach-info-notice" style="margin-top:8px;border-color:color-mix(in srgb,var(--peach-breach) 40%,transparent)">1743 This will disconnect all paired devices — they will need to pair again — and your 12 recovery words will change. You'll be shown the new words next.1744 <Show when={rotateError()}>1745 <div style="color:var(--peach-breach);margin-top:6px">{rotateError()}</div>1746 </Show>1747 <div style="display:flex;gap:6px;margin-top:10px">1748 <button1749 class="peach-btn peach-btn--subtle peach-btn--sm"1750 disabled={rotating()}1751 onClick={() => setRotateConfirmOpen(false)}1752 >Cancel</button>1753 <button1754 class="peach-btn peach-btn--sm"1755 style="background:var(--peach-breach);border-color:var(--peach-breach)"1756 disabled={rotating()}1757 onClick={() => void doRotateSyncIdentity()}1758 >{rotating() ? "Rotating…" : "Rotate sync identity"}</button>1759 </div>1760 </div>1761 </Show>1762 </div>1763 </div>1764 );1765 }17661767 function renderAppearance() {1768 const sizeOptions: Array<{ id: "medium" | "large"; label: string }> = [1769 { id: "medium", label: "Default" },1770 { id: "large", label: "Large" },1771 ];1772 return (1773 <div class="peach-settings-body">1774 <div class="peach-settings-group">1775 <p class="peach-settings-sub-label">THEME</p>1776 <div class="peach-settings-row peach-settings-row--meta">1777 <span>Theme</span>1778 <button1779 class="peach-btn peach-btn--subtle peach-btn--sm"1780 onClick={() =>1781 props.onThemeChange(props.theme === "dark" ? "light" : "dark")1782 }1783 >1784 {props.theme === "dark" ? "Switch to light" : "Switch to dark"}1785 </button>1786 </div>17871788 <p class="peach-settings-sub-label">ACCENT</p>1789 <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">1790 <span>Accent</span>1791 <div class="peach-accent-picker">1792 <For each={ACCENT_COLORS}>1793 {(color) => (1794 <button1795 class="peach-accent-swatch peach-swatch"1796 classList={{ "is-active": props.accentColor === color.name }}1797 style={{ background: color.hex }}1798 onClick={() => void props.onAccentChange(color.name)}1799 aria-label={color.name}1800 title={color.name}1801 />1802 )}1803 </For>1804 </div>1805 </div>18061807 <p class="peach-settings-sub-label">TEXT SIZE</p>1808 <div class="peach-settings-row peach-settings-row--meta">1809 <span>Size</span>1810 <div class="peach-pill-group">1811 <For each={sizeOptions}>1812 {(opt) => (1813 <button1814 class="peach-btn peach-btn--ghost peach-btn--sm"1815 classList={{ "is-active": props.textSize === opt.id }}1816 onClick={() => void props.onTextSizeChange(opt.id)}1817 >1818 {opt.label}1819 </button>1820 )}1821 </For>1822 </div>1823 </div>18241825 <p class="peach-settings-sub-label">MOTION</p>1826 <div class="peach-settings-row peach-settings-row--meta">1827 <span>Reduce motion</span>1828 <button1829 class="peach-btn peach-btn--subtle peach-btn--sm"1830 onClick={() => void props.onReduceMotionChange(!props.reduceMotion)}1831 >1832 {props.reduceMotion ? "On" : "Off"}1833 </button>1834 </div>1835 </div>1836 </div>1837 );1838 }18391840 function renderShortcuts() {1841 const [capturingAction, setCapturingAction] = createSignal<ShortcutAction | null>(null);1842 const [conflictMsg, setConflictMsg] = createSignal<string | null>(null);18431844 function startCapture(action: ShortcutAction) {1845 setConflictMsg(null);1846 setCapturingAction(action);1847 }1848 function cancelCapture() {1849 setCapturingAction(null);1850 setConflictMsg(null);1851 }1852 function commitBinding(action: ShortcutAction, binding: Keybinding) {1853 const conflict = findConflict(props.shortcuts, binding, action);1854 if (conflict) {1855 const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict);1856 setConflictMsg(`Already bound to "${other?.label ?? conflict}"`);1857 return;1858 }1859 const next = { ...props.shortcuts, [action]: binding };1860 setCapturingAction(null);1861 setConflictMsg(null);1862 void props.onShortcutsChange(next);1863 }1864 function resetAction(action: ShortcutAction) {1865 const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] };1866 void props.onShortcutsChange(next);1867 }1868 function resetAll() {1869 void props.onShortcutsChange({ ...DEFAULT_BINDINGS });1870 }18711872 1873 1874 function handleCaptureKey(e: KeyboardEvent) {1875 e.preventDefault();1876 e.stopPropagation();1877 if (e.key === "Escape") { cancelCapture(); return; }1878 const binding = keybindingFromEvent(e);1879 if (!binding) return; 1880 const action = capturingAction();1881 if (action) commitBinding(action, binding);1882 }18831884 return (1885 <div class="peach-settings-body">1886 <div class="peach-settings-group peach-shortcuts-group">1887 <p class="peach-settings-sub-label">ENTRY VIEW</p>1888 <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry")}>1889 {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1890 </For>1891 </div>18921893 <div class="peach-settings-group peach-shortcuts-group">1894 <p class="peach-settings-sub-label">ANYWHERE IN POPUP</p>1895 <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global")}>1896 {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1897 </For>1898 </div>18991900 <Show when={conflictMsg()}>1901 <div class="peach-shortcuts-conflict">{conflictMsg()}</div>1902 </Show>19031904 <div class="peach-settings-group" style="display:flex;flex-direction:column;gap:8px">1905 <div class="peach-settings-row peach-settings-row--meta">1906 <span>Inline autofill on page</span>1907 <a1908 class="peach-settings-link"1909 href="chrome://extensions/shortcuts"1910 onClick={(e) => {1911 e.preventDefault();1912 chrome.tabs.create({ url: "chrome://extensions/shortcuts" });1913 }}1914 >1915 ⌘⇧P — customize ↗1916 </a>1917 </div>1918 <button1919 class="peach-btn peach-btn--subtle peach-btn--sm"1920 style="align-self:flex-start"1921 onClick={resetAll}1922 >1923 Reset all to defaults1924 </button>1925 </div>1926 </div>1927 );19281929 function renderRow(1930 desc: typeof SHORTCUT_DESCRIPTORS[number],1931 capturing: () => ShortcutAction | null,1932 onStart: (a: ShortcutAction) => void,1933 onCancel: () => void,1934 onReset: (a: ShortcutAction) => void,1935 onKey: (e: KeyboardEvent) => void,1936 ) {1937 const isActive = () => capturing() === desc.action;1938 const binding = () => props.shortcuts[desc.action];1939 const isCustom = () => {1940 const b = binding();1941 const d = DEFAULT_BINDINGS[desc.action];1942 return b.key !== d.key1943 || Boolean(b.ctrl) !== Boolean(d.ctrl)1944 || Boolean(b.meta) !== Boolean(d.meta)1945 || Boolean(b.shift) !== Boolean(d.shift)1946 || Boolean(b.alt) !== Boolean(d.alt);1947 };1948 return (1949 <div class="peach-shortcut-row">1950 <span class="peach-shortcut-row-label">{desc.label}</span>1951 <button1952 class="peach-shortcut-chip"1953 classList={{ "is-capturing": isActive(), "is-custom": isCustom() }}1954 onClick={() => isActive() ? onCancel() : onStart(desc.action)}1955 onKeyDown={(e) => { if (isActive()) onKey(e); }}1956 ref={(el) => {1957 1958 if (el) {1959 queueMicrotask(() => { if (isActive()) el.focus(); });1960 }1961 }}1962 >1963 {isActive() ? "Press a key…" : formatKeybinding(binding())}1964 </button>1965 <Show when={isCustom() && !isActive()}>1966 <button1967 class="peach-shortcut-reset"1968 title="Reset to default"1969 onClick={() => onReset(desc.action)}1970 >1971 ↺1972 </button>1973 </Show>1974 </div>1975 );1976 }1977 }19781979 const PRO_FEATURES: Array<{1980 feature: ProFeature;1981 name: string;1982 desc: string;1983 }> = [1984 { feature: "sync", name: "Peach Sync", desc: "Encrypted · peer-to-peer" },1985 { feature: "codex", name: "Peach Codex", desc: "Restore from paper" },1986 { feature: "passkeys", name: "Passkeys", desc: "Zero-knowledge WebAuthn" },1987 { feature: "advanced_health", name: "Advanced Vault Health", desc: "Reuse · age · exposure" },1988 ];19891990 const PRO_SECURITY_FEATURES: Array<{1991 feature: ProFeature;1992 name: string;1993 desc: string;1994 }> = [1995 { feature: "canary_entry", name: "Canary entries", desc: "Detects vault compromise" },1996 { feature: "duress_vault", name: "Duress vault", desc: "Decoy under coercion" },1997 ];19981999 const [licenseKey, setLicenseKey] = createSignal<string | null>(null);2000 const [showKey, setShowKey] = createSignal(false);20012002 const [deactivating, setDeactivating] = createSignal(false);20032004 async function handleDeactivateLicense() {2005 setDeactivating(true);2006 await sendMessage({ type: "DEACTIVATE_LICENSE" });2007 setLicenseInfo(null);2008 await props.onLicenseChange?.();2009 setDeactivating(false);2010 }20112012 async function handleTestCanary(entry: {id: string; title: string; canary: Canary}) {2013 setCanaryTestingId(entry.id);2014 setCanaryTestResult(null);2015 const res = await sendMessage({2016 type: "FIRE_CANARY_PING",2017 payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger: "test" },2018 }) as { ok: boolean };2019 setCanaryTestResult({ id: entry.id, ok: res.ok });2020 setCanaryTestingId(null);2021 setTimeout(() => setCanaryTestResult((r) => r?.id === entry.id ? null : r), 3000);2022 }20232024 function renderLicense() {2025 async function onActivate() {2026 await handleActivateLicense();2027 if ((licenseInfo()?.valid ?? false) && licenseInfo()?.plan === "pro") {2028 await props.onLicenseChange?.();2029 }2030 }20312032 return (2033 <div class="peach-settings-body">2034 <Show2035 when={isPro()}2036 fallback={2037 <div class="peach-pro-upgrade">2038 <p class="peach-pro-eyebrow">PEACH PRO · ONE-TIME</p>20392040 <h2 class="peach-pro-headline">2041 Every<em>thing,</em><br />every<em>where.</em>2042 </h2>20432044 <p class="peach-pro-subtitle">2045 One key unlocks every Pro feature — for the lifetime of the app.2046 </p>20472048 <div class="peach-pro-grid">2049 <For each={PRO_FEATURES}>2050 {(item, i) => (2051 <div2052 class="peach-pro-grid-item"2053 classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}2054 >2055 <span class="peach-pro-grid-num">{String(i() + 1).padStart(2, "0")}</span>2056 <span class="peach-pro-grid-name">{item.name}</span>2057 <span class="peach-pro-grid-desc">{item.desc}</span>2058 </div>2059 )}2060 </For>2061 </div>20622063 <p class="peach-pro-section-label">ADVANCED SECURITY</p>2064 <div class="peach-pro-grid">2065 <For each={PRO_SECURITY_FEATURES}>2066 {(item, i) => (2067 <div2068 class="peach-pro-grid-item"2069 classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}2070 >2071 <span class="peach-pro-grid-num">{String(PRO_FEATURES.length + i() + 1).padStart(2, "0")}</span>2072 <span class="peach-pro-grid-name">{item.name}</span>2073 <span class="peach-pro-grid-desc">{item.desc}</span>2074 </div>2075 )}2076 </For>2077 </div>20782079 <div class="peach-pro-upgrade-actions">2080 <p class="peach-pro-pricing">2081 $39 <span class="peach-pro-pricing-sub">once · yours forever</span>2082 </p>2083 <a2084 href="https://buy.polar.sh/polar_cl_IXSFIPojx67rcOTksn8LkH5mnU3osPHtYqBIl1eyuIz"2085 target="_blank"2086 rel="noopener noreferrer"2087 class="peach-pro-upgrade-cta"2088 >2089 Get Peach Pro2090 </a>2091 <div class="peach-pro-key-divider"><span>ALREADY HAVE A KEY</span></div>2092 <div class="peach-pro-key-inline">2093 <input2094 class="peach-pro-key-inline-input"2095 type="text"2096 placeholder="PEACH-XXXX-XXXX-XXXX"2097 value={licenseKeyInput()}2098 onInput={(e) => setLicenseKeyInput(e.currentTarget.value)}2099 onKeyDown={(e) => { if (e.key === "Enter") void onActivate(); }}2100 autocomplete="off"2101 spellcheck={false}2102 />2103 <button2104 class="peach-pro-key-apply"2105 onClick={() => void onActivate()}2106 disabled={activatingLicense() || !licenseKeyInput().trim()}2107 >2108 {activatingLicense() ? "…" : "APPLY"}2109 </button>2110 </div>2111 <Show when={licenseError()}>2112 <p class="peach-pro-key-error">{licenseError()}</p>2113 </Show>2114 </div>2115 </div>2116 }2117 >2118 <div class="peach-pro-active">2119 <div class="peach-pro-active-hero">2120 <button2121 class="peach-pro-active-mark-wrap"2122 type="button"2123 aria-label="Celebrate"2124 onClick={handleProCelebrationClick}2125 >2126 <PeachMark class="peach-pro-active-mark" />2127 <Show when={justActivated()}>2128 <div class="peach-pro-sparks" aria-hidden="true">2129 <span class="peach-pro-spark" style="--spark-x:0px;--spark-y:-54px;--spark-delay:0ms">✦</span>2130 <span class="peach-pro-spark" style="--spark-x:38px;--spark-y:-38px;--spark-delay:50ms">✦</span>2131 <span class="peach-pro-spark" style="--spark-x:54px;--spark-y:0px;--spark-delay:110ms">·</span>2132 <span class="peach-pro-spark" style="--spark-x:38px;--spark-y:38px;--spark-delay:30ms">✦</span>2133 <span class="peach-pro-spark" style="--spark-x:0px;--spark-y:54px;--spark-delay:80ms">·</span>2134 <span class="peach-pro-spark" style="--spark-x:-38px;--spark-y:38px;--spark-delay:130ms">✦</span>2135 <span class="peach-pro-spark" style="--spark-x:-54px;--spark-y:0px;--spark-delay:55ms">·</span>2136 <span class="peach-pro-spark" style="--spark-x:-38px;--spark-y:-38px;--spark-delay:95ms">✦</span>2137 </div>2138 </Show>2139 </button>2140 <h2 class="peach-pro-active-title">Peach Pro</h2>2141 <p class="peach-pro-active-since">{formatActivatedSince(licenseInfo()?.issued)}</p>2142 <Show when={justActivated()}>2143 <p class="peach-pro-thankyou-text" aria-live="polite">thank you ✦</p>2144 </Show>2145 </div>21462147 <Show when={licenseKey()}>2148 <div class="peach-settings-license">2149 <span class="peach-settings-label">LICENSE KEY</span>2150 <div class="peach-settings-license-row">2151 <Show2152 when={showKey()}2153 fallback={2154 <span class="peach-settings-license-key peach-settings-license-key--masked" aria-label="License key hidden">2155 ••••••••••••••••••••••••••••2156 </span>2157 }2158 >2159 <span class="peach-settings-license-key">{licenseKey()!}</span>2160 </Show>2161 <div class="peach-settings-license-actions">2162 <button2163 class="peach-settings-license-copy"2164 title="Copy license key"2165 onClick={() => copyLicenseKey(licenseKey()!)}2166 >2167 {copied() ? "✓" : "copy"}2168 </button>2169 <button2170 class="peach-settings-license-toggle"2171 aria-label={showKey() ? "Hide license key" : "Reveal license key"}2172 title={showKey() ? "Hide" : "Reveal"}2173 onClick={() => setShowKey(v => !v)}2174 >2175 <Show2176 when={showKey()}2177 fallback={2178 <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">2179 <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />2180 <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />2181 </svg>2182 }2183 >2184 <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">2185 <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />2186 <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />2187 <line x1="2" y1="2" x2="12" y2="12" />2188 </svg>2189 </Show>2190 </button>2191 </div>2192 </div>2193 </div>2194 </Show>21952196 <div class="peach-pro-active-features">2197 <For each={[...PRO_FEATURES, ...PRO_SECURITY_FEATURES]}>2198 {(item) => (2199 <div class="peach-pro-active-feature">2200 <span class="peach-pro-active-feature-mark">✦</span>2201 <span class="peach-pro-active-feature-name">{item.name}</span>2202 </div>2203 )}2204 </For>2205 </div>22062207 <button2208 class="peach-pro-deactivate"2209 onClick={() => void handleDeactivateLicense()}2210 disabled={deactivating()}2211 >2212 {deactivating() ? "Deactivating…" : "Deactivate"}2213 </button>2214 </div>2215 </Show>2216 </div>2217 );2218 }22192220 function renderFeedback() {2221 return (2222 <div class="peach-settings-body">2223 <div class="peach-settings-group peach-feedback-group">2224 <Show2225 when={feedbackState() === 'sent'}2226 fallback={2227 <>2228 <p class="peach-feedback-intro">2229 Help us improve Peach. Every report goes directly to our inbox.2230 </p>22312232 <p class="peach-settings-sub-label">TYPE</p>2233 <div class="peach-feedback-chips">2234 <For each={FEEDBACK_CATEGORIES}>2235 {(cat) => {2236 const selected = () => fbCategory() === cat;2237 return (2238 <button2239 type="button"2240 class={`peach-feedback-chip ${selected() ? "is-active" : ""}`}2241 aria-pressed={selected()}2242 onClick={() => setFbCategory(cat)}2243 >2244 {cat}2245 </button>2246 );2247 }}2248 </For>2249 </div>22502251 <p class="peach-settings-sub-label">MESSAGE</p>2252 <textarea2253 class="peach-feedback-textarea"2254 placeholder="Describe the issue or idea..."2255 maxLength={MAX_FEEDBACK_MESSAGE_LENGTH}2256 value={fbMessage()}2257 onInput={(e) => setFbMessage(e.currentTarget.value)}2258 />22592260 <p class="peach-feedback-meta">2261 Version {version} · will be included automatically2262 </p>22632264 <button2265 type="button"2266 class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"2267 disabled={!fbMessage().trim() || feedbackState() === 'sending'}2268 onClick={() => void handleSubmitFeedback()}2269 >2270 <Switch>2271 <Match when={feedbackState() === 'idle'}>Send</Match>2272 <Match when={feedbackState() === 'sending'}>Sending...</Match>2273 <Match when={feedbackState() === 'error'}>Failed - try again</Match>2274 </Switch>2275 </button>22762277 <p class="peach-feedback-anon">2278 Sent anonymously · no account required2279 </p>2280 </>2281 }2282 >2283 <div class="peach-feedback-thanks" role="status" aria-live="polite">2284 <p class="peach-feedback-thanks-title">Thank you</p>2285 <p>2286 Your note is in the queue. Someone will process it soon.2287 </p>2288 </div>2289 </Show>2290 </div>2291 </div>2292 );2293 }22942295 const bodies: StackBodies = {2296 protection: renderSecurity(),2297 vault: renderVault(),2298 sync: renderSync(),2299 appearance: renderAppearance(),2300 shortcuts: renderShortcuts(),2301 pro: renderLicense(),2302 feedback: renderFeedback(),2303 };23042305 return (2306 <Show2307 when={showNewRecoveryWords()}2308 fallback={2309 <div2310 class="peach-overlay-view peach-settings-overlay"2311 classList={{ "ov-enter-right": !props.isExiting, "ov-exit-right": !!props.isExiting }}2312 style="display:flex;flex-direction:column"2313 >2314 <StackDeck2315 active={activeCard()}2316 onActiveChange={setActiveCard}2317 onBack={props.onBack}2318 cards={cards()}2319 bodies={bodies}2320 version={version}2321 isFanning={isFanning()}2322 celebratingCardId={justActivated() ? "pro" : null}2323 isPro={isPro()}2324 />2325 </div>2326 }2327 >2328 <RecoveryWordDisplayView onDone={() => setShowNewRecoveryWords(false)} />2329 </Show>2330 );2331}