1import { createEffect, createMemo, createSignal, For, onMount, Show } from "solid-js";2import { sendMessage } from "../App";3import { PeachMark } from "../components/PeachMark";4import { StackDeck } from "../components/settings/StackDeck";5import type { CardId, StackBodies, StackCardSpec } from "../components/settings/types";6import { RecoveryWordDisplayView } from "./RecoveryWordDisplay";7import type { Canary } from "../../shared/types";8import { ACCENT_COLORS } from "../../shared/types";9import type {10 AccentColorName,11 PasskeyProviderState,12 ProFeature,13 VaultSnapshot,14} from "../../shared/types";15import {16 DEFAULT_BINDINGS,17 findConflict,18 formatKeybinding,19 keybindingFromEvent,20 SHORTCUT_DESCRIPTORS,21 type Keybinding,22 type ShortcutAction,23} from "../../shared/shortcuts";24import { isNativePlatform, nativePlugin } from "../../shared/native";2526interface LicenseInfo {27 valid: boolean;28 plan: "free" | "pro";29 issued?: string;30}3132interface SettingsProps {33 entriesCount: number;34 breachMonitoring: boolean;35 lockOnClose: boolean;36 autoLockMinutes: number;37 trashRetentionDays: number;38 theme: "dark" | "light";39 accentColor: AccentColorName;40 textSize: "small" | "medium" | "large";41 reduceMotion: boolean;42 isPro?: boolean;43 initialSection?: string | null;44 upgradeHighlight?: ProFeature | null;45 onBreachMonitoringChange: (enabled: boolean) => void | Promise<void>;46 onLockOnCloseChange: (enabled: boolean) => void | Promise<void>;47 onAutoLockMinutesChange: (minutes: number) => void | Promise<void>;48 onTrashRetentionChange: (days: number) => void | Promise<void>;49 onThemeChange: (theme: "dark" | "light") => void;50 onAccentChange: (name: AccentColorName) => void | Promise<void>;51 onTextSizeChange: (size: "small" | "medium" | "large") => void | Promise<void>;52 onReduceMotionChange: (enabled: boolean) => void | Promise<void>;53 shortcuts: Record<ShortcutAction, Keybinding>;54 onShortcutsChange: (active: Record<ShortcutAction, Keybinding>) => void | Promise<void>;55 onLicenseChange?: () => void | Promise<void>;56 onBack: () => void;57 onImport: () => void;58 onExport: () => void;59 onBreachCheckDone: () => void;60 onOpenHealth?: () => void;61 onOpenSyncPairing?: () => void;62 onOpenFileImport?: () => void;63 onOpenPanic?: () => void;64 onOpenCodexGenerate?: () => void;65 onOpenCodexRestore?: () => void;66 676869 onProGate?: (feature: ProFeature) => void;70}7172const FEEDBACK_CATEGORIES = ["Bug report", "Feature request", "Question", "Other"];7374function formatActivatedSince(issued: string | undefined): string {75 if (!issued) return "Active";76 try {77 const d = new Date(issued);78 if (Number.isNaN(d.getTime())) return "Active";79 return `Active since ${d.toLocaleDateString(undefined, { month: "long", year: "numeric" })}`;80 } catch {81 return "Active";82 }83}8485function initialSectionToCard(value: string | null | undefined): CardId | null {86 if (!value) return null;87 if (value === "security") return "protection";88 if (value === "license") return "pro";89 if (90 value === "protection" ||91 value === "vault" ||92 value === "sync" ||93 value === "appearance" ||94 value === "shortcuts" ||95 value === "pro" ||96 value === "feedback"97 ) {98 return value;99 }100 return null;101}102103export function SettingsView(props: SettingsProps) {104 const manifest = chrome.runtime.getManifest();105 const version = manifest.version;106 const initial = props.initialSection;107 const [activeCard, setActiveCard] = createSignal<CardId>(108 props.upgradeHighlight ? "pro" : initialSectionToCard(initial) ?? "protection"109 );110111 const [checkingBreaches, setCheckingBreaches] = createSignal(false);112 const [breachResult, setBreachResult] = createSignal<string | null>(null);113 const [licenseInfo, setLicenseInfo] = createSignal<LicenseInfo | null>(null);114 const [licenseKeyInput, setLicenseKeyInput] = createSignal("");115 const [licenseError, setLicenseError] = createSignal<string | null>(null);116 const [, setLicenseSuccess] = createSignal<string | null>(null);117 const [activatingLicense, setActivatingLicense] = createSignal(false);118119 const isPro = () => props.isPro ?? false;120 const cards = createMemo<StackCardSpec[]>(() => [121 {122 id: "protection",123 num: "I · PROTECT",124 title: "Protection",125 lede: `${props.breachMonitoring ? "breaches watched" : "breach watch off"} · ${props.lockOnClose ? "locks on close" : "manual lock"}`,126 bgVar: "--stack-protect-bg",127 fgVar: "--stack-protect-fg",128 icon: "shield",129 },130 {131 id: "vault",132 num: "II · KEEP",133 title: "Vault",134 lede: `${props.entriesCount} ${props.entriesCount === 1 ? "entry" : "entries"} · backups, history, cleanup`,135 bgVar: "--stack-vault-bg",136 fgVar: "--stack-vault-fg",137 icon: "lock",138 },139 {140 id: "sync",141 num: "III · ECHO",142 title: "Sync",143 lede: isPro() ? "pair by chirp, keep in step" : "Pro · encrypted device pairing",144 bgVar: "--stack-sync-bg",145 fgVar: "--stack-sync-fg",146 icon: "sync",147 },148 {149 id: "appearance",150 num: "IV · DRESS",151 title: "Appearance",152 lede: `${props.theme} · ${props.accentColor} · ${props.textSize}`,153 bgVar: "--stack-appear-bg",154 fgVar: "--stack-appear-fg",155 icon: "sun",156 },157 {158 id: "shortcuts",159 num: "V · KEYS",160 title: "Shortcuts",161 lede: `${SHORTCUT_DESCRIPTORS.length} bindings · what your fingers know`,162 bgVar: "--stack-shortcut-bg",163 fgVar: "--stack-shortcut-fg",164 icon: "keys",165 },166 {167 id: "pro",168 num: "VI · FOREVER",169 title: "Peach Pro",170 lede: isPro() ? "active · paid once, yours forever" : "$39 once · yours forever",171 bgVar: "--stack-pro-bg",172 fgVar: "--stack-pro-fg",173 icon: "spark",174 },175 {176 id: "feedback",177 num: "VII · ECHO",178 title: "Feedback",179 lede: `version ${version} · tell us what would make it better`,180 bgVar: "--stack-feedback-bg",181 fgVar: "--stack-feedback-fg",182 icon: "bubble",183 },184 ]);185 const [biometricSupported, setBiometricSupported] = createSignal(false);186 const [biometricStored, setBiometricStored] = createSignal(false);187 const [biometricBusy, setBiometricBusy] = createSignal(false);188 const [biometricError, setBiometricError] = createSignal<string | null>(null);189 const [passkeyState, setPasskeyState] = createSignal<PasskeyProviderState | null>(null);190 const [passkeySaving, setPasskeySaving] = createSignal(false);191192 const [dedupCount, setDedupCount] = createSignal<number | null>(null);193 const [dedupRunning, setDedupRunning] = createSignal(false);194 const [dedupDone, setDedupDone] = createSignal<number | null>(null);195196 const [declinedCardCount, setDeclinedCardCount] = createSignal(0);197198 199 200 201 const [historySnapshots, setHistorySnapshots] = createSignal<VaultSnapshot[]>([]);202 const [historyLoading, setHistoryLoading] = createSignal(false);203 const [historyRestoring, setHistoryRestoring] = createSignal<string | null>(null);204205 async function loadVaultHistory() {206 setHistoryLoading(true);207 try {208 const res = await sendMessage({ type: "GET_VAULT_HISTORY" });209 const list = (res as { ok: boolean; data?: VaultSnapshot[] }).data ?? [];210 setHistorySnapshots(list);211 } finally {212 setHistoryLoading(false);213 }214 }215216 async function handleRestoreVaultSnapshot(id: string) {217 if (historyRestoring()) return;218 setHistoryRestoring(id);219 const res = await sendMessage({220 type: "RESTORE_VAULT_SNAPSHOT",221 payload: { snapshotId: id },222 });223 setHistoryRestoring(null);224 if ((res as { ok: boolean }).ok) {225 226 227 props.onBack();228 }229 }230231 function formatSnapshotTime(savedAt: number): string {232 const diff = Date.now() - savedAt;233 if (diff < 60_000) return "Just now";234 if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;235 const d = new Date(savedAt);236 const isToday = new Date().toDateString() === d.toDateString();237 const isYest =238 new Date(Date.now() - 86_400_000).toDateString() === d.toDateString();239 const t = d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });240 if (isToday) return `Today ${t}`;241 if (isYest) return `Yesterday ${t}`;242 return `${d.toLocaleDateString([], { month: "short", day: "numeric" })} ${t}`;243 }244245 function reasonLabel(reason: VaultSnapshot["reason"]): string {246 return (247 {248 local_save: "saved",249 sync_received: "synced",250 import: "imported",251 restore: "restored",252 } as const253 )[reason] ?? reason;254 }255256 const [showVaultAdvanced, setShowVaultAdvanced] = createSignal(false);257258 const [showChangePw, setShowChangePw] = createSignal(false);259 const [changePwCurrent, setChangePwCurrent] = createSignal("");260 const [changePwNew, setChangePwNew] = createSignal("");261 const [changePwConfirm, setChangePwConfirm] = createSignal("");262 const [changePwSaving, setChangePwSaving] = createSignal(false);263 const [changePwError, setChangePwError] = createSignal<string | null>(null);264 const [changePwSuccess] = createSignal(false);265 const [showNewRecoveryWords, setShowNewRecoveryWords] = createSignal(false);266267 const [duressEnabled, setDuressEnabled] = createSignal(false);268 const [showDuressSetup, setShowDuressSetup] = createSignal(false);269 const [duressPassword, setDuressPassword] = createSignal("");270 const [duressConfirm, setDuressConfirm] = createSignal("");271 const [duressMainPassword, setDuressMainPassword] = createSignal("");272 const [duressSaving, setDuressSaving] = createSignal(false);273 const [duressError, setDuressError] = createSignal<string | null>(null);274 const [duressSuccess, setDuressSuccess] = createSignal<string | null>(null);275276 const [canaryEntries, setCanaryEntries] = createSignal<Array<{id: string; title: string; canary: Canary}>>([]);277278 const [canaryTestingId, setCanaryTestingId] = createSignal<string | null>(null);279 const [canaryTestResult, setCanaryTestResult] = createSignal<{id: string; ok: boolean} | null>(null);280281 const [fbCategory, setFbCategory] = createSignal("Bug report");282 const [fbMessage, setFbMessage] = createSignal("");283 const [fbSubmitting, setFbSubmitting] = createSignal(false);284 const [fbResult, setFbResult] = createSignal<"success" | "error" | null>(null);285286 createEffect(() => {287 if (props.upgradeHighlight) {288 setActiveCard("pro");289 }290 });291292 onMount(async () => {293 const [licenseRes, passkeyRes, duressRes, settingsRes, keyRes] = await Promise.all([294 sendMessage({ type: "GET_LICENSE" }),295 sendMessage({ type: "GET_PASSKEY_PROVIDER_STATE" }),296 sendMessage({ type: "IS_DURESS_ENABLED" }),297 sendMessage({ type: "GET_SETTINGS" }),298 sendMessage({ type: "GET_LICENSE_KEY" }),299 ]);300301 302 303 void loadVaultHistory();304305 if ((licenseRes as { ok: boolean }).ok) {306 setLicenseInfo((licenseRes as { ok: boolean; data?: LicenseInfo }).data ?? null);307 }308309 if ((passkeyRes as { ok: boolean }).ok) {310 setPasskeyState(311 (passkeyRes as { ok: boolean; data?: PasskeyProviderState }).data ?? null312 );313 }314315 if ((duressRes as { ok: boolean }).ok) {316 setDuressEnabled((duressRes as { ok: boolean; data?: boolean }).data ?? false);317 }318319 if ((settingsRes as { ok: boolean }).ok) {320 const sData = (settingsRes as { ok: boolean; data?: { declinedCardFingerprints?: string[] } }).data;321 setDeclinedCardCount(sData?.declinedCardFingerprints?.length ?? 0);322 }323324 if ((keyRes as { ok: boolean }).ok) {325 setLicenseKey((keyRes as { ok: boolean; data?: string }).data ?? null);326 }327328 const entriesRes = await sendMessage({ type: "GET_ENTRIES" });329 if ((entriesRes as { ok: boolean }).ok) {330 const entries = (entriesRes as { ok: boolean; data?: Array<{id: string; title: string; canary?: Canary}> }).data ?? [];331 setCanaryEntries(332 entries333 .filter((e) => e.canary?.webhookUrl)334 .map((e) => ({ id: e.id, title: e.title, canary: e.canary! }))335 );336 }337338339 if (isNativePlatform()) {340 const biometric = nativePlugin<{ isAvailable: () => Promise<{ available: boolean }> }>("BiometricPlugin");341 const keystore = nativePlugin<{ hasMasterKey: () => Promise<{ exists: boolean }> }>("KeystorePlugin");342 try {343 if (biometric) {344 const a = await biometric.isAvailable();345 setBiometricSupported(a.available);346 }347 if (keystore) {348 const k = await keystore.hasMasterKey();349 setBiometricStored(k.exists);350 }351 } catch { }352 }353 });354355 async function handleEnableBiometrics() {356 setBiometricBusy(true);357 setBiometricError(null);358 try {359 const keyRes = (await sendMessage({ type: "GET_DERIVED_KEY" })) as {360 ok: boolean;361 data?: { masterKeyHex: string };362 error?: string;363 };364 if (!keyRes.ok || !keyRes.data?.masterKeyHex) {365 setBiometricError(keyRes.error === "vault_locked"366 ? "unlock the vault first"367 : "couldn't read derived key");368 return;369 }370 const keystore = nativePlugin<{ storeMasterKey: (p: { masterKeyHex: string }) => Promise<void> }>("KeystorePlugin");371 if (!keystore) {372 setBiometricError("biometric plugin unavailable");373 return;374 }375 await keystore.storeMasterKey({ masterKeyHex: keyRes.data.masterKeyHex });376 await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: true } });377 setBiometricStored(true);378 } catch (e) {379 setBiometricError(e instanceof Error ? e.message : "failed to enable biometrics");380 } finally {381 setBiometricBusy(false);382 }383 }384385 async function handleDisableBiometrics() {386 setBiometricBusy(true);387 setBiometricError(null);388 try {389 const keystore = nativePlugin<{ clearMasterKey: () => Promise<void> }>("KeystorePlugin");390 if (keystore) await keystore.clearMasterKey();391 await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: false } });392 setBiometricStored(false);393 } catch (e) {394 setBiometricError(e instanceof Error ? e.message : "failed to disable biometrics");395 } finally {396 setBiometricBusy(false);397 }398 }399400 async function handleChangePassword() {401 setChangePwError(null);402 if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; }403 if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; }404 if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; }405 if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; }406 setChangePwSaving(true);407 const res = await sendMessage({408 type: "CHANGE_PASSWORD",409 payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() },410 }) as { ok: boolean; error?: string };411 if (res.ok) {412 setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");413 setShowChangePw(false);414 setShowNewRecoveryWords(true);415 } else {416 setChangePwError(res.error ?? "failed to change password");417 }418 setChangePwSaving(false);419 }420421 const [copied, setCopied] = createSignal(false);422423 async function copyLicenseKey(key: string) {424 await navigator.clipboard.writeText(key);425 setCopied(true);426 setTimeout(() => setCopied(false), 2000);427 }428429 async function handleActivateLicense() {430 const key = licenseKeyInput().trim();431 if (!key) return;432 setActivatingLicense(true);433 setLicenseError(null);434 setLicenseSuccess(null);435 const res = await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key } });436 setActivatingLicense(false);437 if ((res as { ok: boolean }).ok) {438 const data = (res as { ok: boolean; data?: LicenseInfo }).data;439 if (data?.valid) {440 setLicenseInfo(data);441 setLicenseKey(key);442 setLicenseSuccess("License activated!");443 setLicenseKeyInput("");444 } else {445 setLicenseError("Invalid license key");446 }447 } else {448 setLicenseError("Activation failed");449 }450 }451452 async function handleTogglePasskeys() {453 const current = passkeyState();454 if (!current || !current.supported) return;455 setPasskeySaving(true);456 const res = await sendMessage({457 type: "SET_PASSKEY_PROVIDER_ENABLED",458 payload: { enabled: !current.enabled },459 });460 setPasskeySaving(false);461 if ((res as { ok: boolean }).ok) {462 setPasskeyState(463 (res as { ok: boolean; data?: PasskeyProviderState }).data ?? null464 );465 }466 }467468 async function handleCheckAllBreaches() {469 setCheckingBreaches(true);470 setBreachResult(null);471 const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });472 setCheckingBreaches(false);473 if ((res as { ok: boolean }).ok) {474 const data = (res as { ok: boolean; data?: Record<string, number> }).data ?? {};475 const breached = Object.values(data).filter((count) => count > 0).length;476 setBreachResult(477 breached > 0 ? `${breached} password${breached > 1 ? "s" : ""} found in breaches` : "All passwords clean"478 );479 props.onBreachCheckDone();480 }481 }482483484 async function handleSetupDuress() {485 const mainPw = duressMainPassword().trim();486 const duressPw = duressPassword().trim();487 const confirmPw = duressConfirm().trim();488489 if (!mainPw) { setDuressError("enter your main password to confirm"); return; }490 if (duressPw.length < 8) { setDuressError("duress password must be at least 8 characters"); return; }491 if (duressPw === mainPw) { setDuressError("duress password must differ from your main password"); return; }492 if (duressPw !== confirmPw) { setDuressError("passwords do not match"); return; }493494 setDuressSaving(true);495 setDuressError(null);496497 const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };498 if (!verifyRes.ok || !verifyRes.data) {499 setDuressError("incorrect main password");500 setDuressSaving(false);501 return;502 }503504 const res = await sendMessage({ type: "SETUP_DURESS", payload: { duressPassword: duressPw } }) as { ok: boolean; error?: string };505 setDuressSaving(false);506 if (res.ok) {507 setDuressEnabled(true);508 setShowDuressSetup(false);509 setDuressPassword("");510 setDuressConfirm("");511 setDuressMainPassword("");512 setDuressSuccess("Duress password set. If entered, a decoy vault will be shown.");513 setTimeout(() => setDuressSuccess(null), 5000);514 } else {515 setDuressError(res.error ?? "failed to set duress password");516 }517 }518519 async function handleClearDeclinedCards() {520 await sendMessage({ type: "UPDATE_SETTINGS", payload: { declinedCardFingerprints: [] } });521 setDeclinedCardCount(0);522 }523524 async function handleCountDuplicates() {525 setDedupRunning(true);526 setDedupDone(null);527 const res = await sendMessage({ type: "COUNT_DUPLICATES" }) as { ok: boolean; data?: number };528 setDedupRunning(false);529 if (res.ok) setDedupCount(res.data ?? 0);530 }531532 async function handleDeduplicate() {533 setDedupRunning(true);534 const res = await sendMessage({ type: "DEDUPLICATE_ENTRIES" }) as { ok: boolean; data?: number };535 setDedupRunning(false);536 if (res.ok) {537 setDedupDone(res.data ?? 0);538 setDedupCount(0);539 }540 }541542 async function handleDisableDuress() {543 const mainPw = duressMainPassword().trim();544 if (!mainPw) { setDuressError("enter your main password to confirm"); return; }545546 setDuressSaving(true);547 setDuressError(null);548549 const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };550 if (!verifyRes.ok || !verifyRes.data) {551 setDuressError("incorrect main password");552 setDuressSaving(false);553 return;554 }555556 const res = await sendMessage({ type: "DISABLE_DURESS" }) as { ok: boolean; error?: string };557 setDuressSaving(false);558 if (res.ok) {559 setDuressEnabled(false);560 setShowDuressSetup(false);561 setDuressMainPassword("");562 setDuressSuccess("Duress password disabled.");563 setTimeout(() => setDuressSuccess(null), 4000);564 } else {565 setDuressError(res.error ?? "failed to disable duress password");566 }567 }568569 async function handleSubmitFeedback() {570 if (!fbMessage().trim() || fbSubmitting()) return;571 setFbSubmitting(true);572 setFbResult(null);573 const res = await sendMessage({574 type: "SUBMIT_FEEDBACK",575 payload: {576 category: fbCategory(),577 message: fbMessage().trim(),578 version,579 browser: navigator.userAgent,580 },581 }) as { ok: boolean; error?: string };582 setFbSubmitting(false);583 if (res.ok) {584 setFbResult("success");585 setFbMessage("");586 setTimeout(() => setFbResult(null), 4000);587 } else {588 setFbResult("error");589 }590 }591592 function renderSecurity() {593 return (594 <div class="peach-settings-body">595 <div class="peach-settings-group">596 <p class="peach-settings-sub-label">LOCKING</p>597 <div class="peach-settings-row">598 <span>Lock on close</span>599 <button600 class={`peach-toggle ${props.lockOnClose ? "is-on" : ""}`}601 onClick={() => void props.onLockOnCloseChange(!props.lockOnClose)}602 aria-label="Toggle lock on close"603 >604 <span class="peach-toggle-knob"></span>605 </button>606 </div>607 <div class="peach-settings-row">608 <span>Auto-lock after</span>609 <select610 class="peach-settings-select"611 value={String(props.autoLockMinutes)}612 onChange={(e) => void props.onAutoLockMinutesChange(Number(e.currentTarget.value))}613 >614 <option value="1">1 minute</option>615 <option value="5">5 minutes</option>616 <option value="15">15 minutes</option>617 <option value="30">30 minutes</option>618 <option value="0">Never</option>619 </select>620 </div>621622 <Show when={isNativePlatform() && biometricSupported()}>623 <p class="peach-settings-sub-label">BIOMETRICS</p>624 <div class="peach-settings-row">625 <span>Unlock with biometrics</span>626 <button627 class={`peach-toggle ${biometricStored() ? "is-on" : ""}`}628 onClick={() => void (biometricStored() ? handleDisableBiometrics() : handleEnableBiometrics())}629 disabled={biometricBusy()}630 aria-label="Toggle biometric unlock"631 >632 <span class="peach-toggle-knob"></span>633 </button>634 </div>635 <Show when={biometricError()}>636 <div class="peach-settings-row peach-settings-row--meta">637 <span class="peach-hint peach-hint-error">{biometricError()}</span>638 </div>639 </Show>640 </Show>641642 <p class="peach-settings-sub-label">BREACH PROTECTION</p>643 <div class="peach-settings-row">644 <span>Breach monitoring</span>645 <button646 class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}647 onClick={() => void props.onBreachMonitoringChange(!props.breachMonitoring)}648 aria-label="Toggle breach monitoring"649 >650 <span class="peach-toggle-knob"></span>651 </button>652 </div>653 <div class="peach-settings-row peach-settings-row--meta">654 <span>Check all passwords</span>655 <button656 class="peach-btn peach-btn--subtle peach-btn--sm"657 onClick={() => void handleCheckAllBreaches()}658 disabled={checkingBreaches()}659 >660 {checkingBreaches() ? "checking..." : "run"}661 </button>662 </div>663 <Show when={breachResult()}>664 <div class="peach-settings-row peach-settings-row--meta">665 <span>Last check</span>666 <span class="peach-settings-value">{breachResult()}</span>667 </div>668 </Show>669670 <p class="peach-settings-sub-label">MASTER PASSWORD</p>671 <div class="peach-settings-row peach-settings-row--meta">672 <span>Master password</span>673 <button674 class="peach-btn peach-btn--subtle peach-btn--sm"675 onClick={() => { setShowChangePw((v) => !v); setChangePwError(null); }}676 >677 {showChangePw() ? "cancel" : "change"}678 </button>679 </div>680 <Show when={showChangePw()}>681 <div class="peach-settings-stack" style="padding: 4px 0 2px; gap: 6px;">682 <input683 class="peach-settings-input"684 type="password"685 placeholder="current password"686 value={changePwCurrent()}687 onInput={(e) => setChangePwCurrent((e.target as HTMLInputElement).value)}688 autocomplete="current-password"689 />690 <input691 class="peach-settings-input"692 type="password"693 placeholder="new password (min 12 chars)"694 value={changePwNew()}695 onInput={(e) => setChangePwNew((e.target as HTMLInputElement).value)}696 autocomplete="new-password"697 />698 <input699 class="peach-settings-input"700 type="password"701 placeholder="confirm new password"702 value={changePwConfirm()}703 onInput={(e) => setChangePwConfirm((e.target as HTMLInputElement).value)}704 autocomplete="new-password"705 onKeyDown={(e) => { if (e.key === "Enter") void handleChangePassword(); }}706 />707 <Show when={changePwError()}>708 <p class="peach-hint peach-hint-error" style="margin:0">{changePwError()}</p>709 </Show>710 <Show when={changePwSuccess()}>711 <p class="peach-hint" style="margin:0;color:var(--peach-accent)">password changed ✓</p>712 </Show>713 <button714 class="peach-btn peach-btn--accent peach-btn--sm"715 style="align-self:flex-start"716 onClick={() => void handleChangePassword()}717 disabled={changePwSaving() || !changePwCurrent() || !changePwNew() || !changePwConfirm()}718 >719 {changePwSaving() ? "saving…" : "save new password"}720 </button>721 </div>722 </Show>723724 <p class="peach-settings-sub-label">725 DURESS PASSWORD726 <Show when={!isPro()}>727 <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>728 </Show>729 </p>730 <div class="peach-settings-row peach-settings-row--top">731 <div class="peach-settings-row-main">732 <span>733 Duress password734 <Show when={!isPro()}>735 <span class="peach-pro-badge">Pro</span>736 </Show>737 </span>738 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">739 If entered under coercion, shows a decoy empty vault instead of your real one.740 </span>741 </div>742 <div class={`peach-duress-status-pill ${duressEnabled() ? "is-active" : ""}`}>743 {duressEnabled() ? "on" : "off"}744 </div>745 </div>746747 <Show when={duressEnabled() && !showDuressSetup()}>748 <div class="peach-settings-row peach-settings-row--meta">749 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">750 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.751 </span>752 </div>753 </Show>754755 <Show when={duressSuccess()}>756 <div class="peach-settings-row peach-settings-row--meta">757 <span class="peach-settings-value" style="color:var(--peach-sync-ok)">{duressSuccess()}</span>758 </div>759 </Show>760761 <Show when={!showDuressSetup()}>762 <div class="peach-settings-row peach-settings-row--meta">763 <button764 class="peach-btn peach-btn--subtle peach-btn--sm"765 onClick={() => {766 767 768 769 770 771 if (!isPro() && !duressEnabled()) {772 props.onProGate?.("duress_vault");773 setActiveCard("pro");774 return;775 }776 setShowDuressSetup(true);777 setDuressError(null);778 setDuressSuccess(null);779 setDuressPassword("");780 setDuressConfirm("");781 setDuressMainPassword("");782 }}783 >784 {duressEnabled() ? "Change / disable" : "Set up"}785 </button>786 </div>787 </Show>788789 <Show when={showDuressSetup()}>790 <div class="peach-duress-form">791 <label class="peach-label">main password (to confirm)</label>792 <input793 class="peach-settings-input"794 type="password"795 placeholder="your current master password"796 autocomplete="current-password"797 value={duressMainPassword()}798 onInput={(e) => setDuressMainPassword(e.currentTarget.value)}799 />800 <Show when={!duressEnabled()}>801 <label class="peach-label">new duress password</label>802 <input803 class="peach-settings-input"804 type="password"805 placeholder="duress password (min 8 chars)"806 autocomplete="new-password"807 value={duressPassword()}808 onInput={(e) => setDuressPassword(e.currentTarget.value)}809 />810 <label class="peach-label">confirm duress password</label>811 <input812 class="peach-settings-input"813 type="password"814 placeholder="confirm duress password"815 autocomplete="new-password"816 value={duressConfirm()}817 onInput={(e) => setDuressConfirm(e.currentTarget.value)}818 />819 </Show>820821 <Show when={duressError()}>822 <p class="peach-hint peach-hint-error">{duressError()}</p>823 </Show>824825 <div class="peach-duress-actions">826 <button827 class="peach-btn peach-btn--subtle peach-btn--sm"828 onClick={() => { setShowDuressSetup(false); setDuressError(null); }}829 >830 cancel831 </button>832 <Show when={duressEnabled()}>833 <button834 class="peach-btn peach-btn--ghost peach-btn--sm"835 style="color:var(--peach-breach)"836 onClick={() => void handleDisableDuress()}837 disabled={duressSaving()}838 >839 {duressSaving() ? "disabling..." : "disable duress"}840 </button>841 </Show>842 <Show when={!duressEnabled()}>843 <button844 class="peach-btn peach-btn--accent peach-btn--sm"845 onClick={() => void handleSetupDuress()}846 disabled={duressSaving() || !duressMainPassword() || !duressPassword() || !duressConfirm()}847 >848 {duressSaving() ? "saving..." : "save"}849 </button>850 </Show>851 </div>852 </div>853 </Show>854855 <p class="peach-settings-sub-label">856 CANARY ENTRIES857 <Show when={!isPro()}>858 <span class="peach-pro-mark" aria-label="Pro feature" style="margin-left:6px">✦</span>859 </Show>860 </p>861 <div class="peach-settings-row peach-settings-row--top">862 <div class="peach-settings-row-main">863 <span>864 Canary entries865 <Show when={!isPro()}>866 <span class="peach-pro-badge">Pro</span>867 </Show>868 </span>869 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">870 Silently pings a webhook when an entry is accessed. Configure canary on any entry in your vault.871 </span>872 </div>873 <span class="peach-settings-value" style="flex-shrink:0">874 {canaryEntries().length > 0 ? `${canaryEntries().length} active` : "none"}875 </span>876 </div>877 <Show when={canaryEntries().length > 0}>878 <For each={canaryEntries()}>879 {(entry) => (880 <div class="peach-settings-row peach-settings-row--meta">881 <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0">{entry.title}</span>882 <button883 class="peach-btn peach-btn--subtle peach-btn--sm"884 onClick={() => void handleTestCanary(entry)}885 disabled={canaryTestingId() === entry.id}886 style="flex-shrink:0"887 >888 {canaryTestingId() === entry.id889 ? "sending…"890 : canaryTestResult()?.id === entry.id891 ? (canaryTestResult()!.ok ? "sent ✓" : "failed ✗")892 : "test"}893 </button>894 </div>895 )}896 </For>897 </Show>898899 <p class="peach-settings-sub-label">PASSKEYS</p>900 <div class="peach-settings-row peach-settings-row--top">901 <div class="peach-settings-row-main">902 <span>Intercept passkey requests</span>903 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--t2)">904 Peach can handle passkey sign-in and creation on supported Chromium browsers.905 </span>906 </div>907 <button908 class={`peach-toggle ${passkeyState()?.enabled ? "is-on" : ""}`}909 onClick={() => void handleTogglePasskeys()}910 aria-label="Toggle passkey interception"911 disabled={!passkeyState()?.supported || passkeySaving()}912 >913 <span class="peach-toggle-knob"></span>914 </button>915 </div>916 <div class="peach-settings-row peach-settings-row--meta">917 <span>Status</span>918 <span class="peach-settings-value">{passkeyState()?.statusLabel ?? "Unavailable"}</span>919 </div>920 <div class="peach-settings-row peach-settings-row--meta">921 <span>Browser</span>922 <span class="peach-settings-value">923 {passkeyState()?.browserLabel ?? "Unknown"}924 {passkeyState()?.supported ? " ✓" : " (interception unavailable)"}925 </span>926 </div>927 <div class="peach-info-notice" style="margin-top:8px">928 Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.929 </div>930 <Show when={passkeyState()?.conflict}>931 <div class="peach-settings-row peach-settings-row--meta">932 <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>933 </div>934 </Show>935 </div>936 </div>937 );938 }939940 function renderVault() {941 return (942 <div class="peach-settings-body">943 <div class="peach-settings-group">944 <p class="peach-settings-sub-label">ENTRIES</p>945 <div class="peach-settings-row peach-settings-row--meta">946 <span>Entries</span>947 <span class="peach-settings-value">{props.entriesCount}</span>948 </div>949 <div class="peach-settings-row peach-settings-row--meta">950 <span>Duplicates</span>951 <Show when={dedupDone() !== null} fallback={952 <button953 class="peach-btn peach-btn--subtle peach-btn--sm"954 onClick={() => void handleCountDuplicates()}955 disabled={dedupRunning()}956 >957 {dedupRunning() ? "scanning..." : dedupCount() === null ? "scan" : dedupCount() === 0 ? "none found" : `${dedupCount()} found — remove?`}958 </button>959 }>960 <span class="peach-settings-value" style="color:var(--peach-sync-ok)">961 {dedupDone() === 0 ? "already clean" : `removed ${dedupDone()}`}962 </span>963 </Show>964 </div>965 <Show when={dedupCount() !== null && dedupCount()! > 0 && dedupDone() === null}>966 <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">967 <div class="peach-restore-warning">968 {dedupCount()} duplicate {dedupCount() === 1 ? "entry" : "entries"} found. The newest copy of each will be kept; older duplicates will be moved to trash.969 </div>970 <div style="display:flex;gap:6px">971 <button972 class="peach-btn peach-btn--subtle peach-btn--sm"973 onClick={() => setDedupCount(null)}974 >975 cancel976 </button>977 <button978 class="peach-btn peach-btn--accent peach-btn--sm"979 onClick={() => void handleDeduplicate()}980 disabled={dedupRunning()}981 >982 {dedupRunning() ? "removing..." : "remove duplicates"}983 </button>984 </div>985 </div>986 </Show>987988 <p class="peach-settings-sub-label">MAINTENANCE</p>989 <div class="peach-settings-row peach-settings-row--meta">990 <span>Trash auto-delete</span>991 <select992 class="peach-settings-select"993 value={String(props.trashRetentionDays)}994 onChange={(e) => void props.onTrashRetentionChange(Number(e.currentTarget.value))}995 >996 <option value="7">7 days</option>997 <option value="30">30 days</option>998 <option value="90">90 days</option>999 <option value="0">Never</option>1000 </select>1001 </div>1002 <div class="peach-settings-row peach-settings-row--meta">1003 <span>Vault Health</span>1004 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>1005 open1006 </button>1007 </div>1008 <Show when={declinedCardCount() > 0}>1009 <div class="peach-settings-row peach-settings-row--meta">1010 <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>1011 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>1012 Clear1013 </button>1014 </div>1015 </Show>10161017 <p class="peach-settings-sub-label">DATA</p>1018 <div class="peach-settings-row peach-settings-row--meta">1019 <span>Import passwords</span>1020 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>1021 open1022 </button>1023 </div>1024 <div class="peach-settings-row peach-settings-row--meta">1025 <span>Export backup</span>1026 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onExport}>1027 open1028 </button>1029 </div>1030 <div class="peach-settings-row peach-settings-row--meta">1031 <span>1032 Peach Codex1033 <Show when={!isPro()}>1034 <span class="peach-pro-badge">Pro</span>1035 </Show>1036 </span>1037 <button1038 class="peach-btn peach-btn--subtle peach-btn--sm"1039 onClick={() => props.onOpenCodexGenerate?.()}1040 >1041 open1042 </button>1043 </div>1044 {1045}1046 <div class="peach-settings-row peach-settings-row--meta">1047 <span>Restore from Codex</span>1048 <button1049 class="peach-btn peach-btn--subtle peach-btn--sm"1050 onClick={() => props.onOpenCodexRestore?.()}1051 >1052 open1053 </button>1054 </div>10551056 <p class="peach-settings-sub-label">VAULT HISTORY</p>1057 <Show when={historyLoading()}>1058 <p class="peach-settings-hint">Loading…</p>1059 </Show>1060 <Show when={!historyLoading() && historySnapshots().length === 0}>1061 <p class="peach-settings-hint">1062 No snapshots yet. Peach saves automatically before every change —1063 saves, syncs, and imports.1064 </p>1065 </Show>1066 <Show when={!historyLoading() && historySnapshots().length > 0}>1067 <p class="peach-settings-hint">1068 Restoring locks your vault. Unlock with the password that1069 encrypted that snapshot to confirm.1070 </p>1071 <div class="peach-history-list">1072 <For each={historySnapshots()}>1073 {(snap) => (1074 <div class="peach-history-row">1075 <div class="peach-history-meta">1076 <span class="peach-history-time">1077 {formatSnapshotTime(snap.savedAt)}1078 </span>1079 <span class="peach-history-sub">1080 {[1081 snap.entryCount !== null1082 ? `${snap.entryCount} ${snap.entryCount === 1 ? "entry" : "entries"}`1083 : null,1084 reasonLabel(snap.reason),1085 ]1086 .filter(Boolean)1087 .join(" · ")}1088 </span>1089 </div>1090 <button1091 class={`peach-btn peach-btn--subtle peach-btn--sm peach-history-btn${1092 historyRestoring() === snap.id ? " is-busy" : ""1093 }`}1094 onClick={() => void handleRestoreVaultSnapshot(snap.id)}1095 disabled={historyRestoring() !== null}1096 >1097 <Show when={historyRestoring() === snap.id} fallback="Restore">1098 …1099 </Show>1100 </button>1101 </div>1102 )}1103 </For>1104 </div>1105 <p class="peach-history-footer">1106 {historySnapshots().length} of 10 snapshots1107 </p>1108 </Show>11091110 <button1111 class="peach-settings-advanced-toggle"1112 onClick={() => setShowVaultAdvanced((v) => !v)}1113 aria-expanded={showVaultAdvanced()}1114 >1115 <span class="peach-settings-sub-label" style="margin:0">ADVANCED</span>1116 <span class="peach-settings-advanced-chevron">1117 {showVaultAdvanced() ? "▲" : "▼"}1118 </span>1119 </button>11201121 <Show when={showVaultAdvanced()}>1122 <div class="peach-danger-card">1123 <div class="peach-danger-card-head">1124 <span class="peach-danger-card-glyph" aria-hidden="true">⟳</span>1125 <h3 class="peach-danger-card-title">Replace vault from backup</h3>1126 </div>1127 <p class="peach-danger-card-copy">1128 Wipes the current vault and restores from a backup file.1129 Recoverable only if your backup is good.1130 </p>1131 <button1132 class="peach-danger-card-button"1133 onClick={() => props.onOpenFileImport?.()}1134 >1135 Choose backup file1136 </button>1137 </div>11381139 <Show when={props.onOpenPanic}>1140 <div class="peach-danger-card peach-danger-card--hazard">1141 <div class="peach-danger-card-head">1142 <span class="peach-danger-card-glyph" aria-hidden="true">⚠</span>1143 <h3 class="peach-danger-card-title">Destroy vault</h3>1144 </div>1145 <p class="peach-danger-card-copy">1146 Permanently wipes every entry, setting, and license on this1147 device. There is no recovery.1148 </p>1149 <button1150 class="peach-danger-card-button"1151 onClick={() => props.onOpenPanic?.()}1152 >1153 Begin destruction1154 </button>1155 </div>1156 </Show>1157 </Show>1158 </div>1159 </div>1160 );1161 }11621163 function renderSync() {1164 return (1165 <div class="peach-settings-body">1166 <div class="peach-settings-group">1167 <p class="peach-settings-sub-label">PAIRING</p>1168 <div class="peach-settings-row peach-settings-row--top">1169 <div class="peach-settings-row-main">1170 <span>1171 Peach Sync1172 <Show when={!isPro()}>1173 <span class="peach-pro-badge">Pro</span>1174 </Show>1175 </span>1176 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1177 Pair nearby devices by chirp and keep encrypted vault blobs in step without a central account.1178 </span>1179 </div>1180 <span class={`stk-pip ${isPro() ? "is-on" : ""}`} aria-hidden="true" />1181 </div>1182 <div class="peach-settings-row peach-settings-row--meta">1183 <span>Status</span>1184 <span class="peach-settings-value">{isPro() ? "Ready" : "Requires Pro"}</span>1185 </div>1186 <div class="peach-settings-row peach-settings-row--meta">1187 <span>Sync hub</span>1188 <button1189 class="peach-btn peach-btn--subtle peach-btn--sm"1190 onClick={() => props.onOpenSyncPairing?.()}1191 >1192 open1193 </button>1194 </div>11951196 <p class="peach-settings-sub-label">DEVICE FLOW</p>1197 <div class="peach-settings-row peach-settings-row--top">1198 <div class="peach-settings-row-main">1199 <span>Pair new device</span>1200 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1201 Opens the full chirp pairing console with scan, show-code, and advanced diagnostics.1202 </span>1203 </div>1204 <button1205 class="peach-btn peach-btn--subtle peach-btn--sm"1206 onClick={() => props.onOpenSyncPairing?.()}1207 >1208 pair1209 </button>1210 </div>1211 <div class="peach-info-notice" style="margin-top:8px">1212 Sync uses the same zero-knowledge vault format as backup and restore; pairing authorizes a device, not a cloud account.1213 </div>1214 </div>1215 </div>1216 );1217 }12181219 function renderAppearance() {1220 const sizeOptions: Array<{ id: "small" | "medium" | "large"; label: string }> = [1221 { id: "small", label: "Small" },1222 { id: "medium", label: "Default" },1223 { id: "large", label: "Large" },1224 ];1225 return (1226 <div class="peach-settings-body">1227 <div class="peach-settings-group">1228 <p class="peach-settings-sub-label">THEME</p>1229 <div class="peach-settings-row peach-settings-row--meta">1230 <span>Theme</span>1231 <button1232 class="peach-btn peach-btn--subtle peach-btn--sm"1233 onClick={() =>1234 props.onThemeChange(props.theme === "dark" ? "light" : "dark")1235 }1236 >1237 {props.theme === "dark" ? "Switch to light" : "Switch to dark"}1238 </button>1239 </div>12401241 <p class="peach-settings-sub-label">ACCENT</p>1242 <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">1243 <span>Accent</span>1244 <div class="peach-accent-picker">1245 <For each={ACCENT_COLORS}>1246 {(color) => (1247 <button1248 class="peach-accent-swatch peach-swatch"1249 classList={{ "is-active": props.accentColor === color.name }}1250 style={{ background: color.hex }}1251 onClick={() => void props.onAccentChange(color.name)}1252 aria-label={color.name}1253 title={color.name}1254 />1255 )}1256 </For>1257 </div>1258 </div>12591260 <p class="peach-settings-sub-label">TEXT SIZE</p>1261 <div class="peach-settings-row peach-settings-row--meta">1262 <span>Size</span>1263 <div class="peach-pill-group">1264 <For each={sizeOptions}>1265 {(opt) => (1266 <button1267 class="peach-btn peach-btn--ghost peach-btn--sm"1268 classList={{ "is-active": props.textSize === opt.id }}1269 onClick={() => void props.onTextSizeChange(opt.id)}1270 >1271 {opt.label}1272 </button>1273 )}1274 </For>1275 </div>1276 </div>12771278 <p class="peach-settings-sub-label">MOTION</p>1279 <div class="peach-settings-row peach-settings-row--meta">1280 <span>Reduce motion</span>1281 <button1282 class="peach-btn peach-btn--subtle peach-btn--sm"1283 onClick={() => void props.onReduceMotionChange(!props.reduceMotion)}1284 >1285 {props.reduceMotion ? "On" : "Off"}1286 </button>1287 </div>1288 </div>1289 </div>1290 );1291 }12921293 function renderShortcuts() {1294 const [capturingAction, setCapturingAction] = createSignal<ShortcutAction | null>(null);1295 const [conflictMsg, setConflictMsg] = createSignal<string | null>(null);12961297 function startCapture(action: ShortcutAction) {1298 setConflictMsg(null);1299 setCapturingAction(action);1300 }1301 function cancelCapture() {1302 setCapturingAction(null);1303 setConflictMsg(null);1304 }1305 function commitBinding(action: ShortcutAction, binding: Keybinding) {1306 const conflict = findConflict(props.shortcuts, binding, action);1307 if (conflict) {1308 const other = SHORTCUT_DESCRIPTORS.find((d) => d.action === conflict);1309 setConflictMsg(`Already bound to "${other?.label ?? conflict}"`);1310 return;1311 }1312 const next = { ...props.shortcuts, [action]: binding };1313 setCapturingAction(null);1314 setConflictMsg(null);1315 void props.onShortcutsChange(next);1316 }1317 function resetAction(action: ShortcutAction) {1318 const next = { ...props.shortcuts, [action]: DEFAULT_BINDINGS[action] };1319 void props.onShortcutsChange(next);1320 }1321 function resetAll() {1322 void props.onShortcutsChange({ ...DEFAULT_BINDINGS });1323 }13241325 1326 1327 function handleCaptureKey(e: KeyboardEvent) {1328 e.preventDefault();1329 e.stopPropagation();1330 if (e.key === "Escape") { cancelCapture(); return; }1331 const binding = keybindingFromEvent(e);1332 if (!binding) return; 1333 const action = capturingAction();1334 if (action) commitBinding(action, binding);1335 }13361337 return (1338 <div class="peach-settings-body">1339 <div class="peach-settings-group peach-shortcuts-group">1340 <p class="peach-settings-sub-label">ENTRY VIEW</p>1341 <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "entry")}>1342 {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1343 </For>1344 </div>13451346 <div class="peach-settings-group peach-shortcuts-group">1347 <p class="peach-settings-sub-label">ANYWHERE IN POPUP</p>1348 <For each={SHORTCUT_DESCRIPTORS.filter((d) => d.scope === "global")}>1349 {(desc) => renderRow(desc, capturingAction, startCapture, cancelCapture, resetAction, handleCaptureKey)}1350 </For>1351 </div>13521353 <Show when={conflictMsg()}>1354 <div class="peach-shortcuts-conflict">{conflictMsg()}</div>1355 </Show>13561357 <div class="peach-settings-group" style="display:flex;flex-direction:column;gap:8px">1358 <div class="peach-settings-row peach-settings-row--meta">1359 <span>Inline autofill on page</span>1360 <a1361 class="peach-settings-link"1362 href="chrome://extensions/shortcuts"1363 onClick={(e) => {1364 e.preventDefault();1365 chrome.tabs.create({ url: "chrome://extensions/shortcuts" });1366 }}1367 >1368 ⌘⇧P — customize ↗1369 </a>1370 </div>1371 <button1372 class="peach-btn peach-btn--subtle peach-btn--sm"1373 style="align-self:flex-start"1374 onClick={resetAll}1375 >1376 Reset all to defaults1377 </button>1378 </div>1379 </div>1380 );13811382 function renderRow(1383 desc: typeof SHORTCUT_DESCRIPTORS[number],1384 capturing: () => ShortcutAction | null,1385 onStart: (a: ShortcutAction) => void,1386 onCancel: () => void,1387 onReset: (a: ShortcutAction) => void,1388 onKey: (e: KeyboardEvent) => void,1389 ) {1390 const isActive = () => capturing() === desc.action;1391 const binding = () => props.shortcuts[desc.action];1392 const isCustom = () => {1393 const b = binding();1394 const d = DEFAULT_BINDINGS[desc.action];1395 return b.key !== d.key1396 || Boolean(b.ctrl) !== Boolean(d.ctrl)1397 || Boolean(b.meta) !== Boolean(d.meta)1398 || Boolean(b.shift) !== Boolean(d.shift)1399 || Boolean(b.alt) !== Boolean(d.alt);1400 };1401 return (1402 <div class="peach-shortcut-row">1403 <span class="peach-shortcut-row-label">{desc.label}</span>1404 <button1405 class="peach-shortcut-chip"1406 classList={{ "is-capturing": isActive(), "is-custom": isCustom() }}1407 onClick={() => isActive() ? onCancel() : onStart(desc.action)}1408 onKeyDown={(e) => { if (isActive()) onKey(e); }}1409 ref={(el) => {1410 1411 if (el) {1412 queueMicrotask(() => { if (isActive()) el.focus(); });1413 }1414 }}1415 >1416 {isActive() ? "Press a key…" : formatKeybinding(binding())}1417 </button>1418 <Show when={isCustom() && !isActive()}>1419 <button1420 class="peach-shortcut-reset"1421 title="Reset to default"1422 onClick={() => onReset(desc.action)}1423 >1424 ↺1425 </button>1426 </Show>1427 </div>1428 );1429 }1430 }14311432 const PRO_FEATURES: Array<{1433 feature: ProFeature;1434 name: string;1435 desc: string;1436 }> = [1437 { feature: "sync", name: "Peach Sync", desc: "Encrypted · peer-to-peer" },1438 { feature: "codex", name: "Peach Codex", desc: "Restore from paper" },1439 { feature: "passkeys", name: "Passkeys", desc: "Zero-knowledge WebAuthn" },1440 { feature: "ios_app", name: "Mobile apps", desc: "Android · iOS soon" },1441 { feature: "advanced_health", name: "Vault health", desc: "Reuse · age · exposure" },1442 { feature: "smart_password_change", name: "Smart reset", desc: "Guided password change" },1443 ];14441445 const PRO_SECURITY_FEATURES: Array<{1446 feature: ProFeature;1447 name: string;1448 desc: string;1449 }> = [1450 { feature: "canary_entry", name: "Canary entries", desc: "Detects vault compromise" },1451 { feature: "duress_vault", name: "Duress vault", desc: "Decoy under coercion" },1452 ];14531454 const [licenseKey, setLicenseKey] = createSignal<string | null>(null);1455 const [showKey, setShowKey] = createSignal(false);14561457 const [deactivating, setDeactivating] = createSignal(false);14581459 async function handleDeactivateLicense() {1460 setDeactivating(true);1461 await sendMessage({ type: "DEACTIVATE_LICENSE" });1462 setLicenseInfo(null);1463 await props.onLicenseChange?.();1464 setDeactivating(false);1465 }14661467 async function handleTestCanary(entry: {id: string; title: string; canary: Canary}) {1468 setCanaryTestingId(entry.id);1469 setCanaryTestResult(null);1470 const res = await sendMessage({1471 type: "FIRE_CANARY_PING",1472 payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger: "test" },1473 }) as { ok: boolean };1474 setCanaryTestResult({ id: entry.id, ok: res.ok });1475 setCanaryTestingId(null);1476 setTimeout(() => setCanaryTestResult((r) => r?.id === entry.id ? null : r), 3000);1477 }14781479 function renderLicense() {1480 async function onActivate() {1481 await handleActivateLicense();1482 if ((licenseInfo()?.valid ?? false) && licenseInfo()?.plan === "pro") {1483 await props.onLicenseChange?.();1484 }1485 }14861487 return (1488 <div class="peach-settings-body">1489 <Show1490 when={isPro()}1491 fallback={1492 <div class="peach-pro-upgrade">1493 <p class="peach-pro-eyebrow">PEACH PRO · ONE-TIME</p>14941495 <h2 class="peach-pro-headline">1496 Every<em>thing,</em><br />every<em>where.</em>1497 </h2>14981499 <p class="peach-pro-subtitle">1500 One key unlocks every Pro feature — for the lifetime of the app.1501 </p>15021503 <div class="peach-pro-grid">1504 <For each={PRO_FEATURES}>1505 {(item, i) => (1506 <div1507 class="peach-pro-grid-item"1508 classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}1509 >1510 <span class="peach-pro-grid-num">{String(i() + 1).padStart(2, "0")}</span>1511 <span class="peach-pro-grid-name">{item.name}</span>1512 <span class="peach-pro-grid-desc">{item.desc}</span>1513 </div>1514 )}1515 </For>1516 </div>15171518 <p class="peach-pro-section-label">ADVANCED SECURITY</p>1519 <div class="peach-pro-grid">1520 <For each={PRO_SECURITY_FEATURES}>1521 {(item, i) => (1522 <div1523 class="peach-pro-grid-item"1524 classList={{ "is-highlighted": props.upgradeHighlight === item.feature }}1525 >1526 <span class="peach-pro-grid-num">{String(PRO_FEATURES.length + i() + 1).padStart(2, "0")}</span>1527 <span class="peach-pro-grid-name">{item.name}</span>1528 <span class="peach-pro-grid-desc">{item.desc}</span>1529 </div>1530 )}1531 </For>1532 </div>15331534 <div class="peach-pro-upgrade-actions">1535 <p class="peach-pro-pricing">1536 $39 <span class="peach-pro-pricing-sub">once · yours forever</span>1537 </p>1538 <a1539 href="https://peachpasswords.com/#pricing"1540 target="_blank"1541 rel="noopener noreferrer"1542 class="peach-pro-upgrade-cta"1543 >1544 Get Peach Pro1545 </a>1546 <div class="peach-pro-key-divider"><span>ALREADY HAVE A KEY</span></div>1547 <div class="peach-pro-key-inline">1548 <input1549 class="peach-pro-key-inline-input"1550 type="text"1551 placeholder="PEACH-XXXX-XXXX-XXXX"1552 value={licenseKeyInput()}1553 onInput={(e) => setLicenseKeyInput(e.currentTarget.value)}1554 onKeyDown={(e) => { if (e.key === "Enter") void onActivate(); }}1555 autocomplete="off"1556 spellcheck={false}1557 />1558 <button1559 class="peach-pro-key-apply"1560 onClick={() => void onActivate()}1561 disabled={activatingLicense() || !licenseKeyInput().trim()}1562 >1563 {activatingLicense() ? "…" : "APPLY"}1564 </button>1565 </div>1566 <Show when={licenseError()}>1567 <p class="peach-pro-key-error">{licenseError()}</p>1568 </Show>1569 </div>1570 </div>1571 }1572 >1573 <div class="peach-pro-active">1574 <div class="peach-pro-active-hero">1575 <div class="peach-pro-active-mark-wrap">1576 <PeachMark class="peach-pro-active-mark" />1577 </div>1578 <h2 class="peach-pro-active-title">Peach Pro</h2>1579 <p class="peach-pro-active-since">{formatActivatedSince(licenseInfo()?.issued)}</p>1580 </div>15811582 <Show when={licenseKey()}>1583 <div class="peach-settings-license">1584 <span class="peach-settings-label">LICENSE KEY</span>1585 <div class="peach-settings-license-row">1586 <Show1587 when={showKey()}1588 fallback={1589 <span class="peach-settings-license-key peach-settings-license-key--masked" aria-label="License key hidden">1590 ••••••••••••••••••••••••••••1591 </span>1592 }1593 >1594 <span class="peach-settings-license-key">{licenseKey()!}</span>1595 </Show>1596 <div class="peach-settings-license-actions">1597 <button1598 class="peach-settings-license-copy"1599 title="Copy license key"1600 onClick={() => copyLicenseKey(licenseKey()!)}1601 >1602 {copied() ? "✓" : "copy"}1603 </button>1604 <button1605 class="peach-settings-license-toggle"1606 aria-label={showKey() ? "Hide license key" : "Reveal license key"}1607 title={showKey() ? "Hide" : "Reveal"}1608 onClick={() => setShowKey(v => !v)}1609 >1610 <Show1611 when={showKey()}1612 fallback={1613 <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">1614 <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />1615 <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />1616 </svg>1617 }1618 >1619 <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">1620 <ellipse cx="7" cy="7" rx="5.5" ry="3.5" />1621 <circle cx="7" cy="7" r="1.5" fill="currentColor" stroke="none" />1622 <line x1="2" y1="2" x2="12" y2="12" />1623 </svg>1624 </Show>1625 </button>1626 </div>1627 </div>1628 </div>1629 </Show>16301631 <div class="peach-pro-active-features">1632 <For each={PRO_FEATURES}>1633 {(item) => (1634 <div class="peach-pro-active-feature">1635 <span class="peach-pro-active-feature-mark">✦</span>1636 <span class="peach-pro-active-feature-name">{item.name}</span>1637 </div>1638 )}1639 </For>1640 </div>16411642 <button1643 class="peach-pro-deactivate"1644 onClick={() => void handleDeactivateLicense()}1645 disabled={deactivating()}1646 >1647 {deactivating() ? "Deactivating…" : "Deactivate"}1648 </button>1649 </div>1650 </Show>1651 </div>1652 );1653 }16541655 function renderFeedback() {1656 return (1657 <div class="peach-settings-body">1658 <div class="peach-settings-group peach-feedback-group">1659 <p class="peach-feedback-intro">1660 Help us improve Peach. Every report goes directly to our inbox.1661 </p>16621663 <p class="peach-settings-sub-label">TYPE</p>1664 <div class="peach-feedback-chips">1665 <For each={FEEDBACK_CATEGORIES}>1666 {(cat) => (1667 <button1668 class={`peach-feedback-chip ${fbCategory() === cat ? "is-active" : ""}`}1669 onClick={() => setFbCategory(cat)}1670 >1671 {cat}1672 </button>1673 )}1674 </For>1675 </div>16761677 <p class="peach-settings-sub-label">MESSAGE</p>1678 <textarea1679 class="peach-feedback-textarea"1680 placeholder="Describe the issue or idea..."1681 value={fbMessage()}1682 onInput={(e) => setFbMessage(e.currentTarget.value)}1683 />16841685 <p class="peach-feedback-meta">1686 Version {version} · will be included automatically1687 </p>16881689 <Show when={fbResult() === "success"}>1690 <p class="peach-feedback-result peach-feedback-result--ok">1691 Sent — thank you1692 </p>1693 </Show>1694 <Show when={fbResult() === "error"}>1695 <p class="peach-feedback-result peach-feedback-result--err">1696 Failed to send — try again1697 </p>1698 </Show>16991700 <button1701 class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"1702 onClick={() => void handleSubmitFeedback()}1703 disabled={fbSubmitting() || !fbMessage().trim()}1704 >1705 {fbSubmitting() ? "Sending..." : "Send feedback"}1706 </button>17071708 <p class="peach-feedback-anon">1709 Sent anonymously · no account required1710 </p>1711 </div>1712 </div>1713 );1714 }17151716 const bodies: StackBodies = {1717 protection: renderSecurity(),1718 vault: renderVault(),1719 sync: renderSync(),1720 appearance: renderAppearance(),1721 shortcuts: renderShortcuts(),1722 pro: renderLicense(),1723 feedback: renderFeedback(),1724 };17251726 return (1727 <Show1728 when={showNewRecoveryWords()}1729 fallback={1730 <div class="peach-overlay-view ov-enter-right" style="display:flex;flex-direction:column">1731 <StackDeck1732 active={activeCard()}1733 onActiveChange={setActiveCard}1734 onBack={props.onBack}1735 cards={cards()}1736 bodies={bodies}1737 />1738 </div>1739 }1740 >1741 <RecoveryWordDisplayView onDone={() => setShowNewRecoveryWords(false)} />1742 </Show>1743 );1744}