73260e9b28a1
rewrite Settings.tsx: two-level navigation, seven sub-views, feedback form
- time
- 2026-04-26 13:30 UTC · 6 months ago
- machine
- koh2
- parent
- a205e1e3d989
↓ peach-browser-73260e9b.tar.gzatlas at this save
files changed2 files
src/popup/views/Settings.tsx+530 −292src/popup/views/contracts.test.tsx+27 −6src/popup/views/Settings.tsx+530 −292
@@ -1,4 +1,4 @@1-import { createSignal, For, onMount, Show } from "solid-js";1+import { createSignal, For, Match, onMount, Show, Switch } from "solid-js";22 import { sendMessage } from "../App";33 import { ACCENT_COLORS } from "../../shared/types";44 import type { AccentColorName, PasskeyProviderState } from "../../shared/types";@@ -34,8 +34,21 @@3434 onOpenFileImport?: () => void;3535 }3636 37+type SectionName =38+ | "security"39+ | "vault"40+ | "sync"41+ | "appearance"42+ | "shortcuts"43+ | "license"44+ | "feedback";45+46+const FEEDBACK_CATEGORIES = ["Bug report", "Feature request", "Question", "Other"];47+3748 export function SettingsView(props: SettingsProps) {3849 const version = chrome.runtime.getManifest().version;50+ const [activeSection, setActiveSection] = createSignal<SectionName | null>(null);51+3952 const [checkingBreaches, setCheckingBreaches] = createSignal(false);4053 const [breachResult, setBreachResult] = createSignal<string | null>(null);4154 const [licenseInfo, setLicenseInfo] = createSignal<LicenseInfo | null>(null);@@ -53,15 +66,12 @@5366 const [passkeyState, setPasskeyState] = createSignal<PasskeyProviderState | null>(null);5467 const [passkeySaving, setPasskeySaving] = createSignal(false);5568 56- // Deduplication state5769 const [dedupCount, setDedupCount] = createSignal<number | null>(null);5870 const [dedupRunning, setDedupRunning] = createSignal(false);5971 const [dedupDone, setDedupDone] = createSignal<number | null>(null);6072 61- // Declined card fingerprints6273 const [declinedCardCount, setDeclinedCardCount] = createSignal(0);6374 64- // Change master password state6575 const [showChangePw, setShowChangePw] = createSignal(false);6676 const [changePwCurrent, setChangePwCurrent] = createSignal("");6777 const [changePwNew, setChangePwNew] = createSignal("");@@ -70,28 +80,6 @@7080 const [changePwError, setChangePwError] = createSignal<string | null>(null);7181 const [changePwSuccess, setChangePwSuccess] = createSignal(false);7282 73- async function handleChangePassword() {74- setChangePwError(null);75- if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; }76- if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; }77- if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; }78- if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; }79- setChangePwSaving(true);80- const res = await sendMessage({81- type: "CHANGE_PASSWORD",82- payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() },83- }) as { ok: boolean; error?: string };84- if (res.ok) {85- setChangePwSuccess(true);86- setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");87- setTimeout(() => { setChangePwSuccess(false); setShowChangePw(false); }, 2200);88- } else {89- setChangePwError(res.error ?? "failed to change password");90- }91- setChangePwSaving(false);92- }93-94- // Duress password state9583 const [duressEnabled, setDuressEnabled] = createSignal(false);9684 const [showDuressSetup, setShowDuressSetup] = createSignal(false);9785 const [duressPassword, setDuressPassword] = createSignal("");@@ -101,6 +89,11 @@10189 const [duressError, setDuressError] = createSignal<string | null>(null);10290 const [duressSuccess, setDuressSuccess] = createSignal<string | null>(null);10391 92+ const [fbCategory, setFbCategory] = createSignal("Bug report");93+ const [fbMessage, setFbMessage] = createSignal("");94+ const [fbSubmitting, setFbSubmitting] = createSignal(false);95+ const [fbResult, setFbResult] = createSignal<"success" | "error" | null>(null);96+10497 onMount(async () => {10598 const [licenseRes, syncRes, passkeyRes, duressRes, settingsRes] = await Promise.all([10699 sendMessage({ type: "GET_LICENSE" }),@@ -157,6 +150,27 @@157150 });158151 });159152 153+ async function handleChangePassword() {154+ setChangePwError(null);155+ if (!changePwCurrent().trim()) { setChangePwError("enter your current password"); return; }156+ if (changePwNew().length < 12) { setChangePwError("new password must be at least 12 characters"); return; }157+ if (changePwNew() !== changePwConfirm()) { setChangePwError("new passwords do not match"); return; }158+ if (changePwNew() === changePwCurrent()) { setChangePwError("new password must differ from current"); return; }159+ setChangePwSaving(true);160+ const res = await sendMessage({161+ type: "CHANGE_PASSWORD",162+ payload: { currentPassword: changePwCurrent(), newPassword: changePwNew() },163+ }) as { ok: boolean; error?: string };164+ if (res.ok) {165+ setChangePwSuccess(true);166+ setChangePwCurrent(""); setChangePwNew(""); setChangePwConfirm("");167+ setTimeout(() => { setChangePwSuccess(false); setShowChangePw(false); }, 2200);168+ } else {169+ setChangePwError(res.error ?? "failed to change password");170+ }171+ setChangePwSaving(false);172+ }173+160174 async function handleActivateLicense() {161175 const key = licenseKeyInput().trim();162176 if (!key) return;@@ -195,11 +209,6 @@195209 }196210 }197211 198- function maskLicenseKey(key: string): string {199- if (key.length < 10) return key.slice(0, 4) + "****";200- return key.slice(0, 6) + "****" + key.slice(-4);201- }202-203212 async function handleCheckAllBreaches() {204213 setCheckingBreaches(true);205214 setBreachResult(null);@@ -269,8 +278,6 @@269278 }270279 271280 function isCurrentNetworkTrusted(): boolean {272- // Show the "mark trusted" button only if there are no trusted networks at all273- // (Tailscale is always trusted implicitly; any explicit networks count too)274281 return trustedNetworks().length > 0;275282 }276283 @@ -287,7 +294,6 @@287294 setDuressSaving(true);288295 setDuressError(null);289296 290- // Verify main password first291297 const verifyRes = await sendMessage({ type: "VERIFY_MAIN_PASSWORD", payload: { password: mainPw } }) as { ok: boolean; data?: boolean };292298 if (!verifyRes.ok || !verifyRes.data) {293299 setDuressError("incorrect main password");@@ -360,144 +366,152 @@360366 }361367 }362368 363- function renderSyncSection() {364- if (!isPro()) {365- return (366- <div class="peach-settings-group" style="position:relative">367- <p class="peach-settings-label">sync</p>368- <div class="peach-sync-pro-gate">369- <div class="peach-sync-pro-gate-blur">370- <div class="peach-settings-row">371- <span>Status</span>372- <span class="peach-settings-value peach-settings-value--status">373- <span class="peach-sync-status-dot" style={{ background: getStatusColor(syncStatus()) }}></span>374- {getStatusLabel(syncStatus())}375- </span>376- </div>377- <div class="peach-settings-row peach-settings-row--meta">378- <span>Last sync</span>379- <span class="peach-settings-value">{lastSyncAt() ? formatRelativeTime(lastSyncAt()) : "Never synced"}</span>380- </div>381- </div>382- <div class="peach-sync-pro-gate-overlay">383- <span class="peach-sync-pro-badge">Pro</span>384- <a href="https://peachpasswords.com/#pricing" target="_blank" rel="noopener noreferrer" class="peach-link" style="color:var(--peach-accent);font-size:13px;font-weight:500">385- Upgrade to Pro →386- </a>387- </div>388- </div>389- </div>390- );369+ async function handleSubmitFeedback() {370+ if (!fbMessage().trim() || fbSubmitting()) return;371+ setFbSubmitting(true);372+ setFbResult(null);373+ const res = await sendMessage({374+ type: "SUBMIT_FEEDBACK",375+ payload: {376+ category: fbCategory(),377+ message: fbMessage().trim(),378+ version,379+ browser: navigator.userAgent,380+ },381+ }) as { ok: boolean; error?: string };382+ setFbSubmitting(false);383+ if (res.ok) {384+ setFbResult("success");385+ setFbMessage("");386+ setTimeout(() => setFbResult(null), 4000);387+ } else {388+ setFbResult("error");391389 }390+ }392391 392+ function handleHeaderBack() {393+ if (activeSection() !== null) {394+ setActiveSection(null);395+ } else {396+ props.onBack();397+ }398+ }399+400+ function currentTitle(): string {401+ const s = activeSection();402+ if (s === null) return "settings";403+ if (s === "license") return "account";404+ return s;405+ }406+407+ function renderCatRow(opts: {408+ section: SectionName;409+ name: string;410+ desc: string;411+ icon: () => any;412+ pro?: boolean;413+ }) {393414 return (394- <div class="peach-settings-group">395- <p class="peach-settings-label">sync</p>396- <div class="peach-settings-row">397- <span>Status</span>398- <span class="peach-settings-value peach-settings-value--status">399- <span class="peach-sync-status-dot" style={{ background: getStatusColor(syncStatus()) }}></span>400- {getStatusLabel(syncStatus())}415+ <button416+ class="peach-settings-cat-row"417+ onClick={() => setActiveSection(opts.section)}418+ >419+ <div class="peach-settings-cat-icon">{opts.icon()}</div>420+ <div class="peach-settings-cat-content">421+ <span class="peach-settings-cat-name">422+ {opts.name}423+ <Show when={opts.pro && !isPro()}>424+ <span class="peach-sync-pro-badge" style="margin-left:6px">Pro</span>425+ </Show>401426 </span>427+ <span class="peach-settings-cat-desc">{opts.desc}</span>402428 </div>403- <div class="peach-settings-row peach-settings-row--meta">404- <span>Last sync</span>405- <span class="peach-settings-value">{lastSyncAt() ? formatRelativeTime(lastSyncAt()) : "Never synced"}</span>406- </div>407- <div class="peach-settings-row peach-settings-row--meta">408- <button409- class="peach-btn peach-btn--subtle peach-btn--sm"410- onClick={() => void handleSyncNow()}411- disabled={syncStatus() === "not_trusted" || syncStatus() === "syncing"}412- >413- {syncingNow() ? "Syncing..." : "Sync now"}414- </button>415- </div>429+ <svg430+ class="peach-settings-cat-chevron"431+ width="6" height="10" viewBox="0 0 6 10" fill="none"432+ stroke="currentColor" stroke-width="1.1"433+ stroke-linecap="round" stroke-linejoin="round"434+ >435+ <path d="M1 1l4 4-4 4" />436+ </svg>437+ </button>438+ );439+ }416440 417- <div class="peach-settings-row peach-settings-row--meta peach-settings-row--stack">418- <span class="peach-sync-section-label">Paired devices ({pairedDevices().length})</span>419- {pairedDevices().map((device) => (420- <div class="peach-sync-device-row">421- <div class="peach-sync-device-info">422- <span class="peach-sync-device-name">{device.displayName}</span>423- <span class="peach-sync-device-meta">424- Last seen: {formatRelativeTime(device.lastSeenAt)} · Last synced: {formatRelativeTime(device.lastSyncAt)}425- </span>426- </div>427- <button428- class="peach-btn peach-btn--ghost peach-btn--sm peach-sync-remove-btn"429- onClick={() => void handleRemoveDevice(device.deviceId)}430- >431- Remove432- </button>433- </div>434- ))}435- <div class="peach-settings-actions--row">436- <button437- class="peach-btn peach-btn--subtle peach-btn--sm"438- onClick={() => props.onOpenSyncPairing?.("initiate")}439- >440- Pair a new device441- </button>442- <button443- class="peach-btn peach-btn--subtle peach-btn--sm"444- onClick={() => props.onOpenSyncPairing?.("accept")}445- >446- Pair with another device447- </button>448- </div>449- </div>441+ const iconShield = () => (442+ <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">443+ <path d="M8 2L3 4.5V8c0 3 2.5 5 5 6 2.5-1 5-3 5-6V4.5L8 2z"/>444+ </svg>445+ );446+ const iconKey = () => (447+ <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">448+ <path d="M4 10a3 3 0 100-6 3 3 0 000 6zm3-3h6m-2-2v4"/>449+ </svg>450+ );451+ const iconLock = () => (452+ <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">453+ <rect x="3" y="7" width="10" height="7" rx="1.5"/>454+ <path d="M6 7V5a3 3 0 016 0v2"/>455+ </svg>456+ );457+ const iconSync = () => (458+ <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">459+ <path d="M4 8a4 4 0 014-4m4 4a4 4 0 01-4 4"/>460+ <path d="M14 6l-2 2-2-2"/>461+ <path d="M2 10l2-2 2 2"/>462+ </svg>463+ );464+ const iconSun = () => (465+ <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">466+ <circle cx="8" cy="8" r="3"/>467+ <path d="M8 2v1m0 10v1M2 8h1m10 0h1m-2.1-4.1-.7.7M4.8 11.3l-.7.7m0-7.7.7.7m6.5 6.3-.7.7"/>468+ </svg>469+ );470+ const iconKeys = () => (471+ <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">472+ <rect x="2" y="5" width="5" height="4" rx="1"/>473+ <rect x="9" y="7" width="5" height="4" rx="1"/>474+ <path d="M7 7h2m-2 2h2"/>475+ </svg>476+ );477+ const iconBubble = () => (478+ <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">479+ <path d="M3 4h10a1 1 0 011 1v6a1 1 0 01-1 1H5l-3 2V5a1 1 0 011-1z"/>480+ </svg>481+ );450482 451- <div class="peach-settings-row peach-settings-row--meta peach-settings-row--stack">452- <span class="peach-sync-section-label">Trusted networks ({trustedNetworks().length})</span>453- {trustedNetworks().filter(n => !n.isTailscale).map((network) => (454- <div class="peach-sync-device-row">455- <span class="peach-sync-device-name">{network.ssid ?? "Unknown network"}</span>456- <button457- class="peach-btn peach-btn--ghost peach-btn--sm peach-sync-remove-btn"458- onClick={() => void handleRemoveNetwork(network.ssid, network.bssid, network.isTailscale)}459- >460- Remove461- </button>462- </div>463- ))}464- <div class="peach-settings-actions--row">465- {!isCurrentNetworkTrusted() && (466- <button467- class="peach-btn peach-btn--subtle peach-btn--sm"468- onClick={() => void handleMarkNetworkTrusted()}469- >470- Mark current network as trusted471- </button>472- )}473- </div>474- <span class="peach-sync-device-meta">475- Tailscale is always trusted476- </span>483+ function renderHome() {484+ return (485+ <div class="peach-settings-body">486+ <p class="peach-settings-nav-label">PROTECTION</p>487+ {renderCatRow({ section: "security", name: "Security", desc: "Locking, breach monitoring, passkeys", icon: iconShield })}488+ {renderCatRow({ section: "license", name: "Account", desc: "Plan & license key", icon: iconKey })}489+490+ <p class="peach-settings-nav-label">VAULT</p>491+ {renderCatRow({ section: "vault", name: "Vault", desc: "Entries, health, import & export", icon: iconLock })}492+ {renderCatRow({ section: "sync", name: "Sync", desc: "Paired devices & trusted networks", icon: iconSync, pro: true })}493+494+ <p class="peach-settings-nav-label">PREFERENCES</p>495+ {renderCatRow({ section: "appearance", name: "Appearance", desc: "Theme & accent color", icon: iconSun })}496+ {renderCatRow({ section: "shortcuts", name: "Shortcuts", desc: "Keyboard reference", icon: iconKeys })}497+498+ <p class="peach-settings-nav-label">SUPPORT</p>499+ {renderCatRow({ section: "feedback", name: "Feedback", desc: "Report issues, suggest features", icon: iconBubble })}500+501+ <div class="peach-settings-footer">502+ <span>Peach {version}</span>503+ <span class="peach-divider"></span>504+ <span>Browser extension</span>477505 </div>478506 </div>479507 );480508 }481509 482- return (483- <div class="peach-overlay-view ov-enter-right" style="display:flex">484- <div class="peach-overlay-header">485- <button486- class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"487- onClick={props.onBack}488- aria-label="Back"489- >490- <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">491- <path d="M6 1L1 6L6 11" />492- </svg>493- </button>494- <span class="peach-overlay-title">settings</span>495- <div class="peach-overlay-spacer"></div>496- </div>497-510+ function renderSecurity() {511+ return (498512 <div class="peach-settings-body">499513 <div class="peach-settings-group">500- <p class="peach-settings-label">security</p>514+ <p class="peach-settings-sub-label">LOCKING</p>501515 <div class="peach-settings-row">502516 <span>Lock on close</span>503517 <button@@ -522,13 +536,13 @@522536 <option value="0">Never</option>523537 </select>524538 </div>539+540+ <p class="peach-settings-sub-label">BREACH PROTECTION</p>525541 <div class="peach-settings-row">526542 <span>Breach monitoring</span>527543 <button528544 class={`peach-toggle ${props.breachMonitoring ? "is-on" : ""}`}529- onClick={() =>530- void props.onBreachMonitoringChange(!props.breachMonitoring)531- }545+ onClick={() => void props.onBreachMonitoringChange(!props.breachMonitoring)}532546 aria-label="Toggle breach monitoring"533547 >534548 <span class="peach-toggle-knob"></span>@@ -544,13 +558,14 @@544558 {checkingBreaches() ? "checking..." : "run"}545559 </button>546560 </div>547- {breachResult() && (561+ <Show when={breachResult()}>548562 <div class="peach-settings-row peach-settings-row--meta">549563 <span>Last check</span>550564 <span class="peach-settings-value">{breachResult()}</span>551565 </div>552- )}553- {/* ── Change master password ──────────────────────────────────── */}566+ </Show>567+568+ <p class="peach-settings-sub-label">MASTER PASSWORD</p>554569 <div class="peach-settings-row peach-settings-row--meta">555570 <span>Master password</span>556571 <button@@ -603,42 +618,8 @@603618 </button>604619 </div>605620 </Show>606- </div>607621 608- {/* ── Keyboard shortcuts ──────────────────────────────────────────── */}609- <div class="peach-settings-group">610- <p class="peach-settings-label">keyboard shortcuts</p>611- <div class="peach-settings-row peach-settings-row--meta">612- <span>Inline popup shortcut</span>613- <a614- class="peach-settings-link"615- href="chrome://extensions/shortcuts"616- onClick={(e) => {617- e.preventDefault();618- chrome.tabs.create({ url: "chrome://extensions/shortcuts" });619- }}620- >621- Ctrl+Shift+Space — customize ↗622- </a>623- </div>624- </div>625-626- {/* ── Cards ─────────────────────────────────────────────────────── */}627- <Show when={declinedCardCount() > 0}>628- <div class="peach-settings-group">629- <p class="peach-settings-label">cards</p>630- <div class="peach-settings-row peach-settings-row--meta">631- <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>632- <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>633- Clear634- </button>635- </div>636- </div>637- </Show>638-639- {/* ── Duress password ─────────────────────────────────────────────── */}640- <div class="peach-settings-group">641- <p class="peach-settings-label">duress password</p>622+ <p class="peach-settings-sub-label">DURESS PASSWORD</p>642623 <div class="peach-settings-row peach-settings-row--top">643624 <div class="peach-settings-row-main">644625 <span>Duress password</span>@@ -741,10 +722,8 @@741722 </div>742723 </div>743724 </Show>744- </div>745725 746- <div class="peach-settings-group">747- <p class="peach-settings-label">passkeys</p>726+ <p class="peach-settings-sub-label">PASSKEYS</p>748727 <div class="peach-settings-row peach-settings-row--top">749728 <div class="peach-settings-row-main">750729 <span>Intercept passkey requests</span>@@ -769,23 +748,27 @@769748 <span>Browser</span>770749 <span class="peach-settings-value">771750 {passkeyState()?.browserLabel ?? "Unknown"}772- {passkeyState()?.supported ? " \u2713" : " (interception unavailable)"}751+ {passkeyState()?.supported ? " ✓" : " (interception unavailable)"}773752 </span>774753 </div>775754 <div class="peach-info-notice" style="margin-top:8px">776755 Only one passkey provider can be active at a time. Disable Bitwarden or 1Password passkey interception if Peach reports a conflict.777756 </div>778- {passkeyState()?.conflict && (757+ <Show when={passkeyState()?.conflict}>779758 <div class="peach-settings-row peach-settings-row--meta">780759 <span class="peach-settings-value" style="color:#e7b36b">{passkeyState()!.conflict}</span>781760 </div>782- )}761+ </Show>783762 </div>763+ </div>764+ );765+ }784766 785- {renderSyncSection()}786-767+ function renderVault() {768+ return (769+ <div class="peach-settings-body">787770 <div class="peach-settings-group">788- <p class="peach-settings-label">vault</p>771+ <p class="peach-settings-sub-label">ENTRIES</p>789772 <div class="peach-settings-row peach-settings-row--meta">790773 <span>Entries</span>791774 <span class="peach-settings-value">{props.entriesCount}</span>@@ -828,35 +811,9 @@828811 </div>829812 </div>830813 </Show>831- <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">832- <span>Accent</span>833- <div class="peach-accent-picker">834- <For each={ACCENT_COLORS}>835- {(color) => (836- <button837- class="peach-accent-swatch"838- classList={{ "is-active": props.accentColor === color.name }}839- style={{ background: color.hex }}840- onClick={() => void props.onAccentChange(color.name)}841- aria-label={color.name}842- title={color.name}843- />844- )}845- </For>846- </div>847- </div>814+815+ <p class="peach-settings-sub-label">MAINTENANCE</p>848816 <div class="peach-settings-row peach-settings-row--meta">849- <span>Theme</span>850- <button851- class="peach-btn peach-btn--subtle peach-btn--sm"852- onClick={() =>853- props.onThemeChange(props.theme === "dark" ? "light" : "dark")854- }855- >856- {props.theme === "dark" ? "Switch to light" : "Switch to dark"}857- </button>858- </div>859- <div class="peach-settings-row peach-settings-row--meta">860817 <span>Trash auto-delete</span>861818 <select862819 class="peach-settings-select"@@ -870,6 +827,22 @@870827 </select>871828 </div>872829 <div class="peach-settings-row peach-settings-row--meta">830+ <span>Vault Health</span>831+ <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>832+ open833+ </button>834+ </div>835+ <Show when={declinedCardCount() > 0}>836+ <div class="peach-settings-row peach-settings-row--meta">837+ <span>{declinedCardCount()} card{declinedCardCount() === 1 ? '' : 's'} set to never save</span>838+ <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => void handleClearDeclinedCards()}>839+ Clear840+ </button>841+ </div>842+ </Show>843+844+ <p class="peach-settings-sub-label">DATA</p>845+ <div class="peach-settings-row peach-settings-row--meta">873846 <span>Import passwords</span>874847 <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onImport}>875848 open@@ -881,12 +854,6 @@881854 open882855 </button>883856 </div>884- <div class="peach-settings-row peach-settings-row--meta">885- <span>Vault Health</span>886- <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={props.onOpenHealth}>887- open888- </button>889- </div>890857 <div class="peach-settings-row peach-settings-row--meta" style="flex-direction:column;align-items:stretch;gap:6px">891858 <span>Replace vault from backup</span>892859 <div class="peach-restore-warning" style="margin-top:2px">@@ -908,63 +875,188 @@908875 </div>909876 </div>910877 </div>878+ </div>879+ );880+ }911881 912- <div class="peach-settings-group">913- <p class="peach-settings-label">license</p>914- {licenseInfo()?.valid && licenseInfo()?.plan === "pro" ? (915- <div class="peach-settings-row peach-settings-row--meta">916- <span>Peach Pro</span>917- <span class="peach-settings-value">918- activated {licenseInfo()?.issued ? new Date(licenseInfo()!.issued!).toLocaleDateString() : ""}919- </span>882+ function renderSync() {883+ if (!isPro()) {884+ return (885+ <div class="peach-settings-body">886+ <div class="peach-settings-group" style="position:relative">887+ <div class="peach-sync-pro-gate">888+ <div class="peach-sync-pro-gate-blur">889+ <div class="peach-settings-row">890+ <span>Status</span>891+ <span class="peach-settings-value peach-settings-value--status">892+ <span class="peach-sync-status-dot" style={{ background: getStatusColor(syncStatus()) }}></span>893+ {getStatusLabel(syncStatus())}894+ </span>895+ </div>896+ <div class="peach-settings-row peach-settings-row--meta">897+ <span>Last sync</span>898+ <span class="peach-settings-value">{lastSyncAt() ? formatRelativeTime(lastSyncAt()) : "Never synced"}</span>899+ </div>900+ </div>901+ <div class="peach-sync-pro-gate-overlay">902+ <span class="peach-sync-pro-badge">Pro</span>903+ <a href="https://peachpasswords.com/#pricing" target="_blank" rel="noopener noreferrer" class="peach-link" style="color:var(--peach-accent);font-size:13px;font-weight:500">904+ Upgrade to Pro →905+ </a>906+ </div>920907 </div>921- ) : (922- <>923- <div class="peach-settings-row" style="flex-direction:column;align-items:stretch;gap:8px">924- <input925- class="peach-underline-input"926- type="text"927- placeholder="PPK-XXXX-XXXX-XXXX-XXXX-XXXX"928- value={licenseKeyInput()}929- onInput={(e) => setLicenseKeyInput(e.currentTarget.value)}930- style="width:100%;box-sizing:border-box"931- />908+ </div>909+ </div>910+ );911+ }912+913+ return (914+ <div class="peach-settings-body">915+ <div class="peach-settings-group">916+ <div class="peach-settings-row">917+ <span>Status</span>918+ <span class="peach-settings-value peach-settings-value--status">919+ <span class="peach-sync-status-dot" style={{ background: getStatusColor(syncStatus()) }}></span>920+ {getStatusLabel(syncStatus())}921+ </span>922+ </div>923+ <div class="peach-settings-row peach-settings-row--meta">924+ <span>Last sync</span>925+ <span class="peach-settings-value">{lastSyncAt() ? formatRelativeTime(lastSyncAt()) : "Never synced"}</span>926+ </div>927+ <div class="peach-settings-row peach-settings-row--meta">928+ <button929+ class="peach-btn peach-btn--subtle peach-btn--sm"930+ onClick={() => void handleSyncNow()}931+ disabled={syncStatus() === "not_trusted" || syncStatus() === "syncing"}932+ >933+ {syncingNow() ? "Syncing..." : "Sync now"}934+ </button>935+ </div>936+937+ <div class="peach-settings-row peach-settings-row--meta peach-settings-row--stack">938+ <span class="peach-sync-section-label">Paired devices ({pairedDevices().length})</span>939+ {pairedDevices().map((device) => (940+ <div class="peach-sync-device-row">941+ <div class="peach-sync-device-info">942+ <span class="peach-sync-device-name">{device.displayName}</span>943+ <span class="peach-sync-device-meta">944+ Last seen: {formatRelativeTime(device.lastSeenAt)} · Last synced: {formatRelativeTime(device.lastSyncAt)}945+ </span>946+ </div>932947 <button933- class="peach-btn peach-btn--subtle peach-btn--sm"934- onClick={() => void handleActivateLicense()}935- disabled={activatingLicense() || !licenseKeyInput().trim()}948+ class="peach-btn peach-btn--ghost peach-btn--sm peach-sync-remove-btn"949+ onClick={() => void handleRemoveDevice(device.deviceId)}936950 >937- {activatingLicense() ? "activating..." : "Activate"}951+ Remove938952 </button>939953 </div>940- {licenseError() && (941- <div class="peach-settings-row peach-settings-row--meta">942- <span class="peach-settings-value" style="color:#e55">{licenseError()}</span>943- </div>944- )}945- {licenseSuccess() && (946- <div class="peach-settings-row peach-settings-row--meta">947- <span class="peach-settings-value" style="color:#5e5">{licenseSuccess()}</span>948- </div>949- )}950- <div class="peach-settings-row peach-settings-row--meta">951- <a952- href="https://peachpasswords.com/#pricing"953- target="_blank"954- rel="noopener noreferrer"955- class="peach-link"956- style="color:var(--peach-accent)"954+ ))}955+ <div class="peach-settings-actions--row">956+ <button957+ class="peach-btn peach-btn--subtle peach-btn--sm"958+ onClick={() => props.onOpenSyncPairing?.("initiate")}959+ >960+ Pair a new device961+ </button>962+ <button963+ class="peach-btn peach-btn--subtle peach-btn--sm"964+ onClick={() => props.onOpenSyncPairing?.("accept")}965+ >966+ Pair with another device967+ </button>968+ </div>969+ </div>970+971+ <div class="peach-settings-row peach-settings-row--meta peach-settings-row--stack">972+ <span class="peach-sync-section-label">Trusted networks ({trustedNetworks().length})</span>973+ {trustedNetworks().filter(n => !n.isTailscale).map((network) => (974+ <div class="peach-sync-device-row">975+ <span class="peach-sync-device-name">{network.ssid ?? "Unknown network"}</span>976+ <button977+ class="peach-btn peach-btn--ghost peach-btn--sm peach-sync-remove-btn"978+ onClick={() => void handleRemoveNetwork(network.ssid, network.bssid, network.isTailscale)}957979 >958- Purchase →959- </a>980+ Remove981+ </button>960982 </div>961- </>962- )}983+ ))}984+ <div class="peach-settings-actions--row">985+ <Show when={!isCurrentNetworkTrusted()}>986+ <button987+ class="peach-btn peach-btn--subtle peach-btn--sm"988+ onClick={() => void handleMarkNetworkTrusted()}989+ >990+ Mark current network as trusted991+ </button>992+ </Show>993+ </div>994+ <span class="peach-sync-device-meta">995+ Tailscale is always trusted996+ </span>997+ </div>963998 </div>999+ </div>1000+ );1001+ }9641002 965- {/* ── Keyboard shortcuts ────────────────────────────────────── */}1003+ function renderAppearance() {1004+ return (1005+ <div class="peach-settings-body">9661006 <div class="peach-settings-group">967- <p class="peach-settings-label">keyboard shortcuts</p>1007+ <p class="peach-settings-sub-label">THEME</p>1008+ <div class="peach-settings-row peach-settings-row--meta">1009+ <span>Theme</span>1010+ <button1011+ class="peach-btn peach-btn--subtle peach-btn--sm"1012+ onClick={() =>1013+ props.onThemeChange(props.theme === "dark" ? "light" : "dark")1014+ }1015+ >1016+ {props.theme === "dark" ? "Switch to light" : "Switch to dark"}1017+ </button>1018+ </div>1019+1020+ <p class="peach-settings-sub-label">ACCENT</p>1021+ <div class="peach-settings-row peach-settings-row--meta peach-settings-row--accent">1022+ <span>Accent</span>1023+ <div class="peach-accent-picker">1024+ <For each={ACCENT_COLORS}>1025+ {(color) => (1026+ <button1027+ class="peach-accent-swatch"1028+ classList={{ "is-active": props.accentColor === color.name }}1029+ style={{ background: color.hex }}1030+ onClick={() => void props.onAccentChange(color.name)}1031+ aria-label={color.name}1032+ title={color.name}1033+ />1034+ )}1035+ </For>1036+ </div>1037+ </div>1038+ </div>1039+ </div>1040+ );1041+ }1042+1043+ function renderShortcuts() {1044+ return (1045+ <div class="peach-settings-body">1046+ <div class="peach-settings-group">1047+ <div class="peach-settings-row peach-settings-row--meta">1048+ <span>Inline popup shortcut</span>1049+ <a1050+ class="peach-settings-link"1051+ href="chrome://extensions/shortcuts"1052+ onClick={(e) => {1053+ e.preventDefault();1054+ chrome.tabs.create({ url: "chrome://extensions/shortcuts" });1055+ }}1056+ >1057+ Ctrl+Shift+Space — customize ↗1058+ </a>1059+ </div>9681060 <div class="peach-shortcuts-grid">9691061 <span class="peach-shortcut-key">C</span><span class="peach-shortcut-desc">copy password</span>9701062 <span class="peach-shortcut-key">U</span><span class="peach-shortcut-desc">copy username</span>@@ -974,13 +1066,159 @@9741066 <span class="peach-shortcut-key">⌘⇧P</span><span class="peach-shortcut-desc">autofill on page</span>9751067 </div>9761068 </div>1069+ </div>1070+ );1071+ }9771072 978- <div class="peach-settings-footer">979- <span>Peach {version}</span>980- <span class="peach-divider"></span>981- <span>Browser extension</span>1073+ function renderLicense() {1074+ return (1075+ <div class="peach-settings-body">1076+ <div class="peach-settings-group">1077+ <p class="peach-settings-sub-label">PLAN</p>1078+ <Show1079+ when={licenseInfo()?.valid && licenseInfo()?.plan === "pro"}1080+ fallback={1081+ <>1082+ <div class="peach-settings-row" style="flex-direction:column;align-items:stretch;gap:8px">1083+ <input1084+ class="peach-underline-input"1085+ type="text"1086+ placeholder="PPK-XXXX-XXXX-XXXX-XXXX-XXXX"1087+ value={licenseKeyInput()}1088+ onInput={(e) => setLicenseKeyInput(e.currentTarget.value)}1089+ style="width:100%;box-sizing:border-box"1090+ />1091+ <button1092+ class="peach-btn peach-btn--subtle peach-btn--sm"1093+ onClick={() => void handleActivateLicense()}1094+ disabled={activatingLicense() || !licenseKeyInput().trim()}1095+ >1096+ {activatingLicense() ? "activating..." : "Activate"}1097+ </button>1098+ </div>1099+ <Show when={licenseError()}>1100+ <div class="peach-settings-row peach-settings-row--meta">1101+ <span class="peach-settings-value" style="color:#e55">{licenseError()}</span>1102+ </div>1103+ </Show>1104+ <Show when={licenseSuccess()}>1105+ <div class="peach-settings-row peach-settings-row--meta">1106+ <span class="peach-settings-value" style="color:#5e5">{licenseSuccess()}</span>1107+ </div>1108+ </Show>1109+ <div class="peach-settings-row peach-settings-row--meta">1110+ <a1111+ href="https://peachpasswords.com/#pricing"1112+ target="_blank"1113+ rel="noopener noreferrer"1114+ class="peach-link"1115+ style="color:var(--peach-accent)"1116+ >1117+ Purchase →1118+ </a>1119+ </div>1120+ </>1121+ }1122+ >1123+ <div class="peach-settings-row peach-settings-row--meta">1124+ <span>Peach Pro</span>1125+ <span class="peach-settings-value">1126+ activated {licenseInfo()?.issued ? new Date(licenseInfo()!.issued!).toLocaleDateString() : ""}1127+ </span>1128+ </div>1129+ </Show>9821130 </div>9831131 </div>1132+ );1133+ }1134+1135+ function renderFeedback() {1136+ return (1137+ <div class="peach-settings-body">1138+ <div class="peach-settings-group">1139+ <p class="peach-feedback-intro">1140+ Help us improve Peach. Every report goes directly to our inbox.1141+ </p>1142+1143+ <p class="peach-settings-sub-label">TYPE</p>1144+ <div class="peach-feedback-chips">1145+ <For each={FEEDBACK_CATEGORIES}>1146+ {(cat) => (1147+ <button1148+ class={`peach-feedback-chip ${fbCategory() === cat ? "is-active" : ""}`}1149+ onClick={() => setFbCategory(cat)}1150+ >1151+ {cat}1152+ </button>1153+ )}1154+ </For>1155+ </div>1156+1157+ <p class="peach-settings-sub-label">MESSAGE</p>1158+ <textarea1159+ class="peach-feedback-textarea"1160+ placeholder="Describe the issue or idea..."1161+ value={fbMessage()}1162+ onInput={(e) => setFbMessage(e.currentTarget.value)}1163+ rows={5}1164+ />1165+1166+ <p class="peach-feedback-meta">1167+ Version {version} · will be included automatically1168+ </p>1169+1170+ <Show when={fbResult() === "success"}>1171+ <p class="peach-feedback-result peach-feedback-result--ok">1172+ Sent — thank you1173+ </p>1174+ </Show>1175+ <Show when={fbResult() === "error"}>1176+ <p class="peach-feedback-result peach-feedback-result--err">1177+ Failed to send — try again1178+ </p>1179+ </Show>1180+1181+ <button1182+ class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"1183+ onClick={() => void handleSubmitFeedback()}1184+ disabled={fbSubmitting() || !fbMessage().trim()}1185+ >1186+ {fbSubmitting() ? "Sending..." : "Send feedback"}1187+ </button>1188+1189+ <p class="peach-feedback-anon">1190+ Sent anonymously · no account required1191+ </p>1192+ </div>1193+ </div>1194+ );1195+ }1196+1197+ return (1198+ <div class="peach-overlay-view ov-enter-right" style="display:flex">1199+ <div class="peach-overlay-header">1200+ <button1201+ class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1202+ onClick={handleHeaderBack}1203+ aria-label="Back"1204+ >1205+ <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">1206+ <path d="M6 1L1 6L6 11" />1207+ </svg>1208+ </button>1209+ <span class="peach-overlay-title">{currentTitle()}</span>1210+ <div class="peach-overlay-spacer"></div>1211+ </div>1212+1213+ <Switch fallback={renderHome()}>1214+ <Match when={activeSection() === "security"}>{renderSecurity()}</Match>1215+ <Match when={activeSection() === "vault"}>{renderVault()}</Match>1216+ <Match when={activeSection() === "sync"}>{renderSync()}</Match>1217+ <Match when={activeSection() === "appearance"}>{renderAppearance()}</Match>1218+ <Match when={activeSection() === "shortcuts"}>{renderShortcuts()}</Match>1219+ <Match when={activeSection() === "license"}>{renderLicense()}</Match>1220+ <Match when={activeSection() === "feedback"}>{renderFeedback()}</Match>1221+ </Switch>9841222 </div>9851223 );9861224 }src/popup/views/contracts.test.tsx+27 −6
@@ -327,6 +327,12 @@327327 );328328 await flushView();329329 330+ const securityRow = Array.from(container.querySelectorAll("button.peach-settings-cat-row")).find(331+ (b) => b.querySelector(".peach-settings-cat-name")?.textContent?.trim() === "Security"332+ ) as HTMLButtonElement;333+ securityRow.click();334+ await flushView();335+330336 const changeButton = Array.from(container.querySelectorAll("button")).find(331337 (candidate) => candidate.textContent?.trim() === "change"332338 ) as HTMLButtonElement;@@ -377,16 +383,18 @@377383 await flushView();378384 sendMessageMock.mockClear();379385 386+ const headerBack = container.querySelector(".peach-overlay-header button") as HTMLButtonElement;387+388+ const securityRow = Array.from(container.querySelectorAll("button.peach-settings-cat-row")).find(389+ (b) => b.querySelector(".peach-settings-cat-name")?.textContent?.trim() === "Security"390+ ) as HTMLButtonElement;391+ securityRow.click();392+ await flushView();393+380394 const selects = container.querySelectorAll("select");381395 changeSelect(selects[0] as HTMLSelectElement, "15");382396 expect(onAutoLockMinutesChange).toHaveBeenCalledWith(15);383397 384- const themeButton = Array.from(container.querySelectorAll("button")).find(385- (candidate) => candidate.textContent?.trim() === "Switch to light"386- ) as HTMLButtonElement;387- themeButton.click();388- expect(onThemeChange).toHaveBeenCalledWith("light");389-390398 const changeButton = Array.from(container.querySelectorAll("button")).find(391399 (candidate) => candidate.textContent?.trim() === "change"392400 ) as HTMLButtonElement;@@ -404,6 +412,19 @@404412 ) as HTMLButtonElement;405413 saveButton.click();406414 await flushView();415+416+ headerBack.click();417+ await flushView();418+ const appearanceRow = Array.from(container.querySelectorAll("button.peach-settings-cat-row")).find(419+ (b) => b.querySelector(".peach-settings-cat-name")?.textContent?.trim() === "Appearance"420+ ) as HTMLButtonElement;421+ appearanceRow.click();422+ await flushView();423+ const themeButton = Array.from(container.querySelectorAll("button")).find(424+ (candidate) => candidate.textContent?.trim() === "Switch to light"425+ ) as HTMLButtonElement;426+ themeButton.click();427+ expect(onThemeChange).toHaveBeenCalledWith("light");407428 408429 expect(sendMessageMock).toHaveBeenCalledWith({409430 type: "CHANGE_PASSWORD",