1import { createEffect, createMemo, createSignal, For, Match, onCleanup, onMount, Show, Switch } from "solid-js";2import { sendMessage } from "../App";3import { PeachMark } from "../components/PeachMark";4import { SyncModeSelector } from "../components/SyncModeSelector";5import { StackDeck } from "../components/settings/StackDeck";6import type { CardId, StackBodies, StackCardSpec } from "../components/settings/types";7import { RecoveryWordDisplayView } from "./RecoveryWordDisplay";8import type { Canary } from "../../shared/types";9import { ACCENT_COLORS } from "../../shared/types";10import type {11 AccentColorName,12 PasskeyProviderState,13 ProFeature,14 VaultSnapshot,15} from "../../shared/types";16import type { PairedDevice } from "../../shared/sync-types";17import {18 describeSyncOutcome,19 syncIndicator,20 syncIndicatorLabel,21 type SyncOutcomeSummary,22} from "../../shared/sync-outcome";23import {24 DEFAULT_BINDINGS,25 findConflict,26 formatKeybinding,27 keybindingFromEvent,28 SHORTCUT_DESCRIPTORS,29 type Keybinding,30 type ShortcutAction,31} from "../../shared/shortcuts";32import { isNativePlatform, nativePlugin } from "../../shared/native";33import { WEB3FORMS_KEY } from "../../shared/config";3435interface LicenseInfo {36 valid: boolean;37 plan: "free" | "pro";38 issued?: string;39}4041interface SettingsProps {42 entriesCount: number;43 breachMonitoring: boolean;44 lockOnClose: boolean;45 autoLockMinutes: number;46 trashRetentionDays: number;47 theme: "dark" | "light";48 accentColor: AccentColorName;49 textSize: "small" | "medium" | "large";50 reduceMotion: boolean;51 isPro?: boolean;52 initialSection?: string | null;53 upgradeHighlight?: ProFeature | null;54 onBreachMonitoringChange: (enabled: boolean) => void | Promise<void>;55 onLockOnCloseChange: (enabled: boolean) => void | Promise<void>;56 onAutoLockMinutesChange: (minutes: number) => void | Promise<void>;57 onTrashRetentionChange: (days: number) => void | Promise<void>;58 onThemeChange: (theme: "dark" | "light") => void;59 onAccentChange: (name: AccentColorName) => void | Promise<void>;60 onTextSizeChange: (size: "small" | "medium" | "large") => void | Promise<void>;61 onReduceMotionChange: (enabled: boolean) => void | Promise<void>;62 shortcuts: Record<ShortcutAction, Keybinding>;63 onShortcutsChange: (active: Record<ShortcutAction, Keybinding>) => void | Promise<void>;64 onLicenseChange?: () => void | Promise<void>;65 isExiting?: boolean;66 onBack: () => void;67 onImport: () => void;68 onExport: () => void;69 onBreachCheckDone: () => void;70 onOpenHealth?: () => void;71 onOpenSyncPairing?: (method?: "code") => void;72 onOpenFileImport?: () => void;73 onOpenPanic?: () => void;74 onOpenCodexGenerate?: () => void;75 onOpenCodexRestore?: () => void;76 777879 onProGate?: (feature: ProFeature) => void;80 onCardChange?: (id: CardId) => void;81}8283const FEEDBACK_CATEGORIES = ["Bug report", "Feature request", "Question", "Other"];84const FEEDBACK_DATA_PERMISSION = "technicalAndInteraction";85const MAX_FEEDBACK_MESSAGE_LENGTH = 4000;86const PRO_CELEBRATION_CARD_STEP_MS = 150;87const PRO_CELEBRATION_THANK_YOU_MS = 3200;8889function sanitizeFeedbackMessage(value: string): string {90 return value91 .trim()92 .slice(0, MAX_FEEDBACK_MESSAGE_LENGTH)93 94 .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "")95 .replace(/&/g, "&")96 .replace(/</g, "<")97 .replace(/>/g, ">")98 .replace(/"/g, """)99 .replace(/'/g, "'");100}101102type DataCollectionPermissionApi = {103 permissions?: {104 getAll?: () => Promise<{ data_collection?: string[] }>;105 request?: (permissions: { data_collection: string[] }) => Promise<boolean>;106 };107};108109async function requestFeedbackDataPermission(): Promise<boolean> {110 const api = ((globalThis as { browser?: DataCollectionPermissionApi }).browser ??111 (typeof chrome !== "undefined" ? chrome : undefined)) as DataCollectionPermissionApi | undefined;112 if (!api?.permissions?.getAll) return true;113114 try {115 const permissions = await api.permissions.getAll();116 if (!("data_collection" in permissions)) return true;117 if (permissions.data_collection?.includes(FEEDBACK_DATA_PERMISSION)) return true;118 return await (api.permissions.request?.({ data_collection: [FEEDBACK_DATA_PERMISSION] }) ?? Promise.resolve(false));119 } catch {120 return false;121 }122}123124function formatActivatedSince(issued: string | undefined): string {125 if (!issued) return "Active";126 try {127 const d = new Date(issued);128 if (Number.isNaN(d.getTime())) return "Active";129 return `Active since ${d.toLocaleDateString(undefined, { month: "long", year: "numeric" })}`;130 } catch {131 return "Active";132 }133}134135function initialSectionToCard(value: string | null | undefined): CardId | null {136 if (!value) return null;137 if (value === "security") return "protection";138 if (value === "license") return "pro";139 if (140 value === "protection" ||141 value === "vault" ||142 value === "sync" ||143 value === "appearance" ||144 value === "shortcuts" ||145 value === "pro" ||146 value === "feedback"147 ) {148 return value;149 }150 return null;151}152153export function SettingsView(props: SettingsProps) {154 const manifest = chrome.runtime.getManifest();155 const version = manifest.version;156 const initial = props.initialSection;157 const [activeCard, setActiveCard] = createSignal<CardId>(158 props.upgradeHighlight ? "pro" : initialSectionToCard(initial) ?? "pro"159 );160161 createEffect(() => { props.onCardChange?.(activeCard()); });162163 const [justActivated, setJustActivated] = createSignal(false);164 const [isFanning, setIsFanning] = createSignal(false);165166 function triggerCelebration() {167 if (isFanning()) return;168 setJustActivated(false);169 setIsFanning(true);170 setActiveCard("pro");171 const fanOrder: CardId[] = ["protection", "vault", "sync", "appearance", "shortcuts", "feedback"];172 fanOrder.forEach((id, i) => {173 window.setTimeout(() => setActiveCard(id), (i + 1) * PRO_CELEBRATION_CARD_STEP_MS);174 });175 window.setTimeout(() => {176 setActiveCard("pro");177 setIsFanning(false);178 setJustActivated(true);179 window.setTimeout(() => setJustActivated(false), PRO_CELEBRATION_THANK_YOU_MS);180 }, (fanOrder.length + 1) * PRO_CELEBRATION_CARD_STEP_MS);181 }182183 function handleProCelebrationClick(e: MouseEvent) {184 e.stopPropagation();185 triggerCelebration();186 }187188 const [checkingBreaches, setCheckingBreaches] = createSignal(false);189 const [breachResult, setBreachResult] = createSignal<string | null>(null);190 const [licenseInfo, setLicenseInfo] = createSignal<LicenseInfo | null>(null);191 const [licenseKeyInput, setLicenseKeyInput] = createSignal("");192 const [licenseError, setLicenseError] = createSignal<string | null>(null);193 const [, setLicenseSuccess] = createSignal<string | null>(null);194 const [activatingLicense, setActivatingLicense] = createSignal(false);195196 const isPro = () => props.isPro ?? false;197 const cards = createMemo<StackCardSpec[]>(() => [198 {199 id: "pro",200 num: "I · FOREVER",201 title: "Peach Pro",202 lede: isPro() ? "active · paid once, yours forever" : "$39 once · yours forever",203 bgVar: "--stack-pro-bg",204 fgVar: "--stack-pro-fg",205 icon: "spark",206 onIconClick: isPro() ? triggerCelebration : undefined,207 },208 {209 id: "protection",210 num: "II · PROTECT",211 title: "Protection",212 lede: `${props.breachMonitoring ? "breaches watched" : "breach watch off"} · ${props.lockOnClose ? "locks on close" : "manual lock"}`,213 bgVar: "--stack-protect-bg",214 fgVar: "--stack-protect-fg",215 icon: "shield",216 },217 {218 id: "vault",219 num: "III · KEEP",220 title: "Vault",221 lede: `${props.entriesCount} ${props.entriesCount === 1 ? "entry" : "entries"} · backups, history, cleanup`,222 bgVar: "--stack-vault-bg",223 fgVar: "--stack-vault-fg",224 icon: "lock",225 },226 {227 id: "sync",228 num: "IV · ECHO",229 title: "Sync",230 lede: isPro() ? "your devices, one vault" : "Pro · encrypted device sync",231 bgVar: "--stack-sync-bg",232 fgVar: "--stack-sync-fg",233 icon: "sync",234 },235 {236 id: "appearance",237 num: "V · DRESS",238 title: "Appearance",239 lede: `${props.theme} · ${props.accentColor} · ${props.textSize}`,240 bgVar: "--stack-appear-bg",241 fgVar: "--stack-appear-fg",242 icon: "sun",243 },244 {245 id: "shortcuts",246 num: "VI · KEYS",247 title: "Shortcuts",248 lede: `${SHORTCUT_DESCRIPTORS.length} bindings · what your fingers know`,249 bgVar: "--stack-shortcut-bg",250 fgVar: "--stack-shortcut-fg",251 icon: "keys",252 },253 {254 id: "feedback",255 num: "VII · ECHO",256 title: "Feedback",257 lede: `version ${version} · tell us what would make it better`,258 bgVar: "--stack-feedback-bg",259 fgVar: "--stack-feedback-fg",260 icon: "bubble",261 },262 ]);263 const [biometricSupported, setBiometricSupported] = createSignal(false);264 const [biometricStored, setBiometricStored] = createSignal(false);265 const [biometricBusy, setBiometricBusy] = createSignal(false);266 const [biometricError, setBiometricError] = createSignal<string | null>(null);267 const [passkeyState, setPasskeyState] = createSignal<PasskeyProviderState | null>(null);268 const [passkeySaving, setPasskeySaving] = createSignal(false);269270 271 272 interface PendingVouched { deviceId: string; deviceName: string }273 const [pendingVouched, setPendingVouched] = createSignal<PendingVouched[]>([]);274 const [vouchedBusy, setVouchedBusy] = createSignal<string | null>(null);275 276 const [pairedDevices, setPairedDevices] = createSignal<PairedDevice[]>([]);277 const [confirmUnpair, setConfirmUnpair] = createSignal<string | null>(null);278 const [renameDeviceId, setRenameDeviceId] = createSignal<string | null>(null);279 const [renameDraft, setRenameDraft] = createSignal("");280 const [syncStatus, setSyncStatus] = createSignal<"idle" | "syncing" | "error">("idle");281 const [syncFlash, setSyncFlash] = createSignal<string | null>(null);282 283 284 const [syncSummary, setSyncSummary] = createSignal<SyncOutcomeSummary>({});285 const syncLight = () =>286 syncStatus() === "syncing"287 ? "busy"288 : syncIndicator(syncSummary(), pairedDevices().length);289 const syncLightLabel = () => syncIndicatorLabel(syncLight(), syncSummary());290 const lastSyncLine = () => {291 const s = syncSummary();292 if (!s.outcome) return "No sync attempted yet";293 return `${syncRelativeTime(s.finishedAt)} · ${describeSyncOutcome(s)}`;294 };295 async function loadSyncState() {296 const r = (await sendMessage({ type: "GET_SYNC_STATE" })) as {297 ok: boolean;298 data?: SyncOutcomeSummary;299 };300 if (r.ok && r.data) {301 setSyncSummary(r.data);302 setSyncStatus(r.data.status ?? "idle");303 }304 }305306 const STALE_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000;307 function syncRelativeTime(when: number | undefined): string {308 if (!when) return "Not synced yet";309 const elapsed = Date.now() - when;310 if (elapsed < 60_000) return "Just now";311 const minutes = Math.floor(elapsed / 60_000);312 if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"} ago`;313 const hours = Math.floor(minutes / 60);314 if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;315 const days = Math.floor(hours / 24);316 return days < 7 ? `${days} day${days === 1 ? "" : "s"} ago` : "Not seen recently";317 }318 function isSyncStale(d: PairedDevice): boolean {319 return d.lastSyncedAt !== undefined && Date.now() - d.lastSyncedAt > STALE_THRESHOLD_MS;320 }321 function deviceTypeLabel(t: PairedDevice["deviceType"]): string {322 switch (t) {323 case "android": return "Android";324 case "ios": return "iPhone";325 case "extension": return "Browser";326 default: return "Device";327 }328 }329330 async function loadPairedDevices() {331 if (!isPro()) return;332 const r = (await sendMessage({ type: "LIST_PAIRED_DEVICES" })) as {333 ok: boolean;334 data?: { devices: PairedDevice[] };335 };336 if (r.ok) setPairedDevices(r.data?.devices ?? []);337 }338 async function unpairDevice(deviceId: string) {339 await sendMessage({ type: "UNPAIR_DEVICE", payload: { deviceId } });340 setConfirmUnpair(null);341 await loadPairedDevices();342 }343 async function renameDevice(deviceId: string) {344 const name = renameDraft().trim();345 if (!name) return;346 const result = (await sendMessage({347 type: "RENAME_PAIRED_DEVICE",348 payload: { deviceId, name },349 })) as { ok: boolean };350 if (!result.ok) return;351 setRenameDeviceId(null);352 await loadPairedDevices();353 }354 async function syncNow() {355 setSyncStatus("syncing");356 setSyncFlash(null);357 const res = (await sendMessage({358 type: "TRIGGER_SYNC",359 payload: { reason: "manual", force: true },360 })) as { ok: boolean; error?: string };361 if (!res.ok) {362 setSyncStatus("error");363 setSyncSummary({ status: "error", outcome: "run_failed", detail: res.error });364 return;365 }366 367 368 369 let s: SyncOutcomeSummary = { status: "syncing" };370 for (let i = 0; i < 40 && s.status === "syncing"; i++) {371 await new Promise((r) => setTimeout(r, 250));372 const state = (await sendMessage({ type: "GET_SYNC_STATE" })) as {373 ok: boolean;374 data?: SyncOutcomeSummary;375 };376 s = state.ok ? state.data ?? { status: "idle" } : { status: "error", outcome: "run_failed" };377 }378 setSyncSummary(s);379 setSyncStatus(s.status ?? "idle");380 381 382 setSyncFlash(describeSyncOutcome(s));383 setTimeout(() => setSyncFlash(null), 4000);384 await loadPairedDevices();385 }386 387 const [rotateConfirmOpen, setRotateConfirmOpen] = createSignal(false);388 const [rotating, setRotating] = createSignal(false);389 const [rotateError, setRotateError] = createSignal<string | null>(null);390 const [showSyncConnection, setShowSyncConnection] = createSignal(false);391 const [showSyncRecovery, setShowSyncRecovery] = createSignal(false);392393 async function loadPendingVouched() {394 const r = (await sendMessage({ type: "GET_PENDING_VOUCHED_DEVICES" })) as {395 ok: boolean;396 data?: PendingVouched[];397 };398 if (r.ok) setPendingVouched(r.data ?? []);399 }400 async function confirmVouched(deviceId: string) {401 setVouchedBusy(deviceId);402 try {403 await sendMessage({ type: "CONFIRM_VOUCHED_DEVICE", payload: { deviceId } });404 await loadPendingVouched();405 } finally {406 setVouchedBusy(null);407 }408 }409 async function rejectVouched(deviceId: string) {410 setVouchedBusy(deviceId);411 try {412 await sendMessage({ type: "REJECT_VOUCHED_DEVICE", payload: { deviceId } });413 await loadPendingVouched();414 } finally {415 setVouchedBusy(null);416 }417 }418 async function doRotateSyncIdentity() {419 setRotateError(null);420 setRotating(true);421 try {422 const r = (await sendMessage({ type: "ROTATE_SYNC_IDENTITY" })) as {423 ok: boolean;424 error?: string;425 };426 if (!r.ok) {427 setRotateError(r.error ?? "Rotation failed.");428 return;429 }430 setRotateConfirmOpen(false);431 432 433 setShowNewRecoveryWords(true);434 } finally {435 setRotating(false);436 }437 }438439 const [dedupCount, setDedupCount] = createSignal<number | null>(null);440 const [dedupRunning, setDedupRunning] = createSignal(false);441 const [dedupDone, setDedupDone] = createSignal<number | null>(null);442443 const [declinedCardCount, setDeclinedCardCount] = createSignal(0);444445 446 447 448 const [historySnapshots, setHistorySnapshots] = createSignal<VaultSnapshot[]>([]);449 const [historyLoading, setHistoryLoading] = createSignal(false);450 const [historyRestoring, setHistoryRestoring] = createSignal<string | null>(null);451 const [historyShowAll, setHistoryShowAll] = createSignal(false);452453 async function loadVaultHistory() {454 setHistoryLoading(true);455 try {456 const res = await sendMessage({ type: "GET_VAULT_HISTORY" });457 const list = (res as { ok: boolean; data?: VaultSnapshot[] }).data ?? [];458 setHistorySnapshots(list);459 } finally {460 setHistoryLoading(false);461 }462 }463464 async function handleRestoreVaultSnapshot(id: string) {465 if (historyRestoring()) return;466 setHistoryRestoring(id);467 const res = await sendMessage({468 type: "RESTORE_VAULT_SNAPSHOT",469 payload: { snapshotId: id },470 });471 setHistoryRestoring(null);472 if ((res as { ok: boolean }).ok) {473 474 475 props.onBack();476 }477 }478479 function formatSnapshotTime(savedAt: number): string {480 const diff = Date.now() - savedAt;481 if (diff < 60_000) return "Just now";482 if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;483 const d = new Date(savedAt);484 const isToday = new Date().toDateString() === d.toDateString();485 const isYest =486 new Date(Date.now() - 86_400_000).toDateString() === d.toDateString();487 const t = d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });488 if (isToday) return `Today ${t}`;489 if (isYest) return `Yesterday ${t}`;490 return `${d.toLocaleDateString([], { month: "short", day: "numeric" })} ${t}`;491 }492493 function reasonLabel(reason: VaultSnapshot["reason"]): string {494 return (495 {496 local_save: "saved",497 sync_received: "synced",498 import: "imported",499 restore: "restored",500 } as const501 )[reason] ?? reason;502 }503504 const [showVaultAdvanced, setShowVaultAdvanced] = createSignal(false);505506 const [showChangePw, setShowChangePw] = createSignal(false);507 const [changePwCurrent, setChangePwCurrent] = createSignal("");508 const [changePwNew, setChangePwNew] = createSignal("");509 const [changePwConfirm, setChangePwConfirm] = createSignal("");510 const [changePwSaving, setChangePwSaving] = createSignal(false);511 const [changePwError, setChangePwError] = createSignal<string | null>(null);512 const [changePwSuccess] = createSignal(false);513 const [showNewRecoveryWords, setShowNewRecoveryWords] = createSignal(false);514515 516 const [showViewWords, setShowViewWords] = createSignal(false);517 const [viewWordsPassword, setViewWordsPassword] = createSignal("");518 const [viewWordsError, setViewWordsError] = createSignal<string | null>(null);519 const [viewedWords, setViewedWords] = createSignal<string[] | null>(null);520 const [viewWordsBusy, setViewWordsBusy] = createSignal(false);521522 async function handleViewRecoveryWords() {523 setViewWordsError(null);524 if (!viewWordsPassword()) {525 setViewWordsError("Enter your master password.");526 return;527 }528 setViewWordsBusy(true);529 try {530 const r = (await sendMessage({531 type: "GET_RECOVERY_WORDS",532 payload: { password: viewWordsPassword() },533 })) as { ok: boolean; data?: string[]; error?: string };534 if (r.ok) {535 setViewedWords(r.data ?? []);536 setViewWordsPassword("");537 return;538 }539 switch (r.error) {540 case "wrong_password":541 setViewWordsError("Incorrect password");542 break;543 case "password_required":544 setViewWordsError("Enter your master password.");545 break;546 case "recovery_not_setup":547 setViewWordsError("Recovery words aren't set up for this vault yet.");548 break;549 default:550 setViewWordsError("Couldn't retrieve recovery words.");551 }552 } finally {553 setViewWordsBusy(false);554 }555 }556 function closeViewWords() {557 setShowViewWords(false);558 setViewWordsPassword("");559 setViewWordsError(null);560 setViewedWords(null);561 }562563 const [duressEnabled, setDuressEnabled] = createSignal(false);564 const [showDuressSetup, setShowDuressSetup] = createSignal(false);565 const [duressPassword, setDuressPassword] = createSignal("");566 const [duressConfirm, setDuressConfirm] = createSignal("");567 const [duressMainPassword, setDuressMainPassword] = createSignal("");568 const [duressSaving, setDuressSaving] = createSignal(false);569 const [duressError, setDuressError] = createSignal<string | null>(null);570 const [duressSuccess, setDuressSuccess] = createSignal<string | null>(null);571572 const [canaryEntries, setCanaryEntries] = createSignal<Array<{id: string; title: string; canary: Canary}>>([]);573574 const [canaryTestingId, setCanaryTestingId] = createSignal<string | null>(null);575 const [canaryTestResult, setCanaryTestResult] = createSignal<{id: string; ok: boolean} | null>(null);576577 const [fbCategory, setFbCategory] = createSignal("Bug report");578 const [fbMessage, setFbMessage] = createSignal("");579 type FeedbackState = 'idle' | 'sending' | 'sent' | 'error';580 const [feedbackState, setFeedbackState] = createSignal<FeedbackState>('idle');581582 createEffect(() => {583 if (props.upgradeHighlight) {584 setActiveCard("pro");585 }586 });587588 onMount(async () => {589 const [licenseRes, passkeyRes, duressRes, settingsRes, keyRes] = await Promise.all([590 sendMessage({ type: "GET_LICENSE" }),591 sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }),592 sendMessage({ type: "IS_DURESS_ENABLED" }),593 sendMessage({ type: "GET_SETTINGS" }),594 sendMessage({ type: "GET_LICENSE_KEY" }),595 ]);596597 598 599 void loadVaultHistory();600601 602 603 void loadPendingVouched();604 void loadPairedDevices();605 void loadSyncState();606 const onBroadcast = (msg: unknown) => {607 const m = msg as { type?: string; payload?: unknown } | null;608 if (m?.type === "VOUCHED_DEVICE_PENDING") {609 void loadPendingVouched();610 }611 if (m?.type === "SYNC_STATE_CHANGED") {612 const payload = (m.payload ?? {}) as SyncOutcomeSummary;613 setSyncSummary(payload);614 setSyncStatus(payload.status ?? "idle");615 }616 };617 chrome.runtime?.onMessage?.addListener(onBroadcast);618 onCleanup(() => {619 try { chrome.runtime?.onMessage?.removeListener(onBroadcast); } catch { }620 });621622 if ((licenseRes as { ok: boolean }).ok) {623 setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null);624 }625626 if ((passkeyRes as { ok: boolean }).ok) {627 setPasskeyState(628 (passkeyRes as { ok: boolean; data?: PasskeyProviderState }).data ?? null629 );630 }631632 if ((duressRes as { ok: boolean }).ok) {633 setDuressEnabled((duressRes as { ok: boolean; data?: boolean }).data ?? false);634 }635636 if ((settingsRes as { ok: boolean }).ok) {637 const sData = (settingsRes as { ok: boolean; data?: { declinedCardFingerprints?: string[] } }).data;638 setDeclinedCardCount(sData?.declinedCardFingerprints?.length ?? 0);639 }640641 if ((keyRes as { ok: boolean }).ok) {642 setLicenseKey((keyRes as { ok: boolean; data?: string }).data ?? null);643 }644645 const entriesRes = await sendMessage({ type: "GET_ENTRIES" });646 if ((entriesRes as { ok: boolean }).ok) {647 const entries = (entriesRes as { ok: boolean; data?: Array<{id: string; title: string; canary?: Canary}> }).data ?? [];648 setCanaryEntries(649 entries650 .filter((e) => e.canary?.webhookUrl)651 .map((e) => ({ id: e.id, title: e.title, canary: e.canary! }))652 );653 }654655656 if (isNativePlatform()) {657 const biometric = nativePlugin<{ isAvailable: () => Promise<{ available: boolean }> }>("BiometricPlugin");658 const keystore = nativePlugin<{ hasMasterKey: () => Promise<{ exists: boolean }> }>("KeystorePlugin");659 try {660 if (biometric) {661 const a = await biometric.isAvailable();662 setBiometricSupported(a.available);663 }664 if (keystore) {665 const k = await keystore.hasMasterKey();666 setBiometricStored(k.exists);667 }668 } catch { }669 }670 });671672 async function handleEnableBiometrics() {673 setBiometricBusy(true);674 setBiometricError(null);675 try {676 const keyRes = (await sendMessage({ type: "GET_DERIVED_KEY" })) as {677 ok: boolean;678 data?: { masterKeyHex: string };679 error?: string;680 };681 if (!keyRes.ok || !keyRes.data?.masterKeyHex) {682 setBiometricError(keyRes.error === "vault_locked"683 ? "unlock the vault first"684 : "couldn't read derived key");685 return;686 }687 const keystore = nativePlugin<{ storeMasterKey: (p: { masterKeyHex: string }) => Promise<void> }>("KeystorePlugin");688 if (!keystore) {689 setBiometricError("biometric plugin unavailable");690 return;691 }692 await keystore.storeMasterKey({ masterKeyHex: keyRes.data.masterKeyHex });693 await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: true } });694 setBiometricStored(true);695 } catch (e) {696 setBiometricError(e instanceof Error ? e.message : "failed to enable biometrics");697 } finally {698 setBiometricBusy(false);699 }700 }701702 async function handleDisableBiometrics() {703 setBiometricBusy(true);704 setBiometricError(null);705 try {706 const keystore = nativePlugin<{ clearMasterKey: () => Promise<void> }>("KeystorePlugin");707 if (keystore) await keystore.clearMasterKey();708 await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: false } });709 setBiometricStored(false);710 } catch (e) {711 setBiometricError(e instanceof Error ? e.message : "failed to disable biometrics");712 } finally {713 setBiometricBusy(false);714 }715 }716717 async function handleChangePassword() {718 setChangePwError(null);719 if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; }720 if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; }721 if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; }722 if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; }723 setChangePwSaving(true);724 const res = await sendMessage({725 type: "CHANGE_PASSWORD",726 payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() },727 }) as { ok: boolean; error?: string };728 if (res.ok) {729 setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");730 setShowChangePw(false);731 732 733 734 735 await chrome.storage?.local?.set?.({ peach_recovery_pending: "1" });736 setShowNewRecoveryWords(true);737 } else {738 setChangePwError(res.error ?? "failed to change password");739 }740 setChangePwSaving(false);741 }742743 const [copied, setCopied] = createSignal(false);744745 async function copyLicenseKey(key: string) {746 await navigator.clipboard.writeText(key);747 setCopied(true);748 setTimeout(() => setCopied(false), 2000);749 }750751 async function handleActivateLicense() {752 const key = licenseKeyInput().trim();753 if (!key) return;754 setActivatingLicense(true);755 setLicenseError(null);756 setLicenseSuccess(null);757 const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } });758 setActivatingLicense(false);759 if ((res as { ok: boolean }).ok) {760 const data = (res as { ok: boolean; data?: LicenseInfo }).data;761 if (data?.valid) {762 setLicenseInfo(data);763 setLicenseKey(key);764 setLicenseSuccess("License activated!");765 setLicenseKeyInput("");766 } else {767 setLicenseError("Invalid license key");768 }769 } else {770 setLicenseError("Activation failed");771 }772 }773774 async function handleTogglePasskeys() {775 const current = passkeyState();776 if (!current || !current.supported) return;777 setPasskeySaving(true);778 const res = await sendMessage({779 type: "SET_PASSKEY_PROVIDER_ENABLED",780 payload: { enabled: !current.enabled },781 });782 setPasskeySaving(false);783 if ((res as { ok: boolean }).ok) {784 setPasskeyState(785 (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null786 );787 }788 }789790 async function handleCheckAllBreaches() {791 setCheckingBreaches(true);792 setBreachResult(null);793 const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });794 setCheckingBreaches(false);795 if ((res as { ok: boolean }).ok) {796 const data = (res as { ok: boolean; data?: Record<string, number> }).data ?? {};797 const breached = Object.values(data).filter((count) => count > 0).length;798 setBreachResult(799 breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean"800 );801 props.onBreachCheckDone();802 }803 }804805806 async function handleSetupDuress() {807 const mainPw = duressMainPassword().trim();808 const duressPw = duressPassword().trim();809 const confirmPw = duressConfirm().trim();810811 if (!mainPw) { setDuressError("enter your main password to confirm"); return; }812 if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; }813 if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; }814 if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; }815816 setDuressSaving(true);817 setDuressError(null);818819 const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };820 if (!verifyRes.ok || !verifyRes.data) {821 setDuressError("incorrect main password");822 setDuressSaving(false);823 return;824 }825826 const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string };827 setDuressSaving(false);828 if (res.ok) {829 setDuressEnabled(true);830 setShowDuressSetup(false);831 setDuressPassword("");832 setDuressConfirm("");833 setDuressMainPassword("");834 setDuressSuccess("Duress password set. If entered, a decoy vault will be shown.");835 setTimeout(() => setDuressSuccess(null), 5000);836 } else {837 setDuressError(838 res.error === "session_expired"839 ? "Vault session expired — lock and unlock Peach, then try again"840 : res.error ?? "failed to set duress password"841 );842 }843 }844845 async function handleClearDeclinedCards() {846 await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } });847 setDeclinedCardCount(0);848 }849850 async function handleCountDuplicates() {851 setDedupRunning(true);852 setDedupDone(null);853 const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number };854 setDedupRunning(false);855 if (res.ok) setDedupCount(res.data ?? 0);856 }857858 async function handleDeduplicate() {859 setDedupRunning(true);860 const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number };861 setDedupRunning(false);862 if (res.ok) {863 setDedupDone(res.data ?? 0);864 setDedupCount(0);865 }866 }867868 async function handleDisableDuress() {869 const mainPw = duressMainPassword().trim();870 if (!mainPw) { setDuressError("enter your main password to confirm"); return; }871872 setDuressSaving(true);873 setDuressError(null);874875 const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };876 if (!verifyRes.ok || !verifyRes.data) {877 setDuressError("incorrect main password");878 setDuressSaving(false);879 return;880 }881882 const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string };883 setDuressSaving(false);884 if (res.ok) {885 setDuressEnabled(false);886 setShowDuressSetup(false);887 setDuressMainPassword("");888 setDuressSuccess("Duress password disabled.");889 setTimeout(() => setDuressSuccess(null), 4000);890 } else {891 setDuressError(res.error ?? "failed to disable duress password");892 }893 }894895 async function handleSubmitFeedback() {896 const message = sanitizeFeedbackMessage(fbMessage());897 if (!message) return;898899 setFeedbackState('sending');900901 try {902 const hasDataPermission = await requestFeedbackDataPermission();903 if (!hasDataPermission) {904 setFeedbackState('idle');905 return;906 }907908 const category = FEEDBACK_CATEGORIES.includes(fbCategory())909 ? fbCategory()910 : FEEDBACK_CATEGORIES[0];911 const res = await fetch('https://api.web3forms.com/submit', {912 method: 'POST',913 headers: { 'Content-Type': 'application/json' },914 body: JSON.stringify({915 access_key: WEB3FORMS_KEY,916 subject: `Peach Feedback - ${category}`,917 message,918 from_name: 'Peach User',919 920 botcheck: '',921 }),922 });923924 if (!res.ok) throw new Error(`HTTP ${res.status}`);925 const data = await res.json() as { success: boolean; message?: string };926 if (!data.success) throw new Error(data.message ?? 'unknown error');927928 setFeedbackState('sent');929 setFbMessage('');930 setFbCategory(FEEDBACK_CATEGORIES[0]);931 } catch (err) {932 console.error('[feedback]', err);933 setFeedbackState('error');934 setTimeout(() => setFeedbackState('idle'), 4000);935 }936 }937938 function renderSecurity() {939 return (940 <div class="peach-settings-body">941 <div class="peach-settings-group">942 <p class="peach-settings-sub-label">LOCKING</p>943 <div class="peach-settings-row">944 <span>Lock on close</span>945 <button946 class={`peach-toggle ${props.lockOnClose ? "is-on" : ""}`}947 onClick={() => void props.onLockOnCloseChange(!props.lockOnClose)}948 aria-label="Toggle lock on close"949 >950 <span class="peach-toggle-knob"></span>951 </button>952 </div>953 <div class="peach-settings-row">954 <span>Auto-lock after</span>955 <select956 class="peach-settings-select"957 value={String(props.autoLockMinutes)}958 onChange={(e) => void props.onAutoLockMinutesChange(Number(e.currentTarget.value))}959 >960 <option value="1">1 minute</option>961 <option value="5">5 minutes</option>962 <option value="15">15 minutes</option>963 <option value="30">30 minutes</option>964 <option value="0">Never</option>965 </select>966 </div>967968 <Show when={isNativePlatform() && biometricSupported()}>969 <p class="peach-settings-sub-label">BIOMETRICS</p>970 <div class="peach-settings-row">971 <span>Unlock with biometrics</span>972 <button973 class={`peach-toggle ${biometricStored() ? "is-on" : ""}`}974 onClick={() => void (biometricStored() ? handleDisableBiometrics() : handleEnableBiometrics())}975 disabled={biometricBusy()}976 aria-label="Toggle biometric unlock"977 >978 <span class="peach-toggle-knob"></span>979 </button>980 </div>981 <Show when={biometricError()}>982 <div class="peach-settings-row peach-settings-row--meta">983 <span class="peach-hint peach-hint-error">{biometricError()}</span>984 </div>985 </Show>986 </Show>987988 <p class="peach-settings-sub-label">BREACH PROTECTION</p>989 <div class="peach-settings-row">990 <span>Breach monitoring</span>991 <button992 class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}993 onClick={() => void props.onBreachMonitoringChange(!props.breachMonitoring)}994 aria-label="Toggle breach monitoring"995 >996 <span class="peach-toggle-knob"></span>997 </button>998 </div>999 <div class="peach-settings-row peach-settings-row--meta">1000 <span>Check all passwords</span>1001 <button1002 class="peach-btn peach-btn--subtle peach-btn--sm"1003 onClick={() => void handleCheckAllBreaches()}1004 disabled={checkingBreaches()}1005 >1006 {checkingBreaches() ? "checking..." : "run"}1007 </button>1008 </div>1009 <Show when={breachResult()}>1010 <div class="peach-settings-row peach-settings-row--meta">1011 <span>Last check</span>1012 <span class="peach-settings-value">{breachResult()}</span>1013 </div>1014 </Show>10151016 <p class="peach-settings-sub-label">MASTER PASSWORD</p>1017 <div class="peach-settings-row peach-settings-row--meta">1018 <span>Master password</span>1019 <button1020 class="peach-btn peach-btn--subtle peach-btn--sm"1021 onClick={() => { setShowChangePw((v) => !v); setChangePwError(null); }}1022 >1023 {showChangePw() ? "cancel" : "change"}1024 </button>1025 </div>1026 <Show when={showChangePw()}>1027 <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1028 <input1029 class="peach-settings-input"1030 type="password"1031 placeholder="current password"1032 value={changePwCurrent()}1033 onInput={(e) => setChangePwCurrent((e.target as HTMLInputElement).value)}1034 autocomplete="current-password"1035 />1036 <input1037 class="peach-settings-input"1038 type="password"1039 placeholder="new password (min 12 chars)"1040 value={changePwNew()}1041 onInput={(e) => setChangePwNew((e.target as HTMLInputElement).value)}1042 autocomplete="new-password"1043 />1044 <input1045 class="peach-settings-input"1046 type="password"1047 placeholder="confirm new password"1048 value={changePwConfirm()}1049 onInput={(e) => setChangePwConfirm((e.target as HTMLInputElement).value)}1050 autocomplete="new-password"1051 onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }}1052 />1053 <Show when={changePwError()}>1054 <p class="peach-hint peach-hint-error" style="margin:0">{changePwError()}</p>1055 </Show>1056 <Show when={changePwSuccess()}>1057 <p class="peach-hint" style="margin:0;color:var(--peach-accent)">password changed ✓</p>1058 </Show>1059 <button1060 class="peach-btn peach-btn--accent peach-btn--sm"1061 style="align-self:flex-start"1062 onClick={() => void handleChangePassword()}1063 disabled={changePwSaving() || !changePwCurrent() || !changePwNew() || !changePwConfirm()}1064 >1065 {changePwSaving() ? "saving…" : "save new password"}1066 </button>1067 </div>1068 </Show>10691070 {}1071 <div class="peach-settings-row peach-settings-row--meta">1072 <span>Recovery words</span>1073 <button1074 class="peach-btn peach-btn--subtle peach-btn--sm"1075 onClick={() => {1076 if (showViewWords()) { closeViewWords(); } else { setShowViewWords(true); }1077 }}1078 >1079 {showViewWords() ? "cancel" : "view"}1080 </button>1081 </div>1082 <Show when={showViewWords()}>1083 <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1084 <Show1085 when={viewedWords()}1086 fallback={1087 <>1088 <p class="peach-hint" style="margin:0;color:var(--peach-t2)">1089 Enter your master password to reveal your recovery words.1090 </p>1091 <input1092 class="peach-settings-input"1093 type="password"1094 placeholder="master password"1095 value={viewWordsPassword()}1096 onInput={(e) => setViewWordsPassword((e.target as HTMLInputElement).value)}1097 autocomplete="current-password"1098 onKeyDown={(e) => { if (e.key === "Enter") void handleViewRecoveryWords(); }}1099 />1100 <Show when={viewWordsError()}>1101 <p class="peach-hint peach-hint-error" style="margin:0">{viewWordsError()}</p>1102 </Show>1103 <button1104 class="peach-btn peach-btn--accent peach-btn--sm"1105 style="align-self:flex-start"1106 onClick={() => void handleViewRecoveryWords()}1107 disabled={viewWordsBusy() || !viewWordsPassword()}1108 >1109 {viewWordsBusy() ? "checking…" : "reveal recovery words"}1110 </button>1111 </>1112 }1113 >1114 <div class="peach-recovery-words-grid">1115 <For each={viewedWords()!}>1116 {(word, i) => (1117 <span class="peach-recovery-word"><b>{i() + 1}</b> {word}</span>1118 )}1119 </For>1120 </div>1121 <button1122 class="peach-btn peach-btn--subtle peach-btn--sm"1123 style="align-self:flex-start"1124 onClick={closeViewWords}1125 >done</button>1126 </Show>1127 </div>1128 </Show>11291130 <p class="peach-settings-sub-label">1131 DURESS PASSWORD1132 <Show when={!isPro()}>1133 <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>1134 </Show>1135 </p>1136 <div class="peach-settings-row peach-settings-row--top">1137 <div class="peach-settings-row-main">1138 <span>1139 Duress password1140 <Show when={!isPro()}>1141 <span class="peach-pro-badge">Pro</span>1142 </Show>1143 </span>1144 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">1145 If entered under coercion, shows a decoy empty vault instead of your real one.1146 </span>1147 </div>1148 <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>1149 {duressEnabled() ? "on" : "off"}1150 </div>1151 </div>11521153 <Show when={duressEnabled() && !showDuressSetup()}>1154 <div class="peach-settings-row peach-settings-row--meta">1155 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1156 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.1157 </span>1158 </div>1159 </Show>11601161 <Show when={duressSuccess()}>1162 <div class="peach-settings-row peach-settings-row--meta">1163 <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>1164 </div>1165 </Show>11661167 <Show when={!showDuressSetup()}>1168 <div class="peach-settings-row peach-settings-row--meta">1169 <button1170 class="peach-btn peach-btn--subtle peach-btn--sm"1171 onClick={() => {1172 1173 1174 1175 1176 1177 if (!isPro() && !duressEnabled()) {1178 props.onProGate?.("duress_vault");1179 setActiveCard("pro");1180 return;1181 }1182 setShowDuressSetup(true);1183 setDuressError(null);1184 setDuressSuccess(null);1185 setDuressPassword("");1186 setDuressConfirm("");1187 setDuressMainPassword("");1188 }}1189 >1190 {duressEnabled() ? "Change / disable" : "Set up"}1191 </button>1192 </div>1193 </Show>11941195 <Show when={showDuressSetup()}>1196 <div class="peach-duress-form">1197 <label class="peach-label">main password (to confirm)</label>1198 <input1199 class="peach-settings-input"1200 type="password"1201 placeholder="your current master password"1202 autocomplete="current-password"1203 value={duressMainPassword()}1204 onInput={(e) => setDuressMainPassword(e.currentTarget.value)}1205 />1206 <Show when={!duressEnabled()}>1207 <label class="peach-label">new duress password</label>1208 <input1209 class="peach-settings-input"1210 type="password"1211 placeholder="duress password (min 8 chars)"1212 autocomplete="new-password"1213 value={duressPassword()}1214 onInput={(e) => setDuressPassword(e.currentTarget.value)}1215 />1216 <label class="peach-label">confirm duress password</label>1217 <input1218 class="peach-settings-input"1219 type="password"1220 placeholder="confirm duress password"1221 autocomplete="new-password"1222 value={duressConfirm()}1223 onInput={(e) => setDuressConfirm(e.currentTarget.value)}1224 />1225 </Show>12261227 <Show when={duressError()}>1228 <p class="peach-hint peach-hint-error">{duressError()}</p>1229 </Show>12301231 <div class="peach-duress-actions">1232 <button1233 class="peach-btn peach-btn--subtle peach-btn--sm"1234 onClick={() => { setShowDuressSetup(false); setDuressError(null); }}1235 >1236 cancel1237 </button>1238 <Show when={duressEnabled()}>1239 <button1240 class="peach-btn peach-btn--ghost peach-btn--sm"1241 style="color:var(--peach-breach)"1242 onClick={() => void handleDisableDuress()}1243 disabled={duressSaving()}1244 >1245 {duressSaving() ? "disabling..." : "disable duress"}1246 </button>1247 </Show>1248 <Show when={!duressEnabled()}>1249 <button1250 class="peach-btn peach-btn--accent peach-btn--sm"1251 onClick={() => void handleSetupDuress()}1252 disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}1253 >1254 {duressSaving() ? "saving..." : "save"}1255 </button>1256 </Show>1257 </div>1258 </div>1259 </Show>12601261 <p class="peach-settings-sub-label">1262 CANARY ENTRIES1263 <Show when={!isPro()}>1264 <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>1265 </Show>1266 </p>1267 <div class="peach-settings-row peach-settings-row--top">1268 <div class="peach-settings-row-main">1269 <span>1270 Canary entries1271 <Show when={!isPro()}>1272 <span class="peach-pro-badge">Pro</span>1273 </Show>1274 </span>1275 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1276 Silently pings a webhook when an entry is accessed. Configure canary on any entry in your vault.1277 </span>1278 </div>1279 <span class="peach-settings-value" style="flex-shrink:0">1280 {canaryEntries().length > 0 ? `${canaryEntries().length} active` : "none"}1281 </span>1282 </div>1283 <Show when={canaryEntries().length > 0}>1284 <For each={canaryEntries()}>1285 {(entry) => (1286 <div class="peach-settings-row peach-settings-row--meta">1287 <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0">{entry.title}</span>1288 <button1289 class="peach-btn peach-btn--subtle peach-btn--sm"1290 onClick={() => void handleTestCanary(entry)}1291 disabled={canaryTestingId() === entry.id}1292 style="flex-shrink:0"1293 >1294 {canaryTestingId() === entry.id1295 ? "sending…"1296 : canaryTestResult()?.id === entry.id1297 ? (canaryTestResult()!.ok ? "sent ✓" : "failed ✗")1298 : "test"}1299 </button>1300 </div>1301 )}1302 </For>1303 </Show>13041305 <p class="peach-settings-sub-label">PASSKEYS</p>1306 <div class="peach-settings-row peach-settings-row--top">1307 <div class="peach-settings-row-main">1308 <span>Intercept passkey requests</span>1309 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">1310 Peach can handle passkey sign-in and creation on supported Chromium browsers.1311 </span>1312 </div>1313 <button1314 class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}1315 onClick={() => void handleTogglePasskeys()}1316 aria-label="Toggle passkey interception"1317 disabled={!passkeyState()?.supported || passkeySaving()}1318 >1319 <span class="peach-toggle-knob"></span>1320 </button>1321 </div>1322 <div class="peach-settings-row peach-settings-row--meta">1323 <span>Status</span>1324 <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>1325 </div>1326 <div class="peach-settings-row peach-settings-row--meta">1327 <span>Browser</span>1328 <span class="peach-settings-value">1329 {passkeyState()?.browserLabel ?? "Unknown"}1330 {passkeyState()?.supported ? " ✓" : " (interception unavailable)"}1331 </span>1332 </div>1333 <div class="peach-info-notice" style="margin-top:8px">1334 Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.1335 </div>1336 <Show when={passkeyState()?.conflict}>1337 <div class="peach-settings-row peach-settings-row--meta">1338 <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>1339 </div>1340 </Show>1341 </div>1342 </div>1343 );1344 }13451346 function renderVault() {1347 return (1348 <div class="peach-settings-body">1349 <div class="peach-settings-group">1350 <p class="peach-settings-sub-label">ENTRIES</p>1351 <div class="peach-settings-row peach-settings-row--meta">1352 <span>Entries</span>1353 <span class="peach-settings-value">{props.entriesCount}</span>1354 </div>1355 <div class="peach-settings-row peach-settings-row--meta">1356 <span>Duplicates</span>1357 <Show when={dedupDone() !== null} fallback={1358 <button1359 class="peach-btn peach-btn--subtle peach-btn--sm"1360 onClick={() => void handleCountDuplicates()}1361 disabled={dedupRunning()}1362 >1363 {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}1364 </button>1365 }>1366 <span class="peach-settings-value" style="color:var(--peach-sync-ok)">1367 {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}1368 </span>1369 </Show>1370 </div>1371 <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>1372 <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">1373 <div class="peach-restore-warning">1374 {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.1375 </div>1376 <div style="display:flex;gap:6px">1377 <button1378 class="peach-btn peach-btn--subtle peach-btn--sm"1379 onClick={() => setDedupCount(null)}1380 >1381 cancel1382 </button>1383 <button1384 class="peach-btn peach-btn--accent peach-btn--sm"1385 onClick={() => void handleDeduplicate()}1386 disabled={dedupRunning()}1387 >1388 {dedupRunning() ? "removing..." : "remove duplicates"}1389 </button>1390 </div>1391 </div>1392 </Show>13931394 <p class="peach-settings-sub-label">MAINTENANCE</p>1395 <div class="peach-settings-row peach-settings-row--meta">1396 <span>Trash auto-delete</span>1397 <select1398 class="peach-settings-select"1399 value={String(props.trashRetentionDays)}1400 onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}1401 >1402 <option value="7">7 days</option>1403 <option value="30">30 days</option>1404 <option value="90">90 days</option>1405 <option value="0">Never</option>1406 </select>1407 </div>1408 <div class="peach-settings-row peach-settings-row--meta">1409 <span>Advanced Vault Health</span>1410 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>1411 open1412 </button>1413 </div>1414 <Show when={declinedCardCount() > 0}>1415 <div class="peach-settings-row peach-settings-row--meta">1416 <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>1417 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>1418 Clear1419 </button>1420 </div>1421 </Show>14221423 <p class="peach-settings-sub-label">DATA</p>1424 <div class="peach-settings-row peach-settings-row--meta">1425 <span>Import passwords</span>1426 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>1427 open1428 </button>1429 </div>1430 <div class="peach-settings-row peach-settings-row--meta">1431 <span>Export backup</span>1432 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onExport}>1433 open1434 </button>1435 </div>1436 <div class="peach-settings-row peach-settings-row--meta">1437 <span>1438 Peach Codex1439 <Show when={!isPro()}>1440 <span class="peach-pro-badge">Pro</span>1441 </Show>1442 </span>1443 <button1444 class="peach-btn peach-btn--subtle peach-btn--sm"1445 onClick={() => props.onOpenCodexGenerate?.()}1446 >1447 open1448 </button>1449 </div>1450 {1451}1452 <div class="peach-settings-row peach-settings-row--meta">1453 <span>Restore from Codex</span>1454 <button1455 class="peach-btn peach-btn--subtle peach-btn--sm"1456 onClick={() => props.onOpenCodexRestore?.()}1457 >1458 open1459 </button>1460 </div>14611462 <span class="peach-settings-sub-label">VAULT HISTORY</span>1463 <Show when={historyLoading()}>1464 <p class="peach-settings-hint">Loading…</p>1465 </Show>1466 <Show when={!historyLoading() && historySnapshots().length === 0}>1467 <p class="peach-settings-hint">1468 No snapshots yet. Peach saves automatically before every change —1469 saves, syncs, and imports.1470 </p>1471 </Show>1472 <Show when={!historyLoading() && historySnapshots().length > 0}>1473 <p class="peach-settings-hint">1474 Restoring locks your vault. Unlock with the password that1475 encrypted that snapshot to confirm.1476 </p>1477 <div class="peach-history-list">1478 <For each={historySnapshots().slice(0, historyShowAll() ? undefined : 3)}>1479 {(snap) => (1480 <div class="peach-history-row">1481 <div class="peach-history-meta">1482 <span class="peach-history-time">1483 {formatSnapshotTime(snap.savedAt)}1484 </span>1485 <span class="peach-history-sub">1486 {[1487 snap.entryCount !== null1488 ? `up to ${snap.entryCount} ${snap.entryCount === 1 ? "entry" : "entries"}`1489 : null,1490 reasonLabel(snap.reason),1491 ]1492 .filter(Boolean)1493 .join(" · ")}1494 </span>1495 </div>1496 <button1497 class={`peach-btn peach-btn--subtle peach-btn--sm peach-history-btn${1498 historyRestoring() === snap.id ? " is-busy" : ""1499 }`}1500 onClick={() => void handleRestoreVaultSnapshot(snap.id)}1501 disabled={historyRestoring() !== null}1502 >1503 <Show when={historyRestoring() === snap.id} fallback="Restore">1504 …1505 </Show>1506 </button>1507 </div>1508 )}1509 </For>1510 </div>1511 <Show when={historySnapshots().length > 3}>1512 <button1513 class="peach-settings-advanced-toggle"1514 onClick={() => setHistoryShowAll((v) => !v)}1515 aria-expanded={historyShowAll()}1516 >1517 <span class="peach-settings-hint" style="margin:0">1518 {historyShowAll() ? "show less" : `${historySnapshots().length - 3} older`}1519 </span>1520 <span class="peach-settings-advanced-chevron">1521 {historyShowAll() ? "▲" : "▼"}1522 </span>1523 </button>1524 </Show>1525 <p class="peach-history-footer">1526 {historySnapshots().length} of 10 snapshots1527 </p>1528 </Show>15291530 <button1531 class="peach-settings-advanced-toggle"1532 onClick={() => setShowVaultAdvanced((v) => !v)}1533 aria-expanded={showVaultAdvanced()}1534 >1535 <span class="peach-settings-sub-label" style="margin:0">ADVANCED</span>1536 <span class="peach-settings-advanced-chevron">1537 {showVaultAdvanced() ? "▲" : "▼"}1538 </span>1539 </button>15401541 <Show when={showVaultAdvanced()}>1542 <div class="peach-danger-card">1543 <div class="peach-danger-card-head">1544 <span class="peach-danger-card-glyph" aria-hidden="true">⟳</span>1545 <h3 class="peach-danger-card-title">Replace vault from backup</h3>1546 </div>1547 <p class="peach-danger-card-copy">1548 Wipes the current vault and restores from a backup file.1549 Recoverable only if your backup is good.1550 </p>1551 <button1552 class="peach-danger-card-button"1553 onClick={() => props.onOpenFileImport?.()}1554 >1555 Choose backup file1556 </button>1557 </div>15581559 <Show when={props.onOpenPanic}>1560 <div class="peach-danger-card peach-danger-card--hazard">1561 <div class="peach-danger-card-head">1562 <span class="peach-danger-card-glyph" aria-hidden="true">⚠</span>1563 <h3 class="peach-danger-card-title">Destroy vault</h3>1564 </div>1565 <p class="peach-danger-card-copy">1566 Permanently wipes every entry, setting, and license on this1567 device. There is no recovery.1568 </p>1569 <button1570 class="peach-danger-card-button"1571 onClick={() => props.onOpenPanic?.()}1572 >1573 Begin destruction1574 </button>1575 </div>1576 </Show>1577 </Show>1578 </div>1579 </div>1580 );1581 }15821583 function renderSync() {1584 return (1585 <div class="peach-settings-body">1586 <div class="peach-settings-group">1587 <p class="peach-settings-sub-label" style="margin-top:0">SYNC STATUS</p>1588 <div1589 class="peach-settings-row peach-settings-row--meta"1590 style="align-items:center;gap:8px"1591 role="status"1592 aria-label={syncLightLabel()}1593 title={syncLightLabel()}1594 >1595 <span class={`stk-pip stk-pip--sync is-${syncLight()}`} aria-hidden="true" />1596 <span class="peach-settings-value" style="color:var(--peach-t2);line-height:1.5;flex:1;min-width:0;overflow-wrap:anywhere">1597 {syncFlash() ?? lastSyncLine()}1598 </span>1599 </div>1600 <Show when={pairedDevices().length > 0}>1601 <div class="peach-settings-row peach-settings-row--meta">1602 <span class="peach-settings-value" style="color:var(--peach-t2)">1603 {(syncSummary().entriesSent ?? 0) + (syncSummary().entriesReceived ?? 0) > 01604 ? `${syncSummary().entriesSent ?? 0} sent · ${syncSummary().entriesReceived ?? 0} received`1605 : ""}1606 </span>1607 <button1608 class="peach-btn peach-btn--subtle peach-btn--sm"1609 aria-label="Sync this vault now"1610 disabled={syncStatus() === "syncing"}1611 onClick={() => void syncNow()}1612 >{syncStatus() === "syncing" ? "syncing…" : "sync now"}</button>1613 </div>1614 </Show>16151616 <p class="peach-settings-sub-label">ADD A DEVICE</p>1617 <button1618 class="peach-sync-pair-option"1619 onClick={() => props.onOpenSyncPairing?.("code")}1620 >1621 <span class="peach-sync-pair-option-icon" aria-hidden="true">1622 <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">1623 <rect x="6" y="2.5" width="8" height="15" rx="1.6" />1624 <path d="M9 15 L11 15" />1625 </svg>1626 </span>1627 <span class="peach-sync-pair-option-main">1628 <span class="peach-sync-pair-option-title">Show a code</span>1629 <span class="peach-sync-pair-option-sub">Scan it with Peach on your phone, then confirm one 8-digit code</span>1630 </span>1631 <span class="peach-sync-pair-option-chevron" aria-hidden="true">›</span>1632 </button>1633 <Show when={!isPro()}>1634 <p class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2);margin:8px 2px 0">1635 At least one device needs Peach Pro — pair with a Pro device and it carries over to here.1636 </p>1637 </Show>16381639 <Show when={pairedDevices().length > 0}>1640 <p class="peach-settings-sub-label">PAIRED DEVICES</p>1641 <For each={pairedDevices()}>1642 {(d) => (1643 <div class="peach-settings-row peach-settings-row--top">1644 <div class="peach-settings-row-main">1645 <Show1646 when={renameDeviceId() === d.deviceId}1647 fallback={<span>{d.localAlias ?? d.deviceName ?? "Unnamed device"}</span>}1648 >1649 <input1650 class="peach-settings-input"1651 aria-label={`Rename ${d.localAlias ?? d.deviceName ?? "device"}`}1652 maxlength={64}1653 value={renameDraft()}1654 onInput={(event) => setRenameDraft(event.currentTarget.value)}1655 onKeyDown={(event) => {1656 if (event.key === "Enter") void renameDevice(d.deviceId);1657 if (event.key === "Escape") setRenameDeviceId(null);1658 }}1659 />1660 </Show>1661 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1662 {deviceTypeLabel(d.deviceType)} · {syncRelativeTime(d.lastSyncedAt)}{isSyncStale(d) ? " · not seen recently" : ""}1663 </span>1664 </div>1665 <Show1666 when={confirmUnpair() === d.deviceId}1667 fallback={1668 <div style="display:flex;gap:4px;flex-shrink:0">1669 <Show1670 when={renameDeviceId() === d.deviceId}1671 fallback={1672 <button1673 class="peach-btn peach-btn--subtle peach-btn--sm"1674 onClick={() => {1675 setRenameDraft(d.localAlias ?? d.deviceName ?? "");1676 setRenameDeviceId(d.deviceId);1677 }}1678 >rename</button>1679 }1680 >1681 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => setRenameDeviceId(null)}>cancel</button>1682 <button class="peach-btn peach-btn--subtle peach-btn--sm" disabled={!renameDraft().trim()} onClick={() => void renameDevice(d.deviceId)}>save</button>1683 </Show>1684 <button1685 class="peach-btn peach-btn--subtle peach-btn--sm"1686 onClick={() => setConfirmUnpair(d.deviceId)}1687 >remove</button>1688 </div>1689 }1690 >1691 {16921693169416951696}1697 <div class="peach-unpair-confirm">1698 <p class="peach-unpair-confirm-note">1699 Stops this device receiving future changes. It doesn't1700 lock it out of what it already has — if it's lost or1701 stolen, also change your master password and generate1702 new recovery words.1703 </p>1704 <div style="display:flex;gap:6px;justify-content:flex-end">1705 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => setConfirmUnpair(null)}>cancel</button>1706 <button1707 class="peach-btn peach-btn--sm"1708 style="background:var(--peach-breach);border-color:var(--peach-breach)"1709 onClick={() => void unpairDevice(d.deviceId)}1710 >remove</button>1711 </div>1712 </div>1713 </Show>1714 </div>1715 )}1716 </For>1717 </Show>17181719 <Show when={pendingVouched().length > 0}>1720 <p class="peach-settings-sub-label">1721 PENDING DEVICES1722 <span class="peach-pro-badge" style="background:#C8A840;color:#1a1a1a">{pendingVouched().length}</span>1723 </p>1724 <For each={pendingVouched()}>1725 {(dev) => (1726 <div class="peach-settings-row peach-settings-row--top">1727 <div class="peach-settings-row-main">1728 <span>{dev.deviceName}</span>1729 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1730 Was added by another of your devices. Allow it to sync with this vault?1731 </span>1732 </div>1733 <div style="display:flex;gap:6px;flex-shrink:0">1734 <button1735 class="peach-btn peach-btn--sm"1736 disabled={vouchedBusy() === dev.deviceId}1737 onClick={() => void confirmVouched(dev.deviceId)}1738 >Allow</button>1739 <button1740 class="peach-btn peach-btn--subtle peach-btn--sm"1741 disabled={vouchedBusy() === dev.deviceId}1742 onClick={() => void rejectVouched(dev.deviceId)}1743 >Reject</button>1744 </div>1745 </div>1746 )}1747 </For>1748 </Show>17491750 <button1751 class="peach-settings-advanced-toggle peach-sync-disclosure-toggle"1752 onClick={() => setShowSyncConnection((value) => !value)}1753 aria-expanded={showSyncConnection()}1754 aria-controls="peach-sync-connection"1755 >1756 <span>1757 <span class="peach-settings-sub-label" style="margin:0">CONNECTION</span>1758 <span class="peach-sync-disclosure-summary">Relay and privacy choices</span>1759 </span>1760 <span class="peach-settings-advanced-chevron">1761 {showSyncConnection() ? "▲" : "▼"}1762 </span>1763 </button>1764 <Show when={showSyncConnection()}>1765 <div id="peach-sync-connection" class="peach-sync-disclosure-content">1766 <SyncModeSelector />1767 </div>1768 </Show>17691770 <button1771 class="peach-settings-advanced-toggle peach-sync-disclosure-toggle"1772 onClick={() => setShowSyncRecovery((value) => !value)}1773 aria-expanded={showSyncRecovery()}1774 aria-controls="peach-sync-recovery"1775 >1776 <span>1777 <span class="peach-settings-sub-label" style="margin:0">RECOVERY & SECURITY</span>1778 <span class="peach-sync-disclosure-summary">For a lost or stolen device</span>1779 </span>1780 <span class="peach-settings-advanced-chevron">1781 {showSyncRecovery() ? "▲" : "▼"}1782 </span>1783 </button>1784 <Show when={showSyncRecovery()}>1785 <div id="peach-sync-recovery" class="peach-sync-disclosure-content">1786 <Show1787 when={rotateConfirmOpen()}1788 fallback={1789 <div class="peach-settings-row peach-settings-row--top">1790 <div class="peach-settings-row-main">1791 <span>Rotate sync identity</span>1792 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1793 Disconnects every device and replaces the keys used for sync.1794 </span>1795 </div>1796 <button1797 class="peach-btn peach-btn--subtle peach-btn--sm"1798 style="color:var(--peach-breach);border-color:color-mix(in srgb,var(--peach-breach) 40%,transparent)"1799 onClick={() => { setRotateError(null); setRotateConfirmOpen(true); }}1800 >rotate</button>1801 </div>1802 }1803 >1804 <div class="peach-info-notice" style="margin-top:8px;border-color:color-mix(in srgb,var(--peach-breach) 40%,transparent)">1805 This disconnects every paired device and changes your 12 recovery words. You'll be shown the new words next.1806 <Show when={rotateError()}>1807 <div style="color:var(--peach-breach);margin-top:6px">{rotateError()}</div>1808 </Show>1809 <div style="display:flex;gap:6px;margin-top:10px">1810 <button1811 class="peach-btn peach-btn--subtle peach-btn--sm"1812 disabled={rotating()}1813 onClick={() => setRotateConfirmOpen(false)}1814 >Cancel</button>1815 <button1816 class="peach-btn peach-btn--sm"1817 style="background:var(--peach-breach);border-color:var(--peach-breach)"1818 disabled={rotating()}1819 onClick={() => void doRotateSyncIdentity()}1820 >{rotating() ? "Rotating…" : "Rotate sync identity"}</button>1821 </div>1822 </div>1823 </Show>1824 </div>1825 </Show>1826 </div>1827 </div>1828 );1829 }18301831 function renderAppearance() {1832 const sizeOptions: Array<{ id: "medium" | "large"; label: string }> = [1833 { id: "medium", label: "Default" },1834 { id: "large", label: "Large" },1835 ];1836 return (1837 <div class="peach-settings-body">1838 <div class="peach-settings-group">1839 <p class="peach-settings-sub-label">THEME</p>1840 <div class="peach-settings-row peach-settings-row--meta">1841 <span>Theme</span>1842 <button1843 class="peach-btn peach-btn--subtle peach-btn--sm"1844 onClick={() =>1845 props.onThemeChange(props.theme === "dark" ? "light" : "dark")1846 }1847 >1848 {props.theme === "dark" ? "Switch to light" : "Switch to dark"}1849 </button>1850 </div>18511852 <p class="peach-settings-sub-label">ACCENT</p>1853 <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">1854 <span>Accent</span>1855 <div class="peach-accent-picker">1856 <For each={ACCENT_COLORS}>1857 {(color) => (1858 <button1859 class="peach-accent-swatch peach-swatch"1860 classList={{ "is-active": props.accentColor === color.name }}1861 style={{ background: color.hex }}1862 onClick={() => void props.onAccentChange(color.name)}1863 aria-label={color.name}1864 title={color.name}1865 />1866 )}1867 </For>1868 </div>1869 </div>18701871 <p class="peach-settings-sub-label">TEXT SIZE</p>1872 <div class="peach-settings-row peach-settings-row--meta">1873 <span>Size</span>1874 <div class="peach-pill-group">1875 <For each={sizeOptions}>1876 {(opt) => (1877 <button1878 class="peach-btn peach-btn--ghost peach-btn--sm"1879 classList={{ "is-active": props.textSize === opt.id }}1880 onClick={() => void props.onTextSizeChange(opt.id)}1881 >1882 {opt.label}1883 </button>1884 )}1885 </For>1886 </div>1887 </div>18881889 <p class="peach-settings-sub-label">MOTION</p>1890 <div class="peach-settings-row peach-settings-row--meta">1891 <span>Reduce motion</span>1892 <button1893 class="peach-btn peach-btn--subtle peach-btn--sm"1894 onClick={() => void props.onReduceMotionChange(!props.reduceMotion)}1895 >1896 {props.reduceMotion ? "On" : "Off"}1897 </button>1898 </div>1899 </div>1900 </div>1901 );1902 }19031904 function renderShortcuts() {1905 const [capturingAction, setCapturingAction] = createSignal<ShortcutAction | null>(null);1906 const [conflictMsg, setConflictMsg] = createSignal<string | null>(null);19071908 function startCapture(action: ShortcutAction) {1909 setConflictMsg(null);1910 setCapturingAction(action);1911 }1912 function cancelCapture() {1913 setCapturingAction(null);1914 setConflictMsg(null);1915 }1916 function commitBinding(action: ShortcutAction, binding: Keybinding) {1917 const conflict = findConflict(props.shortcuts, binding, action);1918 if (conflict) {1919 const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict);1920 setConflictMsg(`Already bound to "${other?.label ?? conflict}"`);1921 return;1922 }1923 const next = { ...props.shortcuts, [action]: binding };1924 setCapturingAction(null);1925 setConflictMsg(null);1926 void props.onShortcutsChange(next);1927 }1928 function resetAction(action: ShortcutAction) {1929 const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] };1930 void props.onShortcutsChange(next);1931 }1932 function resetAll() {1933 void props.onShortcutsChange({ ...DEFAULT_BINDINGS });1934 }19351936 1937 1938 function handleCaptureKey(e: KeyboardEvent) {1939 e.preventDefault();1940 e.stopPropagation();1941 if (e.key === "Escape") { cancelCapture(); return; }1942 const binding = keybindingFromEvent(e);1943 if (!binding) return; 1944 const action = capturingAction();1945 if (action) commitBinding(action, binding);1946 }19471948 return (1949 <div class="peach-settings-body">1950 <div class="peach-settings-group peach-shortcuts-group">1951 <p class="peach-settings-sub-label">ENTRY VIEW</p>1952 <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry")}>1953 {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1954 </For>1955 </div>19561957 <div class="peach-settings-group peach-shortcuts-group">1958 <p class="peach-settings-sub-label">ANYWHERE IN POPUP</p>1959 <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global")}>1960 {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1961 </For>1962 </div>19631964 <Show when={conflictMsg()}>1965 <div class="peach-shortcuts-conflict">{conflictMsg()}</div>1966 </Show>19671968 <div class="peach-settings-group" style="display:flex;flex-direction:column;gap:8px">1969 <div class="peach-settings-row peach-settings-row--meta">1970 <span>Inline autofill on page</span>1971 <a1972 class="peach-settings-link"1973 href="chrome://extensions/shortcuts"1974 onClick={(e) => {1975 e.preventDefault();1976 chrome.tabs.create({ url: "chrome://extensions/shortcuts" });1977 }}1978 >1979 ⌘⇧P — customize ↗1980 </a>1981 </div>1982 <button1983 class="peach-btn peach-btn--subtle peach-btn--sm"1984 style="align-self:flex-start"1985 onClick={resetAll}1986 >1987 Reset all to defaults1988 </button>1989 </div>1990 </div>1991 );19921993 function renderRow(1994 desc: typeof SHORTCUT_DESCRIPTORS[number],1995 capturing: () => ShortcutAction | null,1996 onStart: (a: ShortcutAction) => void,1997 onCancel: () => void,1998 onReset: (a: ShortcutAction) => void,1999 onKey: (e: KeyboardEvent) => void,2000 ) {2001 const isActive = () => capturing() === desc.action;2002 const binding = () => props.shortcuts[desc.action];2003 const isCustom = () => {2004 const b = binding();2005 const d = DEFAULT_BINDINGS[desc.action];2006 return b.key !== d.key2007 || Boolean(b.ctrl) !== Boolean(d.ctrl)2008 || Boolean(b.meta) !== Boolean(d.meta)2009 || Boolean(b.shift) !== Boolean(d.shift)2010 || Boolean(b.alt) !== Boolean(d.alt);2011 };2012 return (2013 <div class="peach-shortcut-row">2014 <span class="peach-shortcut-row-label">{desc.label}</span>2015 <button2016 class="peach-shortcut-chip"2017 classList={{ "is-capturing": isActive(), "is-custom": isCustom() }}2018 onClick={() => isActive() ? onCancel() : onStart(desc.action)}2019 onKeyDown={(e) => { if (isActive()) onKey(e); }}2020 ref={(el) => {2021 2022 if (el) {2023 queueMicrotask(() => { if (isActive()) el.focus(); });2024 }2025 }}2026 >2027 {isActive() ? "Press a key…" : formatKeybinding(binding())}2028 </button>2029 <Show when={isCustom() && !isActive()}>2030 <button2031 class="peach-shortcut-reset"2032 title="Reset to default"2033 onClick={() => onReset(desc.action)}2034 >2035 ↺2036 </button>2037 </Show>2038 </div>2039 );2040 }2041 }20422043 const PRO_FEATURES: Array<{2044 feature: ProFeature;2045 name: string;2046 desc: string;2047 }> = [2048 { feature: "sync", name: "Peach Sync", desc: "Encrypted · peer-to-peer" },2049 { feature: "codex", name: "Peach Codex", desc: "Restore from paper" },2050 { feature: "passkeys", name: "Passkeys", desc: "Zero-knowledge WebAuthn" },2051 { feature: "advanced_health", name: "Advanced Vault Health", desc: "Reuse · age · exposure" },2052 ];20532054 const PRO_SECURITY_FEATURES: Array<{2055 feature: ProFeature;2056 name: string;2057 desc: string;2058 }> = [2059 { feature: "canary_entry", name: "Canary entries", desc: "Detects vault compromise" },2060 { feature: "duress_vault", name: "Duress vault", desc: "Decoy under coercion" },2061 ];20622063 const [licenseKey, setLicenseKey] = createSignal<string | null>(null);2064 const [showKey, setShowKey] = createSignal(false);20652066 const [deactivating, setDeactivating] = createSignal(false);20672068 async function handleDeactivateLicense() {2069 setDeactivating(true);2070 await sendMessage({ type: "DEACTIVATE_LICENSE" });2071 setLicenseInfo(null);2072 await props.onLicenseChange?.();2073 setDeactivating(false);2074 }20752076 async function handleTestCanary(entry: {id: string; title: string; canary: Canary}) {2077 setCanaryTestingId(entry.id);2078 setCanaryTestResult(null);2079 const res = await sendMessage({2080 type: "FIRE_CANARY_PING",2081 payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger: "test" },2082 }) as { ok: boolean };2083 setCanaryTestResult({ id: entry.id, ok: res.ok });2084 setCanaryTestingId(null);2085 setTimeout(() => setCanaryTestResult((r) => r?.id === entry.id ? null : r), 3000);2086 }20872088 function renderLicense() {2089 async function onActivate() {2090 await handleActivateLicense();2091 if ((licenseInfo()?.valid ?? false) && licenseInfo()?.plan === "pro") {2092 await props.onLicenseChange?.();2093 }2094 }20952096 return (2097 <div class="peach-settings-body">2098 <Show2099 when={isPro()}2100 fallback={2101 <div class="peach-pro-upgrade">2102 <p class="peach-pro-eyebrow">PEACH PRO · ONE-TIME</p>21032104 <h2 class="peach-pro-headline">2105 Every<em>thing,</em><br />every<em>where.</em>2106 </h2>21072108 <p class="peach-pro-subtitle">2109 One key unlocks every Pro feature — for the lifetime of the app.2110 </p>21112112 <div class="peach-pro-grid">2113 <For each={PRO_FEATURES}>2114 {(item, i) => (2115 <div2116 class="peach-pro-grid-item"2117 classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}2118 >2119 <span class="peach-pro-grid-num">{String(i() + 1).padStart(2, "0")}</span>2120 <span class="peach-pro-grid-name">{item.name}</span>2121 <span class="peach-pro-grid-desc">{item.desc}</span>2122 </div>2123 )}2124 </For>2125 </div>21262127 <p class="peach-pro-section-label">ADVANCED SECURITY</p>2128 <div class="peach-pro-grid">2129 <For each={PRO_SECURITY_FEATURES}>2130 {(item, i) => (2131 <div2132 class="peach-pro-grid-item"2133 classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}2134 >2135 <span class="peach-pro-grid-num">{String(PRO_FEATURES.length + i() + 1).padStart(2, "0")}</span>2136 <span class="peach-pro-grid-name">{item.name}</span>2137 <span class="peach-pro-grid-desc">{item.desc}</span>2138 </div>2139 )}2140 </For>2141 </div>21422143 <div class="peach-pro-upgrade-actions">2144 <p class="peach-pro-pricing">2145 $39 <span class="peach-pro-pricing-sub">once · yours forever</span>2146 </p>2147 <a2148 href="https://buy.polar.sh/polar_cl_IXSFIPojx67rcOTksn8LkH5mnU3osPHtYqBIl1eyuIz"2149 target="_blank"2150 rel="noopener noreferrer"2151 class="peach-pro-upgrade-cta"2152 >2153 Get Peach Pro2154 </a>2155 <div class="peach-pro-key-divider"><span>ALREADY HAVE A KEY</span></div>2156 <div class="peach-pro-key-inline">2157 <input2158 class="peach-pro-key-inline-input"2159 type="text"2160 placeholder="PEACH-XXXX-XXXX-XXXX"2161 value={licenseKeyInput()}2162 onInput={(e) => setLicenseKeyInput(e.currentTarget.value)}2163 onKeyDown={(e) => { if (e.key === "Enter") void onActivate(); }}2164 autocomplete="off"2165 spellcheck={false}2166 />2167 <button2168 class="peach-pro-key-apply"2169 onClick={() => void onActivate()}2170 disabled={activatingLicense() || !licenseKeyInput().trim()}2171 >2172 {activatingLicense() ? "…" : "APPLY"}2173 </button>2174 </div>2175 <Show when={licenseError()}>2176 <p class="peach-pro-key-error">{licenseError()}</p>2177 </Show>2178 </div>2179 </div>2180 }2181 >2182 <div class="peach-pro-active">2183 <div class="peach-pro-active-hero">2184 <button2185 class="peach-pro-active-mark-wrap"2186 type="button"2187 aria-label="Celebrate"2188 onClick={handleProCelebrationClick}2189 >2190 <PeachMark class="peach-pro-active-mark" />2191 <Show when={justActivated()}>2192 <div class="peach-pro-sparks" aria-hidden="true">2193 <span class="peach-pro-spark" style="--spark-x:0px;--spark-y:-54px;--spark-delay:0ms">✦</span>2194 <span class="peach-pro-spark" style="--spark-x:38px;--spark-y:-38px;--spark-delay:50ms">✦</span>2195 <span class="peach-pro-spark" style="--spark-x:54px;--spark-y:0px;--spark-delay:110ms">·</span>2196 <span class="peach-pro-spark" style="--spark-x:38px;--spark-y:38px;--spark-delay:30ms">✦</span>2197 <span class="peach-pro-spark" style="--spark-x:0px;--spark-y:54px;--spark-delay:80ms">·</span>2198 <span class="peach-pro-spark" style="--spark-x:-38px;--spark-y:38px;--spark-delay:130ms">✦</span>2199 <span class="peach-pro-spark" style="--spark-x:-54px;--spark-y:0px;--spark-delay:55ms">·</span>2200 <span class="peach-pro-spark" style="--spark-x:-38px;--spark-y:-38px;--spark-delay:95ms">✦</span>2201 </div>2202 </Show>2203 </button>2204 <h2 class="peach-pro-active-title">Peach Pro</h2>2205 <p class="peach-pro-active-since">{formatActivatedSince(licenseInfo()?.issued)}</p>2206 <Show when={justActivated()}>2207 <p class="peach-pro-thankyou-text" aria-live="polite">thank you ✦</p>2208 </Show>2209 </div>22102211 <Show when={licenseKey()}>2212 <div class="peach-settings-license">2213 <span class="peach-settings-label">LICENSE KEY</span>2214 <div class="peach-settings-license-row">2215 <Show2216 when={showKey()}2217 fallback={2218 <span class="peach-settings-license-key peach-settings-license-key--masked" aria-label="License key hidden">2219 ••••••••••••••••••••••••••••2220 </span>2221 }2222 >2223 <span class="peach-settings-license-key">{licenseKey()!}</span>2224 </Show>2225 <div class="peach-settings-license-actions">2226 <button2227 class="peach-settings-license-copy"2228 title="Copy license key"2229 onClick={() => copyLicenseKey(licenseKey()!)}2230 >2231 {copied() ? "✓" : "copy"}2232 </button>2233 <button2234 class="peach-settings-license-toggle"2235 aria-label={showKey() ? "Hide license key" : "Reveal license key"}2236 title={showKey() ? "Hide" : "Reveal"}2237 onClick={() => setShowKey(v => !v)}2238 >2239 <Show2240 when={showKey()}2241 fallback={2242 <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">2243 <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />2244 <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />2245 </svg>2246 }2247 >2248 <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">2249 <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />2250 <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />2251 <line x1="2" y1="2" x2="12" y2="12" />2252 </svg>2253 </Show>2254 </button>2255 </div>2256 </div>2257 </div>2258 </Show>22592260 <div class="peach-pro-active-features">2261 <For each={[...PRO_FEATURES, ...PRO_SECURITY_FEATURES]}>2262 {(item) => (2263 <div class="peach-pro-active-feature">2264 <span class="peach-pro-active-feature-mark">✦</span>2265 <span class="peach-pro-active-feature-name">{item.name}</span>2266 </div>2267 )}2268 </For>2269 </div>22702271 <button2272 class="peach-pro-deactivate"2273 onClick={() => void handleDeactivateLicense()}2274 disabled={deactivating()}2275 >2276 {deactivating() ? "Deactivating…" : "Deactivate"}2277 </button>2278 </div>2279 </Show>2280 </div>2281 );2282 }22832284 function renderFeedback() {2285 return (2286 <div class="peach-settings-body">2287 <div class="peach-settings-group peach-feedback-group">2288 <Show2289 when={feedbackState() === 'sent'}2290 fallback={2291 <>2292 <p class="peach-feedback-intro">2293 Help us improve Peach. Every report goes directly to our inbox.2294 </p>22952296 <p class="peach-settings-sub-label">TYPE</p>2297 <div class="peach-feedback-chips">2298 <For each={FEEDBACK_CATEGORIES}>2299 {(cat) => {2300 const selected = () => fbCategory() === cat;2301 return (2302 <button2303 type="button"2304 class={`peach-feedback-chip ${selected() ? "is-active" : ""}`}2305 aria-pressed={selected()}2306 onClick={() => setFbCategory(cat)}2307 >2308 {cat}2309 </button>2310 );2311 }}2312 </For>2313 </div>23142315 <p class="peach-settings-sub-label">MESSAGE</p>2316 <textarea2317 class="peach-feedback-textarea"2318 placeholder="Describe the issue or idea..."2319 maxLength={MAX_FEEDBACK_MESSAGE_LENGTH}2320 value={fbMessage()}2321 onInput={(e) => setFbMessage(e.currentTarget.value)}2322 />23232324 <p class="peach-feedback-meta">2325 Version {version} · will be included automatically2326 </p>23272328 <button2329 type="button"2330 class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"2331 disabled={!fbMessage().trim() || feedbackState() === 'sending'}2332 onClick={() => void handleSubmitFeedback()}2333 >2334 <Switch>2335 <Match when={feedbackState() === 'idle'}>Send</Match>2336 <Match when={feedbackState() === 'sending'}>Sending...</Match>2337 <Match when={feedbackState() === 'error'}>Failed - try again</Match>2338 </Switch>2339 </button>23402341 <p class="peach-feedback-anon">2342 Sent anonymously · no account required2343 </p>2344 </>2345 }2346 >2347 <div class="peach-feedback-thanks" role="status" aria-live="polite">2348 <p class="peach-feedback-thanks-title">Thank you</p>2349 <p>2350 Your note is in the queue. Someone will process it soon.2351 </p>2352 </div>2353 </Show>2354 </div>2355 </div>2356 );2357 }23582359 const bodies: StackBodies = {2360 protection: renderSecurity(),2361 vault: renderVault(),2362 sync: renderSync(),2363 appearance: renderAppearance(),2364 shortcuts: renderShortcuts(),2365 pro: renderLicense(),2366 feedback: renderFeedback(),2367 };23682369 return (2370 <Show2371 when={showNewRecoveryWords()}2372 fallback={2373 <div2374 class="peach-overlay-view peach-settings-overlay"2375 classList={{ "ov-enter-right": !props.isExiting, "ov-exit-right": !!props.isExiting }}2376 style="display:flex;flex-direction:column"2377 >2378 <StackDeck2379 active={activeCard()}2380 onActiveChange={setActiveCard}2381 onBack={props.onBack}2382 cards={cards()}2383 bodies={bodies}2384 version={version}2385 isFanning={isFanning()}2386 celebratingCardId={justActivated() ? "pro" : null}2387 isPro={isPro()}2388 />2389 </div>2390 }2391 >2392 <RecoveryWordDisplayView onDone={() => setShowNewRecoveryWords(false)} />2393 </Show>2394 );2395}