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 const [pinEnabled, setPinEnabled] = createSignal(false);516 const [pinFlow, setPinFlow] = createSignal<"setup" | "disable" | null>(null);517 const [pinCurrentPassword, setPinCurrentPassword] = createSignal("");518 const [pinValue, setPinValue] = createSignal("");519 const [pinConfirm, setPinConfirm] = createSignal("");520 const [pinSaving, setPinSaving] = createSignal(false);521 const [pinError, setPinError] = createSignal<string | null>(null);522523 524 const [showViewWords, setShowViewWords] = createSignal(false);525 const [viewWordsPassword, setViewWordsPassword] = createSignal("");526 const [viewWordsError, setViewWordsError] = createSignal<string | null>(null);527 const [viewedWords, setViewedWords] = createSignal<string[] | null>(null);528 const [viewWordsBusy, setViewWordsBusy] = createSignal(false);529530 async function handleViewRecoveryWords() {531 setViewWordsError(null);532 if (!viewWordsPassword()) {533 setViewWordsError("Enter your master password.");534 return;535 }536 setViewWordsBusy(true);537 try {538 const r = (await sendMessage({539 type: "GET_RECOVERY_WORDS",540 payload: { password: viewWordsPassword() },541 })) as { ok: boolean; data?: string[]; error?: string };542 if (r.ok) {543 setViewedWords(r.data ?? []);544 setViewWordsPassword("");545 return;546 }547 switch (r.error) {548 case "wrong_password":549 setViewWordsError("Incorrect password");550 break;551 case "password_required":552 setViewWordsError("Enter your master password.");553 break;554 case "recovery_not_setup":555 setViewWordsError("Recovery words aren't set up for this vault yet.");556 break;557 default:558 setViewWordsError("Couldn't retrieve recovery words.");559 }560 } finally {561 setViewWordsBusy(false);562 }563 }564 function closeViewWords() {565 setShowViewWords(false);566 setViewWordsPassword("");567 setViewWordsError(null);568 setViewedWords(null);569 }570571 const [duressEnabled, setDuressEnabled] = createSignal(false);572 const [showDuressSetup, setShowDuressSetup] = createSignal(false);573 const [duressPassword, setDuressPassword] = createSignal("");574 const [duressConfirm, setDuressConfirm] = createSignal("");575 const [duressMainPassword, setDuressMainPassword] = createSignal("");576 const [duressSaving, setDuressSaving] = createSignal(false);577 const [duressError, setDuressError] = createSignal<string | null>(null);578 const [duressSuccess, setDuressSuccess] = createSignal<string | null>(null);579580 const [canaryEntries, setCanaryEntries] = createSignal<Array<{id: string; title: string; canary: Canary}>>([]);581582 const [canaryTestingId, setCanaryTestingId] = createSignal<string | null>(null);583 const [canaryTestResult, setCanaryTestResult] = createSignal<{id: string; ok: boolean} | null>(null);584585 const [fbCategory, setFbCategory] = createSignal("Bug report");586 const [fbMessage, setFbMessage] = createSignal("");587 type FeedbackState = 'idle' | 'sending' | 'sent' | 'error';588 const [feedbackState, setFeedbackState] = createSignal<FeedbackState>('idle');589590 createEffect(() => {591 if (props.upgradeHighlight) {592 setActiveCard("pro");593 }594 });595596 onMount(async () => {597 const [licenseRes, passkeyRes, duressRes, settingsRes, keyRes] = await Promise.all([598 sendMessage({ type: "GET_LICENSE" }),599 sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }),600 sendMessage({ type: "IS_DURESS_ENABLED" }),601 sendMessage({ type: "GET_SETTINGS" }),602 sendMessage({ type: "GET_LICENSE_KEY" }),603 ]);604605 606 607 void loadVaultHistory();608609 610 611 void loadPendingVouched();612 void loadPairedDevices();613 void loadSyncState();614 const onBroadcast = (msg: unknown) => {615 const m = msg as { type?: string; payload?: unknown } | null;616 if (m?.type === "VOUCHED_DEVICE_PENDING") {617 void loadPendingVouched();618 }619 if (m?.type === "SYNC_STATE_CHANGED") {620 const payload = (m.payload ?? {}) as SyncOutcomeSummary;621 setSyncSummary(payload);622 setSyncStatus(payload.status ?? "idle");623 }624 };625 chrome.runtime?.onMessage?.addListener(onBroadcast);626 onCleanup(() => {627 try { chrome.runtime?.onMessage?.removeListener(onBroadcast); } catch { }628 });629630 if ((licenseRes as { ok: boolean }).ok) {631 setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null);632 }633634 if ((passkeyRes as { ok: boolean }).ok) {635 setPasskeyState(636 (passkeyRes as { ok: boolean; data?: PasskeyProviderState }).data ?? null637 );638 }639640 if ((duressRes as { ok: boolean }).ok) {641 setDuressEnabled((duressRes as { ok: boolean; data?: boolean }).data ?? false);642 }643644 if ((settingsRes as { ok: boolean }).ok) {645 const sData = (settingsRes as { ok: boolean; data?: { declinedCardFingerprints?: string[]; pinEnabled?: boolean } }).data;646 setDeclinedCardCount(sData?.declinedCardFingerprints?.length ?? 0);647 setPinEnabled(sData?.pinEnabled === true);648 }649650 if ((keyRes as { ok: boolean }).ok) {651 setLicenseKey((keyRes as { ok: boolean; data?: string }).data ?? null);652 }653654 const entriesRes = await sendMessage({ type: "GET_ENTRIES" });655 if ((entriesRes as { ok: boolean }).ok) {656 const entries = (entriesRes as { ok: boolean; data?: Array<{id: string; title: string; canary?: Canary}> }).data ?? [];657 setCanaryEntries(658 entries659 .filter((e) => e.canary?.webhookUrl)660 .map((e) => ({ id: e.id, title: e.title, canary: e.canary! }))661 );662 }663664665 if (isNativePlatform()) {666 const biometric = nativePlugin<{ isAvailable: () => Promise<{ available: boolean }> }>("BiometricPlugin");667 const keystore = nativePlugin<{ hasMasterKey: () => Promise<{ exists: boolean }> }>("KeystorePlugin");668 try {669 if (biometric) {670 const a = await biometric.isAvailable();671 setBiometricSupported(a.available);672 }673 if (keystore) {674 const k = await keystore.hasMasterKey();675 setBiometricStored(k.exists);676 }677 } catch { }678 }679 });680681 async function handleEnableBiometrics() {682 setBiometricBusy(true);683 setBiometricError(null);684 try {685 const keyRes = (await sendMessage({ type: "GET_DERIVED_KEY" })) as {686 ok: boolean;687 data?: { masterKeyHex: string };688 error?: string;689 };690 if (!keyRes.ok || !keyRes.data?.masterKeyHex) {691 setBiometricError(keyRes.error === "vault_locked"692 ? "unlock the vault first"693 : "couldn't read derived key");694 return;695 }696 const keystore = nativePlugin<{ storeMasterKey: (p: { masterKeyHex: string }) => Promise<void> }>("KeystorePlugin");697 if (!keystore) {698 setBiometricError("biometric plugin unavailable");699 return;700 }701 await keystore.storeMasterKey({ masterKeyHex: keyRes.data.masterKeyHex });702 await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: true } });703 setBiometricStored(true);704 } catch (e) {705 setBiometricError(e instanceof Error ? e.message : "failed to enable biometrics");706 } finally {707 setBiometricBusy(false);708 }709 }710711 async function handleDisableBiometrics() {712 setBiometricBusy(true);713 setBiometricError(null);714 try {715 const keystore = nativePlugin<{ clearMasterKey: () => Promise<void> }>("KeystorePlugin");716 if (keystore) await keystore.clearMasterKey();717 await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: false } });718 setBiometricStored(false);719 } catch (e) {720 setBiometricError(e instanceof Error ? e.message : "failed to disable biometrics");721 } finally {722 setBiometricBusy(false);723 }724 }725726 function resetPinFlow() {727 setPinFlow(null);728 setPinCurrentPassword("");729 setPinValue("");730 setPinConfirm("");731 setPinError(null);732 }733734 async function handleSetupPin() {735 setPinError(null);736 if (!pinCurrentPassword()) { setPinError("enter your current password"); return; }737 if (!/^\d{4,12}$/.test(pinValue())) { setPinError("PIN must be 4–12 digits"); return; }738 if (pinValue() !== pinConfirm()) { setPinError("PINs do not match"); return; }739 setPinSaving(true);740 const res = await sendMessage({741 type: "SETUP_PIN",742 payload: { currentPassword: pinCurrentPassword(), pin: pinValue() },743 }) as { ok: boolean; error?: string };744 if (res.ok) {745 setPinEnabled(true);746 resetPinFlow();747 } else {748 setPinError(res.error === "incorrect_current_password" ? "incorrect current password" : "couldn't set up PIN");749 }750 setPinSaving(false);751 }752753 async function handleDisablePin() {754 setPinError(null);755 if (!pinCurrentPassword()) { setPinError("enter your current password"); return; }756 setPinSaving(true);757 const res = await sendMessage({758 type: "DISABLE_PIN",759 payload: { currentPassword: pinCurrentPassword() },760 }) as { ok: boolean; error?: string };761 if (res.ok) {762 setPinEnabled(false);763 resetPinFlow();764 } else {765 setPinError(res.error === "incorrect_current_password" ? "incorrect current password" : "couldn't remove PIN");766 }767 setPinSaving(false);768 }769770 async function handleChangePassword() {771 setChangePwError(null);772 if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; }773 if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; }774 if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; }775 if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; }776 setChangePwSaving(true);777 const res = await sendMessage({778 type: "CHANGE_PASSWORD",779 payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() },780 }) as { ok: boolean; error?: string };781 if (res.ok) {782 setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");783 setShowChangePw(false);784 785 786 787 788 await chrome.storage?.local?.set?.({ peach_recovery_pending: "1" });789 setShowNewRecoveryWords(true);790 } else {791 setChangePwError(res.error ?? "failed to change password");792 }793 setChangePwSaving(false);794 }795796 const [copied, setCopied] = createSignal(false);797798 async function copyLicenseKey(key: string) {799 await navigator.clipboard.writeText(key);800 setCopied(true);801 setTimeout(() => setCopied(false), 2000);802 }803804 async function handleActivateLicense() {805 const key = licenseKeyInput().trim();806 if (!key) return;807 setActivatingLicense(true);808 setLicenseError(null);809 setLicenseSuccess(null);810 const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } });811 setActivatingLicense(false);812 if ((res as { ok: boolean }).ok) {813 const data = (res as { ok: boolean; data?: LicenseInfo }).data;814 if (data?.valid) {815 setLicenseInfo(data);816 setLicenseKey(key);817 setLicenseSuccess("License activated!");818 setLicenseKeyInput("");819 } else {820 setLicenseError("Invalid license key");821 }822 } else {823 setLicenseError("Activation failed");824 }825 }826827 async function handleTogglePasskeys() {828 const current = passkeyState();829 if (!current || !current.supported) return;830 setPasskeySaving(true);831 const res = await sendMessage({832 type: "SET_PASSKEY_PROVIDER_ENABLED",833 payload: { enabled: !current.enabled },834 });835 setPasskeySaving(false);836 if ((res as { ok: boolean }).ok) {837 setPasskeyState(838 (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null839 );840 }841 }842843 async function handleCheckAllBreaches() {844 setCheckingBreaches(true);845 setBreachResult(null);846 const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });847 setCheckingBreaches(false);848 if ((res as { ok: boolean }).ok) {849 const data = (res as { ok: boolean; data?: Record<string, number> }).data ?? {};850 const breached = Object.values(data).filter((count) => count > 0).length;851 setBreachResult(852 breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean"853 );854 props.onBreachCheckDone();855 }856 }857858859 async function handleSetupDuress() {860 const mainPw = duressMainPassword().trim();861 const duressPw = duressPassword().trim();862 const confirmPw = duressConfirm().trim();863864 if (!mainPw) { setDuressError("enter your main password to confirm"); return; }865 if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; }866 if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; }867 if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; }868869 setDuressSaving(true);870 setDuressError(null);871872 const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };873 if (!verifyRes.ok || !verifyRes.data) {874 setDuressError("incorrect main password");875 setDuressSaving(false);876 return;877 }878879 const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string };880 setDuressSaving(false);881 if (res.ok) {882 setDuressEnabled(true);883 setShowDuressSetup(false);884 setDuressPassword("");885 setDuressConfirm("");886 setDuressMainPassword("");887 setDuressSuccess("Duress password set. If entered, a decoy vault will be shown.");888 setTimeout(() => setDuressSuccess(null), 5000);889 } else {890 setDuressError(891 res.error === "session_expired"892 ? "Vault session expired — lock and unlock Peach, then try again"893 : res.error ?? "failed to set duress password"894 );895 }896 }897898 async function handleClearDeclinedCards() {899 await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } });900 setDeclinedCardCount(0);901 }902903 async function handleCountDuplicates() {904 setDedupRunning(true);905 setDedupDone(null);906 const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number };907 setDedupRunning(false);908 if (res.ok) setDedupCount(res.data ?? 0);909 }910911 async function handleDeduplicate() {912 setDedupRunning(true);913 const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number };914 setDedupRunning(false);915 if (res.ok) {916 setDedupDone(res.data ?? 0);917 setDedupCount(0);918 }919 }920921 async function handleDisableDuress() {922 const mainPw = duressMainPassword().trim();923 if (!mainPw) { setDuressError("enter your main password to confirm"); return; }924925 setDuressSaving(true);926 setDuressError(null);927928 const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };929 if (!verifyRes.ok || !verifyRes.data) {930 setDuressError("incorrect main password");931 setDuressSaving(false);932 return;933 }934935 const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string };936 setDuressSaving(false);937 if (res.ok) {938 setDuressEnabled(false);939 setShowDuressSetup(false);940 setDuressMainPassword("");941 setDuressSuccess("Duress password disabled.");942 setTimeout(() => setDuressSuccess(null), 4000);943 } else {944 setDuressError(res.error ?? "failed to disable duress password");945 }946 }947948 async function handleSubmitFeedback() {949 const message = sanitizeFeedbackMessage(fbMessage());950 if (!message) return;951952 setFeedbackState('sending');953954 try {955 const hasDataPermission = await requestFeedbackDataPermission();956 if (!hasDataPermission) {957 setFeedbackState('idle');958 return;959 }960961 const category = FEEDBACK_CATEGORIES.includes(fbCategory())962 ? fbCategory()963 : FEEDBACK_CATEGORIES[0];964 const res = await fetch('https://api.web3forms.com/submit', {965 method: 'POST',966 headers: { 'Content-Type': 'application/json' },967 body: JSON.stringify({968 access_key: WEB3FORMS_KEY,969 subject: `Peach Feedback - ${category}`,970 message,971 from_name: 'Peach User',972 973 botcheck: '',974 }),975 });976977 if (!res.ok) throw new Error(`HTTP ${res.status}`);978 const data = await res.json() as { success: boolean; message?: string };979 if (!data.success) throw new Error(data.message ?? 'unknown error');980981 setFeedbackState('sent');982 setFbMessage('');983 setFbCategory(FEEDBACK_CATEGORIES[0]);984 } catch (err) {985 console.error('[feedback]', err);986 setFeedbackState('error');987 setTimeout(() => setFeedbackState('idle'), 4000);988 }989 }990991 function renderSecurity() {992 return (993 <div class="peach-settings-body">994 <div class="peach-settings-group">995 <p class="peach-settings-sub-label">LOCKING</p>996 <div class="peach-settings-row">997 <span>Lock on close</span>998 <button999 class={`peach-toggle ${props.lockOnClose ? "is-on" : ""}`}1000 onClick={() => void props.onLockOnCloseChange(!props.lockOnClose)}1001 aria-label="Toggle lock on close"1002 >1003 <span class="peach-toggle-knob"></span>1004 </button>1005 </div>1006 <div class="peach-settings-row">1007 <span>Auto-lock after</span>1008 <select1009 class="peach-settings-select"1010 value={String(props.autoLockMinutes)}1011 onChange={(e) => void props.onAutoLockMinutesChange(Number(e.currentTarget.value))}1012 >1013 <option value="1">1 minute</option>1014 <option value="5">5 minutes</option>1015 <option value="15">15 minutes</option>1016 <option value="30">30 minutes</option>1017 <option value="0">Never</option>1018 </select>1019 </div>10201021 <p class="peach-settings-sub-label">QUICK UNLOCK</p>1022 <div class="peach-settings-row peach-settings-row--meta">1023 <span>PIN unlock</span>1024 <button1025 class="peach-btn peach-btn--subtle peach-btn--sm"1026 onClick={() => { setPinFlow(pinEnabled() ? "disable" : "setup"); setPinError(null); }}1027 >1028 {pinEnabled() ? "remove" : "set up"}1029 </button>1030 </div>1031 <Show when={pinFlow() === "setup"}>1032 <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1033 <input1034 class="peach-settings-input"1035 type="password"1036 placeholder="current password"1037 value={pinCurrentPassword()}1038 onInput={(e) => setPinCurrentPassword((e.target as HTMLInputElement).value)}1039 autocomplete="current-password"1040 />1041 <input1042 class="peach-settings-input"1043 type="password"1044 inputmode="numeric"1045 maxlength="12"1046 placeholder="new 4–12 digit PIN"1047 value={pinValue()}1048 onInput={(e) => setPinValue((e.target as HTMLInputElement).value.replace(/\D/g, "").slice(0, 12))}1049 autocomplete="off"1050 />1051 <input1052 class="peach-settings-input"1053 type="password"1054 inputmode="numeric"1055 maxlength="12"1056 placeholder="confirm PIN"1057 value={pinConfirm()}1058 onInput={(e) => setPinConfirm((e.target as HTMLInputElement).value.replace(/\D/g, "").slice(0, 12))}1059 autocomplete="off"1060 onKeyDown={(e) => { if (e.key === "Enter") void handleSetupPin(); }}1061 />1062 <Show when={pinError()}>1063 <p class="peach-hint peach-hint-error" style="margin:0">{pinError()}</p>1064 </Show>1065 <button1066 class="peach-btn peach-btn--accent peach-btn--sm"1067 style="align-self:flex-start"1068 onClick={() => void handleSetupPin()}1069 disabled={pinSaving() || !pinCurrentPassword() || !/^\d{4,12}$/.test(pinValue()) || !/^\d{4,12}$/.test(pinConfirm())}1070 >1071 {pinSaving() ? "saving…" : "save PIN"}1072 </button>1073 </div>1074 </Show>1075 <Show when={pinFlow() === "disable"}>1076 <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1077 <input1078 class="peach-settings-input"1079 type="password"1080 placeholder="current password"1081 value={pinCurrentPassword()}1082 onInput={(e) => setPinCurrentPassword((e.target as HTMLInputElement).value)}1083 autocomplete="current-password"1084 onKeyDown={(e) => { if (e.key === "Enter") void handleDisablePin(); }}1085 />1086 <Show when={pinError()}>1087 <p class="peach-hint peach-hint-error" style="margin:0">{pinError()}</p>1088 </Show>1089 <button1090 class="peach-btn peach-btn--subtle peach-btn--sm"1091 style="align-self:flex-start"1092 onClick={() => void handleDisablePin()}1093 disabled={pinSaving() || !pinCurrentPassword()}1094 >1095 {pinSaving() ? "removing…" : "remove PIN"}1096 </button>1097 </div>1098 </Show>10991100 <Show when={isNativePlatform() && biometricSupported()}>1101 <p class="peach-settings-sub-label">BIOMETRICS</p>1102 <div class="peach-settings-row">1103 <span>Unlock with biometrics</span>1104 <button1105 class={`peach-toggle ${biometricStored() ? "is-on" : ""}`}1106 onClick={() => void (biometricStored() ? handleDisableBiometrics() : handleEnableBiometrics())}1107 disabled={biometricBusy()}1108 aria-label="Toggle biometric unlock"1109 >1110 <span class="peach-toggle-knob"></span>1111 </button>1112 </div>1113 <Show when={biometricError()}>1114 <div class="peach-settings-row peach-settings-row--meta">1115 <span class="peach-hint peach-hint-error">{biometricError()}</span>1116 </div>1117 </Show>1118 </Show>11191120 <p class="peach-settings-sub-label">BREACH PROTECTION</p>1121 <div class="peach-settings-row">1122 <span>Breach monitoring</span>1123 <button1124 class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}1125 onClick={() => void props.onBreachMonitoringChange(!props.breachMonitoring)}1126 aria-label="Toggle breach monitoring"1127 >1128 <span class="peach-toggle-knob"></span>1129 </button>1130 </div>1131 <div class="peach-settings-row peach-settings-row--meta">1132 <span>Check all passwords</span>1133 <button1134 class="peach-btn peach-btn--subtle peach-btn--sm"1135 onClick={() => void handleCheckAllBreaches()}1136 disabled={checkingBreaches()}1137 >1138 {checkingBreaches() ? "checking..." : "run"}1139 </button>1140 </div>1141 <Show when={breachResult()}>1142 <div class="peach-settings-row peach-settings-row--meta">1143 <span>Last check</span>1144 <span class="peach-settings-value">{breachResult()}</span>1145 </div>1146 </Show>11471148 <p class="peach-settings-sub-label">MASTER PASSWORD</p>1149 <div class="peach-settings-row peach-settings-row--meta">1150 <span>Master password</span>1151 <button1152 class="peach-btn peach-btn--subtle peach-btn--sm"1153 onClick={() => { setShowChangePw((v) => !v); setChangePwError(null); }}1154 >1155 {showChangePw() ? "cancel" : "change"}1156 </button>1157 </div>1158 <Show when={showChangePw()}>1159 <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1160 <input1161 class="peach-settings-input"1162 type="password"1163 placeholder="current password"1164 value={changePwCurrent()}1165 onInput={(e) => setChangePwCurrent((e.target as HTMLInputElement).value)}1166 autocomplete="current-password"1167 />1168 <input1169 class="peach-settings-input"1170 type="password"1171 placeholder="new password (min 12 chars)"1172 value={changePwNew()}1173 onInput={(e) => setChangePwNew((e.target as HTMLInputElement).value)}1174 autocomplete="new-password"1175 />1176 <input1177 class="peach-settings-input"1178 type="password"1179 placeholder="confirm new password"1180 value={changePwConfirm()}1181 onInput={(e) => setChangePwConfirm((e.target as HTMLInputElement).value)}1182 autocomplete="new-password"1183 onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }}1184 />1185 <Show when={changePwError()}>1186 <p class="peach-hint peach-hint-error" style="margin:0">{changePwError()}</p>1187 </Show>1188 <Show when={changePwSuccess()}>1189 <p class="peach-hint" style="margin:0;color:var(--peach-accent)">password changed ✓</p>1190 </Show>1191 <button1192 class="peach-btn peach-btn--accent peach-btn--sm"1193 style="align-self:flex-start"1194 onClick={() => void handleChangePassword()}1195 disabled={changePwSaving() || !changePwCurrent() || !changePwNew() || !changePwConfirm()}1196 >1197 {changePwSaving() ? "saving…" : "save new password"}1198 </button>1199 </div>1200 </Show>12011202 {}1203 <div class="peach-settings-row peach-settings-row--meta">1204 <span>Recovery words</span>1205 <button1206 class="peach-btn peach-btn--subtle peach-btn--sm"1207 onClick={() => {1208 if (showViewWords()) { closeViewWords(); } else { setShowViewWords(true); }1209 }}1210 >1211 {showViewWords() ? "cancel" : "view"}1212 </button>1213 </div>1214 <Show when={showViewWords()}>1215 <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">1216 <Show1217 when={viewedWords()}1218 fallback={1219 <>1220 <p class="peach-hint" style="margin:0;color:var(--peach-t2)">1221 Enter your master password to reveal your recovery words.1222 </p>1223 <input1224 class="peach-settings-input"1225 type="password"1226 placeholder="master password"1227 value={viewWordsPassword()}1228 onInput={(e) => setViewWordsPassword((e.target as HTMLInputElement).value)}1229 autocomplete="current-password"1230 onKeyDown={(e) => { if (e.key === "Enter") void handleViewRecoveryWords(); }}1231 />1232 <Show when={viewWordsError()}>1233 <p class="peach-hint peach-hint-error" style="margin:0">{viewWordsError()}</p>1234 </Show>1235 <button1236 class="peach-btn peach-btn--accent peach-btn--sm"1237 style="align-self:flex-start"1238 onClick={() => void handleViewRecoveryWords()}1239 disabled={viewWordsBusy() || !viewWordsPassword()}1240 >1241 {viewWordsBusy() ? "checking…" : "reveal recovery words"}1242 </button>1243 </>1244 }1245 >1246 <div class="peach-recovery-words-grid">1247 <For each={viewedWords()!}>1248 {(word, i) => (1249 <span class="peach-recovery-word"><b>{i() + 1}</b> {word}</span>1250 )}1251 </For>1252 </div>1253 <button1254 class="peach-btn peach-btn--subtle peach-btn--sm"1255 style="align-self:flex-start"1256 onClick={closeViewWords}1257 >done</button>1258 </Show>1259 </div>1260 </Show>12611262 <p class="peach-settings-sub-label">1263 DURESS PASSWORD1264 <Show when={!isPro()}>1265 <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>1266 </Show>1267 </p>1268 <div class="peach-settings-row peach-settings-row--top">1269 <div class="peach-settings-row-main">1270 <span>1271 Duress password1272 <Show when={!isPro()}>1273 <span class="peach-pro-badge">Pro</span>1274 </Show>1275 </span>1276 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">1277 If entered under coercion, shows a decoy empty vault instead of your real one.1278 </span>1279 </div>1280 <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>1281 {duressEnabled() ? "on" : "off"}1282 </div>1283 </div>12841285 <Show when={duressEnabled() && !showDuressSetup()}>1286 <div class="peach-settings-row peach-settings-row--meta">1287 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1288 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.1289 </span>1290 </div>1291 </Show>12921293 <Show when={duressSuccess()}>1294 <div class="peach-settings-row peach-settings-row--meta">1295 <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>1296 </div>1297 </Show>12981299 <Show when={!showDuressSetup()}>1300 <div class="peach-settings-row peach-settings-row--meta">1301 <button1302 class="peach-btn peach-btn--subtle peach-btn--sm"1303 onClick={() => {1304 1305 1306 1307 1308 1309 if (!isPro() && !duressEnabled()) {1310 props.onProGate?.("duress_vault");1311 setActiveCard("pro");1312 return;1313 }1314 setShowDuressSetup(true);1315 setDuressError(null);1316 setDuressSuccess(null);1317 setDuressPassword("");1318 setDuressConfirm("");1319 setDuressMainPassword("");1320 }}1321 >1322 {duressEnabled() ? "Change / disable" : "Set up"}1323 </button>1324 </div>1325 </Show>13261327 <Show when={showDuressSetup()}>1328 <div class="peach-duress-form">1329 <label class="peach-label">main password (to confirm)</label>1330 <input1331 class="peach-settings-input"1332 type="password"1333 placeholder="your current master password"1334 autocomplete="current-password"1335 value={duressMainPassword()}1336 onInput={(e) => setDuressMainPassword(e.currentTarget.value)}1337 />1338 <Show when={!duressEnabled()}>1339 <label class="peach-label">new duress password</label>1340 <input1341 class="peach-settings-input"1342 type="password"1343 placeholder="duress password (min 8 chars)"1344 autocomplete="new-password"1345 value={duressPassword()}1346 onInput={(e) => setDuressPassword(e.currentTarget.value)}1347 />1348 <label class="peach-label">confirm duress password</label>1349 <input1350 class="peach-settings-input"1351 type="password"1352 placeholder="confirm duress password"1353 autocomplete="new-password"1354 value={duressConfirm()}1355 onInput={(e) => setDuressConfirm(e.currentTarget.value)}1356 />1357 </Show>13581359 <Show when={duressError()}>1360 <p class="peach-hint peach-hint-error">{duressError()}</p>1361 </Show>13621363 <div class="peach-duress-actions">1364 <button1365 class="peach-btn peach-btn--subtle peach-btn--sm"1366 onClick={() => { setShowDuressSetup(false); setDuressError(null); }}1367 >1368 cancel1369 </button>1370 <Show when={duressEnabled()}>1371 <button1372 class="peach-btn peach-btn--ghost peach-btn--sm"1373 style="color:var(--peach-breach)"1374 onClick={() => void handleDisableDuress()}1375 disabled={duressSaving()}1376 >1377 {duressSaving() ? "disabling..." : "disable duress"}1378 </button>1379 </Show>1380 <Show when={!duressEnabled()}>1381 <button1382 class="peach-btn peach-btn--accent peach-btn--sm"1383 onClick={() => void handleSetupDuress()}1384 disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}1385 >1386 {duressSaving() ? "saving..." : "save"}1387 </button>1388 </Show>1389 </div>1390 </div>1391 </Show>13921393 <p class="peach-settings-sub-label">1394 CANARY ENTRIES1395 <Show when={!isPro()}>1396 <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>1397 </Show>1398 </p>1399 <div class="peach-settings-row peach-settings-row--top">1400 <div class="peach-settings-row-main">1401 <span>1402 Canary entries1403 <Show when={!isPro()}>1404 <span class="peach-pro-badge">Pro</span>1405 </Show>1406 </span>1407 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1408 Silently pings a webhook when an entry is accessed. Configure canary on any entry in your vault.1409 </span>1410 </div>1411 <span class="peach-settings-value" style="flex-shrink:0">1412 {canaryEntries().length > 0 ? `${canaryEntries().length} active` : "none"}1413 </span>1414 </div>1415 <Show when={canaryEntries().length > 0}>1416 <For each={canaryEntries()}>1417 {(entry) => (1418 <div class="peach-settings-row peach-settings-row--meta">1419 <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0">{entry.title}</span>1420 <button1421 class="peach-btn peach-btn--subtle peach-btn--sm"1422 onClick={() => void handleTestCanary(entry)}1423 disabled={canaryTestingId() === entry.id}1424 style="flex-shrink:0"1425 >1426 {canaryTestingId() === entry.id1427 ? "sending…"1428 : canaryTestResult()?.id === entry.id1429 ? (canaryTestResult()!.ok ? "sent ✓" : "failed ✗")1430 : "test"}1431 </button>1432 </div>1433 )}1434 </For>1435 </Show>14361437 <p class="peach-settings-sub-label">PASSKEYS</p>1438 <div class="peach-settings-row peach-settings-row--top">1439 <div class="peach-settings-row-main">1440 <span>Intercept passkey requests</span>1441 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">1442 Peach can handle passkey sign-in and creation on supported Chromium browsers.1443 </span>1444 </div>1445 <button1446 class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}1447 onClick={() => void handleTogglePasskeys()}1448 aria-label="Toggle passkey interception"1449 disabled={!passkeyState()?.supported || passkeySaving()}1450 >1451 <span class="peach-toggle-knob"></span>1452 </button>1453 </div>1454 <div class="peach-settings-row peach-settings-row--meta">1455 <span>Status</span>1456 <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>1457 </div>1458 <div class="peach-settings-row peach-settings-row--meta">1459 <span>Browser</span>1460 <span class="peach-settings-value">1461 {passkeyState()?.browserLabel ?? "Unknown"}1462 {passkeyState()?.supported ? " ✓" : " (interception unavailable)"}1463 </span>1464 </div>1465 <div class="peach-info-notice" style="margin-top:8px">1466 Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.1467 </div>1468 <Show when={passkeyState()?.conflict}>1469 <div class="peach-settings-row peach-settings-row--meta">1470 <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>1471 </div>1472 </Show>1473 </div>1474 </div>1475 );1476 }14771478 function renderVault() {1479 return (1480 <div class="peach-settings-body">1481 <div class="peach-settings-group">1482 <p class="peach-settings-sub-label">ENTRIES</p>1483 <div class="peach-settings-row peach-settings-row--meta">1484 <span>Entries</span>1485 <span class="peach-settings-value">{props.entriesCount}</span>1486 </div>1487 <div class="peach-settings-row peach-settings-row--meta">1488 <span>Duplicates</span>1489 <Show when={dedupDone() !== null} fallback={1490 <button1491 class="peach-btn peach-btn--subtle peach-btn--sm"1492 onClick={() => void handleCountDuplicates()}1493 disabled={dedupRunning()}1494 >1495 {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}1496 </button>1497 }>1498 <span class="peach-settings-value" style="color:var(--peach-sync-ok)">1499 {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}1500 </span>1501 </Show>1502 </div>1503 <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>1504 <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">1505 <div class="peach-restore-warning">1506 {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.1507 </div>1508 <div style="display:flex;gap:6px">1509 <button1510 class="peach-btn peach-btn--subtle peach-btn--sm"1511 onClick={() => setDedupCount(null)}1512 >1513 cancel1514 </button>1515 <button1516 class="peach-btn peach-btn--accent peach-btn--sm"1517 onClick={() => void handleDeduplicate()}1518 disabled={dedupRunning()}1519 >1520 {dedupRunning() ? "removing..." : "remove duplicates"}1521 </button>1522 </div>1523 </div>1524 </Show>15251526 <p class="peach-settings-sub-label">MAINTENANCE</p>1527 <div class="peach-settings-row peach-settings-row--meta">1528 <span>Trash auto-delete</span>1529 <select1530 class="peach-settings-select"1531 value={String(props.trashRetentionDays)}1532 onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}1533 >1534 <option value="7">7 days</option>1535 <option value="30">30 days</option>1536 <option value="90">90 days</option>1537 <option value="0">Never</option>1538 </select>1539 </div>1540 <div class="peach-settings-row peach-settings-row--meta">1541 <span>Advanced Vault Health</span>1542 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>1543 open1544 </button>1545 </div>1546 <Show when={declinedCardCount() > 0}>1547 <div class="peach-settings-row peach-settings-row--meta">1548 <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>1549 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>1550 Clear1551 </button>1552 </div>1553 </Show>15541555 <p class="peach-settings-sub-label">DATA</p>1556 <div class="peach-settings-row peach-settings-row--meta">1557 <span>Import passwords</span>1558 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>1559 open1560 </button>1561 </div>1562 <div class="peach-settings-row peach-settings-row--meta">1563 <span>Export backup</span>1564 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onExport}>1565 open1566 </button>1567 </div>1568 <div class="peach-settings-row peach-settings-row--meta">1569 <span>1570 Peach Codex1571 <Show when={!isPro()}>1572 <span class="peach-pro-badge">Pro</span>1573 </Show>1574 </span>1575 <button1576 class="peach-btn peach-btn--subtle peach-btn--sm"1577 onClick={() => props.onOpenCodexGenerate?.()}1578 >1579 open1580 </button>1581 </div>1582 {1583}1584 <div class="peach-settings-row peach-settings-row--meta">1585 <span>Restore from Codex</span>1586 <button1587 class="peach-btn peach-btn--subtle peach-btn--sm"1588 onClick={() => props.onOpenCodexRestore?.()}1589 >1590 open1591 </button>1592 </div>15931594 <span class="peach-settings-sub-label">VAULT HISTORY</span>1595 <Show when={historyLoading()}>1596 <p class="peach-settings-hint">Loading…</p>1597 </Show>1598 <Show when={!historyLoading() && historySnapshots().length === 0}>1599 <p class="peach-settings-hint">1600 No snapshots yet. Peach saves automatically before every change —1601 saves, syncs, and imports.1602 </p>1603 </Show>1604 <Show when={!historyLoading() && historySnapshots().length > 0}>1605 <p class="peach-settings-hint">1606 Restoring locks your vault. Unlock with the password that1607 encrypted that snapshot to confirm.1608 </p>1609 <div class="peach-history-list">1610 <For each={historySnapshots().slice(0, historyShowAll() ? undefined : 3)}>1611 {(snap) => (1612 <div class="peach-history-row">1613 <div class="peach-history-meta">1614 <span class="peach-history-time">1615 {formatSnapshotTime(snap.savedAt)}1616 </span>1617 <span class="peach-history-sub">1618 {[1619 snap.entryCount !== null1620 ? `up to ${snap.entryCount} ${snap.entryCount === 1 ? "entry" : "entries"}`1621 : null,1622 reasonLabel(snap.reason),1623 ]1624 .filter(Boolean)1625 .join(" · ")}1626 </span>1627 </div>1628 <button1629 class={`peach-btn peach-btn--subtle peach-btn--sm peach-history-btn${1630 historyRestoring() === snap.id ? " is-busy" : ""1631 }`}1632 onClick={() => void handleRestoreVaultSnapshot(snap.id)}1633 disabled={historyRestoring() !== null}1634 >1635 <Show when={historyRestoring() === snap.id} fallback="Restore">1636 …1637 </Show>1638 </button>1639 </div>1640 )}1641 </For>1642 </div>1643 <Show when={historySnapshots().length > 3}>1644 <button1645 class="peach-settings-advanced-toggle"1646 onClick={() => setHistoryShowAll((v) => !v)}1647 aria-expanded={historyShowAll()}1648 >1649 <span class="peach-settings-hint" style="margin:0">1650 {historyShowAll() ? "show less" : `${historySnapshots().length - 3} older`}1651 </span>1652 <span class="peach-settings-advanced-chevron">1653 {historyShowAll() ? "▲" : "▼"}1654 </span>1655 </button>1656 </Show>1657 <p class="peach-history-footer">1658 {historySnapshots().length} of 10 snapshots1659 </p>1660 </Show>16611662 <button1663 class="peach-settings-advanced-toggle"1664 onClick={() => setShowVaultAdvanced((v) => !v)}1665 aria-expanded={showVaultAdvanced()}1666 >1667 <span class="peach-settings-sub-label" style="margin:0">ADVANCED</span>1668 <span class="peach-settings-advanced-chevron">1669 {showVaultAdvanced() ? "▲" : "▼"}1670 </span>1671 </button>16721673 <Show when={showVaultAdvanced()}>1674 <div class="peach-danger-card">1675 <div class="peach-danger-card-head">1676 <span class="peach-danger-card-glyph" aria-hidden="true">⟳</span>1677 <h3 class="peach-danger-card-title">Replace vault from backup</h3>1678 </div>1679 <p class="peach-danger-card-copy">1680 Wipes the current vault and restores from a backup file.1681 Recoverable only if your backup is good.1682 </p>1683 <button1684 class="peach-danger-card-button"1685 onClick={() => props.onOpenFileImport?.()}1686 >1687 Choose backup file1688 </button>1689 </div>16901691 <Show when={props.onOpenPanic}>1692 <div class="peach-danger-card peach-danger-card--hazard">1693 <div class="peach-danger-card-head">1694 <span class="peach-danger-card-glyph" aria-hidden="true">⚠</span>1695 <h3 class="peach-danger-card-title">Destroy vault</h3>1696 </div>1697 <p class="peach-danger-card-copy">1698 Permanently wipes every entry, setting, and license on this1699 device. There is no recovery.1700 </p>1701 <button1702 class="peach-danger-card-button"1703 onClick={() => props.onOpenPanic?.()}1704 >1705 Begin destruction1706 </button>1707 </div>1708 </Show>1709 </Show>1710 </div>1711 </div>1712 );1713 }17141715 function renderSync() {1716 return (1717 <div class="peach-settings-body">1718 <div class="peach-settings-group">1719 <p class="peach-settings-sub-label" style="margin-top:0">SYNC STATUS</p>1720 <div1721 class="peach-settings-row peach-settings-row--meta"1722 style="align-items:center;gap:8px"1723 role="status"1724 aria-label={syncLightLabel()}1725 title={syncLightLabel()}1726 >1727 <span class={`stk-pip stk-pip--sync is-${syncLight()}`} aria-hidden="true" />1728 <span class="peach-settings-value" style="color:var(--peach-t2);line-height:1.5;flex:1;min-width:0;overflow-wrap:anywhere">1729 {syncFlash() ?? lastSyncLine()}1730 </span>1731 </div>1732 <Show when={pairedDevices().length > 0}>1733 <div class="peach-settings-row peach-settings-row--meta">1734 <span class="peach-settings-value" style="color:var(--peach-t2)">1735 {(syncSummary().entriesSent ?? 0) + (syncSummary().entriesReceived ?? 0) > 01736 ? `${syncSummary().entriesSent ?? 0} sent · ${syncSummary().entriesReceived ?? 0} received`1737 : ""}1738 </span>1739 <button1740 class="peach-btn peach-btn--subtle peach-btn--sm"1741 aria-label="Sync this vault now"1742 disabled={syncStatus() === "syncing"}1743 onClick={() => void syncNow()}1744 >{syncStatus() === "syncing" ? "syncing…" : "sync now"}</button>1745 </div>1746 </Show>17471748 <p class="peach-settings-sub-label">ADD A DEVICE</p>1749 <button1750 class="peach-sync-pair-option"1751 onClick={() => props.onOpenSyncPairing?.("code")}1752 >1753 <span class="peach-sync-pair-option-icon" aria-hidden="true">1754 <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">1755 <rect x="6" y="2.5" width="8" height="15" rx="1.6" />1756 <path d="M9 15 L11 15" />1757 </svg>1758 </span>1759 <span class="peach-sync-pair-option-main">1760 <span class="peach-sync-pair-option-title">Show a code</span>1761 <span class="peach-sync-pair-option-sub">Scan it with Peach on your phone, then confirm one 8-digit code</span>1762 </span>1763 <span class="peach-sync-pair-option-chevron" aria-hidden="true">›</span>1764 </button>1765 <Show when={!isPro()}>1766 <p class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2);margin:8px 2px 0">1767 At least one device needs Peach Pro — pair with a Pro device and it carries over to here.1768 </p>1769 </Show>17701771 <Show when={pairedDevices().length > 0}>1772 <p class="peach-settings-sub-label">PAIRED DEVICES</p>1773 <For each={pairedDevices()}>1774 {(d) => (1775 <div class="peach-settings-row peach-settings-row--top">1776 <div class="peach-settings-row-main">1777 <Show1778 when={renameDeviceId() === d.deviceId}1779 fallback={<span>{d.localAlias ?? d.deviceName ?? "Unnamed device"}</span>}1780 >1781 <input1782 class="peach-settings-input"1783 aria-label={`Rename ${d.localAlias ?? d.deviceName ?? "device"}`}1784 maxlength={64}1785 value={renameDraft()}1786 onInput={(event) => setRenameDraft(event.currentTarget.value)}1787 onKeyDown={(event) => {1788 if (event.key === "Enter") void renameDevice(d.deviceId);1789 if (event.key === "Escape") setRenameDeviceId(null);1790 }}1791 />1792 </Show>1793 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1794 {deviceTypeLabel(d.deviceType)} · {syncRelativeTime(d.lastSyncedAt)}{isSyncStale(d) ? " · not seen recently" : ""}1795 </span>1796 </div>1797 <Show1798 when={confirmUnpair() === d.deviceId}1799 fallback={1800 <div style="display:flex;gap:4px;flex-shrink:0">1801 <Show1802 when={renameDeviceId() === d.deviceId}1803 fallback={1804 <button1805 class="peach-btn peach-btn--subtle peach-btn--sm"1806 onClick={() => {1807 setRenameDraft(d.localAlias ?? d.deviceName ?? "");1808 setRenameDeviceId(d.deviceId);1809 }}1810 >rename</button>1811 }1812 >1813 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => setRenameDeviceId(null)}>cancel</button>1814 <button class="peach-btn peach-btn--subtle peach-btn--sm" disabled={!renameDraft().trim()} onClick={() => void renameDevice(d.deviceId)}>save</button>1815 </Show>1816 <button1817 class="peach-btn peach-btn--subtle peach-btn--sm"1818 onClick={() => setConfirmUnpair(d.deviceId)}1819 >remove</button>1820 </div>1821 }1822 >1823 {18241825182618271828}1829 <div class="peach-unpair-confirm">1830 <p class="peach-unpair-confirm-note">1831 Stops this device receiving future changes. It doesn't1832 lock it out of what it already has — if it's lost or1833 stolen, also change your master password and generate1834 new recovery words.1835 </p>1836 <div style="display:flex;gap:6px;justify-content:flex-end">1837 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => setConfirmUnpair(null)}>cancel</button>1838 <button1839 class="peach-btn peach-btn--sm"1840 style="background:var(--peach-breach);border-color:var(--peach-breach)"1841 onClick={() => void unpairDevice(d.deviceId)}1842 >remove</button>1843 </div>1844 </div>1845 </Show>1846 </div>1847 )}1848 </For>1849 </Show>18501851 <Show when={pendingVouched().length > 0}>1852 <p class="peach-settings-sub-label">1853 PENDING DEVICES1854 <span class="peach-pro-badge" style="background:#C8A840;color:#1a1a1a">{pendingVouched().length}</span>1855 </p>1856 <For each={pendingVouched()}>1857 {(dev) => (1858 <div class="peach-settings-row peach-settings-row--top">1859 <div class="peach-settings-row-main">1860 <span>{dev.deviceName}</span>1861 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1862 Was added by another of your devices. Allow it to sync with this vault?1863 </span>1864 </div>1865 <div style="display:flex;gap:6px;flex-shrink:0">1866 <button1867 class="peach-btn peach-btn--sm"1868 disabled={vouchedBusy() === dev.deviceId}1869 onClick={() => void confirmVouched(dev.deviceId)}1870 >Allow</button>1871 <button1872 class="peach-btn peach-btn--subtle peach-btn--sm"1873 disabled={vouchedBusy() === dev.deviceId}1874 onClick={() => void rejectVouched(dev.deviceId)}1875 >Reject</button>1876 </div>1877 </div>1878 )}1879 </For>1880 </Show>18811882 <button1883 class="peach-settings-advanced-toggle peach-sync-disclosure-toggle"1884 onClick={() => setShowSyncConnection((value) => !value)}1885 aria-expanded={showSyncConnection()}1886 aria-controls="peach-sync-connection"1887 >1888 <span>1889 <span class="peach-settings-sub-label" style="margin:0">CONNECTION</span>1890 <span class="peach-sync-disclosure-summary">Relay and privacy choices</span>1891 </span>1892 <span class="peach-settings-advanced-chevron">1893 {showSyncConnection() ? "▲" : "▼"}1894 </span>1895 </button>1896 <Show when={showSyncConnection()}>1897 <div id="peach-sync-connection" class="peach-sync-disclosure-content">1898 <SyncModeSelector />1899 </div>1900 </Show>19011902 <button1903 class="peach-settings-advanced-toggle peach-sync-disclosure-toggle"1904 onClick={() => setShowSyncRecovery((value) => !value)}1905 aria-expanded={showSyncRecovery()}1906 aria-controls="peach-sync-recovery"1907 >1908 <span>1909 <span class="peach-settings-sub-label" style="margin:0">RECOVERY & SECURITY</span>1910 <span class="peach-sync-disclosure-summary">For a lost or stolen device</span>1911 </span>1912 <span class="peach-settings-advanced-chevron">1913 {showSyncRecovery() ? "▲" : "▼"}1914 </span>1915 </button>1916 <Show when={showSyncRecovery()}>1917 <div id="peach-sync-recovery" class="peach-sync-disclosure-content">1918 <Show1919 when={rotateConfirmOpen()}1920 fallback={1921 <div class="peach-settings-row peach-settings-row--top">1922 <div class="peach-settings-row-main">1923 <span>Rotate sync identity</span>1924 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1925 Disconnects every device and replaces the keys used for sync.1926 </span>1927 </div>1928 <button1929 class="peach-btn peach-btn--subtle peach-btn--sm"1930 style="color:var(--peach-breach);border-color:color-mix(in srgb,var(--peach-breach) 40%,transparent)"1931 onClick={() => { setRotateError(null); setRotateConfirmOpen(true); }}1932 >rotate</button>1933 </div>1934 }1935 >1936 <div class="peach-info-notice" style="margin-top:8px;border-color:color-mix(in srgb,var(--peach-breach) 40%,transparent)">1937 This disconnects every paired device and changes your 12 recovery words. You'll be shown the new words next.1938 <Show when={rotateError()}>1939 <div style="color:var(--peach-breach);margin-top:6px">{rotateError()}</div>1940 </Show>1941 <div style="display:flex;gap:6px;margin-top:10px">1942 <button1943 class="peach-btn peach-btn--subtle peach-btn--sm"1944 disabled={rotating()}1945 onClick={() => setRotateConfirmOpen(false)}1946 >Cancel</button>1947 <button1948 class="peach-btn peach-btn--sm"1949 style="background:var(--peach-breach);border-color:var(--peach-breach)"1950 disabled={rotating()}1951 onClick={() => void doRotateSyncIdentity()}1952 >{rotating() ? "Rotating…" : "Rotate sync identity"}</button>1953 </div>1954 </div>1955 </Show>1956 </div>1957 </Show>1958 </div>1959 </div>1960 );1961 }19621963 function renderAppearance() {1964 const sizeOptions: Array<{ id: "medium" | "large"; label: string }> = [1965 { id: "medium", label: "Default" },1966 { id: "large", label: "Large" },1967 ];1968 return (1969 <div class="peach-settings-body">1970 <div class="peach-settings-group">1971 <p class="peach-settings-sub-label">THEME</p>1972 <div class="peach-settings-row peach-settings-row--meta">1973 <span>Theme</span>1974 <button1975 class="peach-btn peach-btn--subtle peach-btn--sm"1976 onClick={() =>1977 props.onThemeChange(props.theme === "dark" ? "light" : "dark")1978 }1979 >1980 {props.theme === "dark" ? "Switch to light" : "Switch to dark"}1981 </button>1982 </div>19831984 <p class="peach-settings-sub-label">ACCENT</p>1985 <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">1986 <span>Accent</span>1987 <div class="peach-accent-picker">1988 <For each={ACCENT_COLORS}>1989 {(color) => (1990 <button1991 class="peach-accent-swatch peach-swatch"1992 classList={{ "is-active": props.accentColor === color.name }}1993 style={{ background: color.hex }}1994 onClick={() => void props.onAccentChange(color.name)}1995 aria-label={color.name}1996 title={color.name}1997 />1998 )}1999 </For>2000 </div>2001 </div>20022003 <p class="peach-settings-sub-label">TEXT SIZE</p>2004 <div class="peach-settings-row peach-settings-row--meta">2005 <span>Size</span>2006 <div class="peach-pill-group">2007 <For each={sizeOptions}>2008 {(opt) => (2009 <button2010 class="peach-btn peach-btn--ghost peach-btn--sm"2011 classList={{ "is-active": props.textSize === opt.id }}2012 onClick={() => void props.onTextSizeChange(opt.id)}2013 >2014 {opt.label}2015 </button>2016 )}2017 </For>2018 </div>2019 </div>20202021 <p class="peach-settings-sub-label">MOTION</p>2022 <div class="peach-settings-row peach-settings-row--meta">2023 <span>Reduce motion</span>2024 <button2025 class="peach-btn peach-btn--subtle peach-btn--sm"2026 onClick={() => void props.onReduceMotionChange(!props.reduceMotion)}2027 >2028 {props.reduceMotion ? "On" : "Off"}2029 </button>2030 </div>2031 </div>2032 </div>2033 );2034 }20352036 function renderShortcuts() {2037 const [capturingAction, setCapturingAction] = createSignal<ShortcutAction | null>(null);2038 const [conflictMsg, setConflictMsg] = createSignal<string | null>(null);20392040 function startCapture(action: ShortcutAction) {2041 setConflictMsg(null);2042 setCapturingAction(action);2043 }2044 function cancelCapture() {2045 setCapturingAction(null);2046 setConflictMsg(null);2047 }2048 function commitBinding(action: ShortcutAction, binding: Keybinding) {2049 const conflict = findConflict(props.shortcuts, binding, action);2050 if (conflict) {2051 const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict);2052 setConflictMsg(`Already bound to "${other?.label ?? conflict}"`);2053 return;2054 }2055 const next = { ...props.shortcuts, [action]: binding };2056 setCapturingAction(null);2057 setConflictMsg(null);2058 void props.onShortcutsChange(next);2059 }2060 function resetAction(action: ShortcutAction) {2061 const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] };2062 void props.onShortcutsChange(next);2063 }2064 function resetAll() {2065 void props.onShortcutsChange({ ...DEFAULT_BINDINGS });2066 }20672068 2069 2070 function handleCaptureKey(e: KeyboardEvent) {2071 e.preventDefault();2072 e.stopPropagation();2073 if (e.key === "Escape") { cancelCapture(); return; }2074 const binding = keybindingFromEvent(e);2075 if (!binding) return; 2076 const action = capturingAction();2077 if (action) commitBinding(action, binding);2078 }20792080 return (2081 <div class="peach-settings-body">2082 <div class="peach-settings-group peach-shortcuts-group">2083 <p class="peach-settings-sub-label">ENTRY VIEW</p>2084 <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry")}>2085 {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}2086 </For>2087 </div>20882089 <div class="peach-settings-group peach-shortcuts-group">2090 <p class="peach-settings-sub-label">ANYWHERE IN POPUP</p>2091 <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global")}>2092 {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}2093 </For>2094 </div>20952096 <Show when={conflictMsg()}>2097 <div class="peach-shortcuts-conflict">{conflictMsg()}</div>2098 </Show>20992100 <div class="peach-settings-group" style="display:flex;flex-direction:column;gap:8px">2101 <div class="peach-settings-row peach-settings-row--meta">2102 <span>Inline autofill on page</span>2103 <a2104 class="peach-settings-link"2105 href="chrome://extensions/shortcuts"2106 onClick={(e) => {2107 e.preventDefault();2108 chrome.tabs.create({ url: "chrome://extensions/shortcuts" });2109 }}2110 >2111 ⌘⇧P — customize ↗2112 </a>2113 </div>2114 <button2115 class="peach-btn peach-btn--subtle peach-btn--sm"2116 style="align-self:flex-start"2117 onClick={resetAll}2118 >2119 Reset all to defaults2120 </button>2121 </div>2122 </div>2123 );21242125 function renderRow(2126 desc: typeof SHORTCUT_DESCRIPTORS[number],2127 capturing: () => ShortcutAction | null,2128 onStart: (a: ShortcutAction) => void,2129 onCancel: () => void,2130 onReset: (a: ShortcutAction) => void,2131 onKey: (e: KeyboardEvent) => void,2132 ) {2133 const isActive = () => capturing() === desc.action;2134 const binding = () => props.shortcuts[desc.action];2135 const isCustom = () => {2136 const b = binding();2137 const d = DEFAULT_BINDINGS[desc.action];2138 return b.key !== d.key2139 || Boolean(b.ctrl) !== Boolean(d.ctrl)2140 || Boolean(b.meta) !== Boolean(d.meta)2141 || Boolean(b.shift) !== Boolean(d.shift)2142 || Boolean(b.alt) !== Boolean(d.alt);2143 };2144 return (2145 <div class="peach-shortcut-row">2146 <span class="peach-shortcut-row-label">{desc.label}</span>2147 <button2148 class="peach-shortcut-chip"2149 classList={{ "is-capturing": isActive(), "is-custom": isCustom() }}2150 onClick={() => isActive() ? onCancel() : onStart(desc.action)}2151 onKeyDown={(e) => { if (isActive()) onKey(e); }}2152 ref={(el) => {2153 2154 if (el) {2155 queueMicrotask(() => { if (isActive()) el.focus(); });2156 }2157 }}2158 >2159 {isActive() ? "Press a key…" : formatKeybinding(binding())}2160 </button>2161 <Show when={isCustom() && !isActive()}>2162 <button2163 class="peach-shortcut-reset"2164 title="Reset to default"2165 onClick={() => onReset(desc.action)}2166 >2167 ↺2168 </button>2169 </Show>2170 </div>2171 );2172 }2173 }21742175 const PRO_FEATURES: Array<{2176 feature: ProFeature;2177 name: string;2178 desc: string;2179 }> = [2180 { feature: "sync", name: "Peach Sync", desc: "Encrypted · peer-to-peer" },2181 { feature: "codex", name: "Peach Codex", desc: "Restore from paper" },2182 { feature: "passkeys", name: "Passkeys", desc: "Zero-knowledge WebAuthn" },2183 { feature: "advanced_health", name: "Advanced Vault Health", desc: "Reuse · age · exposure" },2184 ];21852186 const PRO_SECURITY_FEATURES: Array<{2187 feature: ProFeature;2188 name: string;2189 desc: string;2190 }> = [2191 { feature: "canary_entry", name: "Canary entries", desc: "Detects vault compromise" },2192 { feature: "duress_vault", name: "Duress vault", desc: "Decoy under coercion" },2193 ];21942195 const [licenseKey, setLicenseKey] = createSignal<string | null>(null);2196 const [showKey, setShowKey] = createSignal(false);21972198 const [deactivating, setDeactivating] = createSignal(false);21992200 async function handleDeactivateLicense() {2201 setDeactivating(true);2202 await sendMessage({ type: "DEACTIVATE_LICENSE" });2203 setLicenseInfo(null);2204 await props.onLicenseChange?.();2205 setDeactivating(false);2206 }22072208 async function handleTestCanary(entry: {id: string; title: string; canary: Canary}) {2209 setCanaryTestingId(entry.id);2210 setCanaryTestResult(null);2211 const res = await sendMessage({2212 type: "FIRE_CANARY_PING",2213 payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger: "test" },2214 }) as { ok: boolean };2215 setCanaryTestResult({ id: entry.id, ok: res.ok });2216 setCanaryTestingId(null);2217 setTimeout(() => setCanaryTestResult((r) => r?.id === entry.id ? null : r), 3000);2218 }22192220 function renderLicense() {2221 async function onActivate() {2222 await handleActivateLicense();2223 if ((licenseInfo()?.valid ?? false) && licenseInfo()?.plan === "pro") {2224 await props.onLicenseChange?.();2225 }2226 }22272228 return (2229 <div class="peach-settings-body">2230 <Show2231 when={isPro()}2232 fallback={2233 <div class="peach-pro-upgrade">2234 <p class="peach-pro-eyebrow">PEACH PRO · ONE-TIME</p>22352236 <h2 class="peach-pro-headline">2237 Every<em>thing,</em><br />every<em>where.</em>2238 </h2>22392240 <p class="peach-pro-subtitle">2241 One key unlocks every Pro feature — for the lifetime of the app.2242 </p>22432244 <div class="peach-pro-grid">2245 <For each={PRO_FEATURES}>2246 {(item, i) => (2247 <div2248 class="peach-pro-grid-item"2249 classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}2250 >2251 <span class="peach-pro-grid-num">{String(i() + 1).padStart(2, "0")}</span>2252 <span class="peach-pro-grid-name">{item.name}</span>2253 <span class="peach-pro-grid-desc">{item.desc}</span>2254 </div>2255 )}2256 </For>2257 </div>22582259 <p class="peach-pro-section-label">ADVANCED SECURITY</p>2260 <div class="peach-pro-grid">2261 <For each={PRO_SECURITY_FEATURES}>2262 {(item, i) => (2263 <div2264 class="peach-pro-grid-item"2265 classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}2266 >2267 <span class="peach-pro-grid-num">{String(PRO_FEATURES.length + i() + 1).padStart(2, "0")}</span>2268 <span class="peach-pro-grid-name">{item.name}</span>2269 <span class="peach-pro-grid-desc">{item.desc}</span>2270 </div>2271 )}2272 </For>2273 </div>22742275 <div class="peach-pro-upgrade-actions">2276 <p class="peach-pro-pricing">2277 $39 <span class="peach-pro-pricing-sub">once · yours forever</span>2278 </p>2279 <a2280 href="https://buy.polar.sh/polar_cl_IXSFIPojx67rcOTksn8LkH5mnU3osPHtYqBIl1eyuIz"2281 target="_blank"2282 rel="noopener noreferrer"2283 class="peach-pro-upgrade-cta"2284 >2285 Get Peach Pro2286 </a>2287 <div class="peach-pro-key-divider"><span>ALREADY HAVE A KEY</span></div>2288 <div class="peach-pro-key-inline">2289 <input2290 class="peach-pro-key-inline-input"2291 type="text"2292 placeholder="PEACH-XXXX-XXXX-XXXX"2293 value={licenseKeyInput()}2294 onInput={(e) => setLicenseKeyInput(e.currentTarget.value)}2295 onKeyDown={(e) => { if (e.key === "Enter") void onActivate(); }}2296 autocomplete="off"2297 spellcheck={false}2298 />2299 <button2300 class="peach-pro-key-apply"2301 onClick={() => void onActivate()}2302 disabled={activatingLicense() || !licenseKeyInput().trim()}2303 >2304 {activatingLicense() ? "…" : "APPLY"}2305 </button>2306 </div>2307 <Show when={licenseError()}>2308 <p class="peach-pro-key-error">{licenseError()}</p>2309 </Show>2310 </div>2311 </div>2312 }2313 >2314 <div class="peach-pro-active">2315 <div class="peach-pro-active-hero">2316 <button2317 class="peach-pro-active-mark-wrap"2318 type="button"2319 aria-label="Celebrate"2320 onClick={handleProCelebrationClick}2321 >2322 <PeachMark class="peach-pro-active-mark" />2323 <Show when={justActivated()}>2324 <div class="peach-pro-sparks" aria-hidden="true">2325 <span class="peach-pro-spark" style="--spark-x:0px;--spark-y:-54px;--spark-delay:0ms">✦</span>2326 <span class="peach-pro-spark" style="--spark-x:38px;--spark-y:-38px;--spark-delay:50ms">✦</span>2327 <span class="peach-pro-spark" style="--spark-x:54px;--spark-y:0px;--spark-delay:110ms">·</span>2328 <span class="peach-pro-spark" style="--spark-x:38px;--spark-y:38px;--spark-delay:30ms">✦</span>2329 <span class="peach-pro-spark" style="--spark-x:0px;--spark-y:54px;--spark-delay:80ms">·</span>2330 <span class="peach-pro-spark" style="--spark-x:-38px;--spark-y:38px;--spark-delay:130ms">✦</span>2331 <span class="peach-pro-spark" style="--spark-x:-54px;--spark-y:0px;--spark-delay:55ms">·</span>2332 <span class="peach-pro-spark" style="--spark-x:-38px;--spark-y:-38px;--spark-delay:95ms">✦</span>2333 </div>2334 </Show>2335 </button>2336 <h2 class="peach-pro-active-title">Peach Pro</h2>2337 <p class="peach-pro-active-since">{formatActivatedSince(licenseInfo()?.issued)}</p>2338 <Show when={justActivated()}>2339 <p class="peach-pro-thankyou-text" aria-live="polite">thank you ✦</p>2340 </Show>2341 </div>23422343 <Show when={licenseKey()}>2344 <div class="peach-settings-license">2345 <span class="peach-settings-label">LICENSE KEY</span>2346 <div class="peach-settings-license-row">2347 <Show2348 when={showKey()}2349 fallback={2350 <span class="peach-settings-license-key peach-settings-license-key--masked" aria-label="License key hidden">2351 ••••••••••••••••••••••••••••2352 </span>2353 }2354 >2355 <span class="peach-settings-license-key">{licenseKey()!}</span>2356 </Show>2357 <div class="peach-settings-license-actions">2358 <button2359 class="peach-settings-license-copy"2360 title="Copy license key"2361 onClick={() => copyLicenseKey(licenseKey()!)}2362 >2363 {copied() ? "✓" : "copy"}2364 </button>2365 <button2366 class="peach-settings-license-toggle"2367 aria-label={showKey() ? "Hide license key" : "Reveal license key"}2368 title={showKey() ? "Hide" : "Reveal"}2369 onClick={() => setShowKey(v => !v)}2370 >2371 <Show2372 when={showKey()}2373 fallback={2374 <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">2375 <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />2376 <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />2377 </svg>2378 }2379 >2380 <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">2381 <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />2382 <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />2383 <line x1="2" y1="2" x2="12" y2="12" />2384 </svg>2385 </Show>2386 </button>2387 </div>2388 </div>2389 </div>2390 </Show>23912392 <div class="peach-pro-active-features">2393 <For each={[...PRO_FEATURES, ...PRO_SECURITY_FEATURES]}>2394 {(item) => (2395 <div class="peach-pro-active-feature">2396 <span class="peach-pro-active-feature-mark">✦</span>2397 <span class="peach-pro-active-feature-name">{item.name}</span>2398 </div>2399 )}2400 </For>2401 </div>24022403 <button2404 class="peach-pro-deactivate"2405 onClick={() => void handleDeactivateLicense()}2406 disabled={deactivating()}2407 >2408 {deactivating() ? "Deactivating…" : "Deactivate"}2409 </button>2410 </div>2411 </Show>2412 </div>2413 );2414 }24152416 function renderFeedback() {2417 return (2418 <div class="peach-settings-body">2419 <div class="peach-settings-group peach-feedback-group">2420 <Show2421 when={feedbackState() === 'sent'}2422 fallback={2423 <>2424 <p class="peach-feedback-intro">2425 Help us improve Peach. Every report goes directly to our inbox.2426 </p>24272428 <p class="peach-settings-sub-label">TYPE</p>2429 <div class="peach-feedback-chips">2430 <For each={FEEDBACK_CATEGORIES}>2431 {(cat) => {2432 const selected = () => fbCategory() === cat;2433 return (2434 <button2435 type="button"2436 class={`peach-feedback-chip ${selected() ? "is-active" : ""}`}2437 aria-pressed={selected()}2438 onClick={() => setFbCategory(cat)}2439 >2440 {cat}2441 </button>2442 );2443 }}2444 </For>2445 </div>24462447 <p class="peach-settings-sub-label">MESSAGE</p>2448 <textarea2449 class="peach-feedback-textarea"2450 placeholder="Describe the issue or idea..."2451 maxLength={MAX_FEEDBACK_MESSAGE_LENGTH}2452 value={fbMessage()}2453 onInput={(e) => setFbMessage(e.currentTarget.value)}2454 />24552456 <p class="peach-feedback-meta">2457 Version {version} · will be included automatically2458 </p>24592460 <button2461 type="button"2462 class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"2463 disabled={!fbMessage().trim() || feedbackState() === 'sending'}2464 onClick={() => void handleSubmitFeedback()}2465 >2466 <Switch>2467 <Match when={feedbackState() === 'idle'}>Send</Match>2468 <Match when={feedbackState() === 'sending'}>Sending...</Match>2469 <Match when={feedbackState() === 'error'}>Failed - try again</Match>2470 </Switch>2471 </button>24722473 <p class="peach-feedback-anon">2474 Sent anonymously · no account required2475 </p>2476 </>2477 }2478 >2479 <div class="peach-feedback-thanks" role="status" aria-live="polite">2480 <p class="peach-feedback-thanks-title">Thank you</p>2481 <p>2482 Your note is in the queue. Someone will process it soon.2483 </p>2484 </div>2485 </Show>2486 </div>2487 </div>2488 );2489 }24902491 const bodies: StackBodies = {2492 protection: renderSecurity(),2493 vault: renderVault(),2494 sync: renderSync(),2495 appearance: renderAppearance(),2496 shortcuts: renderShortcuts(),2497 pro: renderLicense(),2498 feedback: renderFeedback(),2499 };25002501 return (2502 <Show2503 when={showNewRecoveryWords()}2504 fallback={2505 <div2506 class="peach-overlay-view peach-settings-overlay"2507 classList={{ "ov-enter-right": !props.isExiting, "ov-exit-right": !!props.isExiting }}2508 style="display:flex;flex-direction:column"2509 >2510 <StackDeck2511 active={activeCard()}2512 onActiveChange={setActiveCard}2513 onBack={props.onBack}2514 cards={cards()}2515 bodies={bodies}2516 version={version}2517 isFanning={isFanning()}2518 celebratingCardId={justActivated() ? "pro" : null}2519 isPro={isPro()}2520 />2521 </div>2522 }2523 >2524 <RecoveryWordDisplayView onDone={() => setShowNewRecoveryWords(false)} />2525 </Show>2526 );2527}