d99308b4fb2a
fix: Polish and harden feedback flow (v3.6.63)
- time
- 2026-05-20 11:36 UTC · 5 months ago
- machine
- koh2
- parent
- 893de0a2c330
↓ peach-browser-d99308b4.tar.gzatlas at this save
files changed8 files
manifest.firefox.json+1 −1manifest.json+1 −1package-lock.json+2 −2package.json+1 −1src/popup/styles/popup.css+33 −3src/popup/styles/settings-stack.css+22 −0src/popup/views/Settings.tsx+80 −44src/popup/views/contracts.test.tsx+78 −0manifest.firefox.json+1 −1
@@ -2,7 +2,7 @@22 "_comment_audio_capture": "Firefox does not honor the Chrome 'audioCapture' optional_permission — getUserMedia in WebExtensions is gated solely by the runtime permission prompt. Microphone access for chirp pairing works from the popup under the existing '<all_urls>' host permission; no manifest entry is required or accepted here.",33 "manifest_version": 3,44 "name": "Peach",5- "version": "3.6.62",5+ "version": "3.6.63",66 "description": "Zero-knowledge password manager",77 "browser_specific_settings": {88 "gecko": {manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach",4- "version": "3.6.62",4+ "version": "3.6.63",55 "description": "Zero-knowledge password manager",66 "action": {77 "default_popup": "src/popup/index.html",package-lock.json+2 −2
@@ -1,12 +1,12 @@11 {22 "name": "peach",3- "version": "3.6.49",3+ "version": "3.6.63",44 "lockfileVersion": 3,55 "requires": true,66 "packages": {77 "": {88 "name": "peach",9- "version": "3.6.49",9+ "version": "3.6.63",1010 "dependencies": {1111 "@bokuweb/zstd-wasm": "^0.0.27",1212 "@stablelib/blake2s": "^2.0.1",package.json+1 −1
@@ -1,6 +1,6 @@11 {22 "name": "peach",3- "version": "3.6.54",3+ "version": "3.6.63",44 "description": "Zero-knowledge password manager",55 "type": "module",66 "scripts": {src/popup/styles/popup.css+33 −3
@@ -2048,9 +2048,13 @@20482048 color: var(--peach-t1);20492049 }20502050 .peach-feedback-chip.is-active {2051- border-color: color-mix(in srgb, var(--peach-accent) 50%, transparent);2052- background: color-mix(in srgb, var(--peach-accent) 8%, transparent);2053- color: var(--peach-accent);2051+ border-color: color-mix(in srgb, var(--peach-accent) 82%, white 18%);2052+ background: color-mix(in srgb, var(--peach-accent) 22%, rgba(255, 255, 255, 0.08));2053+ color: color-mix(in srgb, var(--peach-accent) 86%, white 14%);2054+ box-shadow:2055+ 0 0 0 1px color-mix(in srgb, var(--peach-accent) 42%, transparent),2056+ 0 0 14px color-mix(in srgb, var(--peach-accent) 22%, transparent);2057+ font-weight: 700;20542058 }20552059 20562060 .peach-feedback-textarea {@@ -2107,6 +2111,32 @@21072111 font-size: 0.625rem;21082112 color: var(--peach-t2);21092113 text-align: center;2114+}2115+2116+.peach-feedback-thanks {2117+ margin: auto 0;2118+ padding: 18px 14px;2119+ border: 1px solid color-mix(in srgb, var(--peach-accent) 42%, transparent);2120+ border-radius: 8px;2121+ background:2122+ linear-gradient(180deg, color-mix(in srgb, var(--peach-accent) 16%, transparent), transparent),2123+ rgba(0, 0, 0, 0.18);2124+ color: var(--peach-t1);2125+ text-align: center;2126+}2127+2128+.peach-feedback-thanks p {2129+ margin: 0;2130+ font-size: 0.75rem;2131+ line-height: 1.5;2132+}2133+2134+.peach-feedback-thanks-title {2135+ color: var(--peach-t0);2136+ font-family: "Cormorant Garamond", serif;2137+ font-size: 1.375rem !important;2138+ line-height: 1.1 !important;2139+ margin-bottom: 8px !important;21102140 }21112141 21122142 .peach-passkey-badge {src/popup/styles/settings-stack.css+22 −0
@@ -432,6 +432,28 @@432432 background: color-mix(in srgb, currentColor 16%, transparent);433433 }434434 435+.stk-card .peach-feedback-chip.is-active {436+ color: #18121a;437+ border-color: color-mix(in srgb, currentColor 92%, white 8%);438+ background: color-mix(in srgb, currentColor 86%, white 14%);439+ box-shadow:440+ 0 0 0 1px color-mix(in srgb, currentColor 56%, transparent),441+ 0 0 16px color-mix(in srgb, currentColor 28%, transparent);442+ font-weight: 700;443+}444+445+.stk-card .peach-feedback-thanks {446+ color: currentColor;447+ border-color: color-mix(in srgb, currentColor 32%, transparent);448+ background:449+ linear-gradient(180deg, color-mix(in srgb, currentColor 12%, transparent), transparent),450+ rgba(0, 0, 0, 0.18);451+}452+453+.stk-card .peach-feedback-thanks-title {454+ color: currentColor;455+}456+435457 .stk-card .peach-settings-input,436458 .stk-card .peach-settings-select,437459 .stk-card .peach-pro-key-inline-input,src/popup/views/Settings.tsx+80 −44
@@ -73,7 +73,20 @@7373 }7474 7575 const FEEDBACK_CATEGORIES = ["Bug report", "Feature request", "Question", "Other"];76+const MAX_FEEDBACK_MESSAGE_LENGTH = 4000;7677 78+function sanitizeFeedbackMessage(value: string): string {79+ return value80+ .trim()81+ .slice(0, MAX_FEEDBACK_MESSAGE_LENGTH)82+ .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "")83+ .replace(/&/g, "&")84+ .replace(/</g, "<")85+ .replace(/>/g, ">")86+ .replace(/"/g, """)87+ .replace(/'/g, "'");88+}89+7790 function formatActivatedSince(issued: string | undefined): string {7891 if (!issued) return "Active";7992 try {@@ -577,18 +590,22 @@577590 }578591 579592 async function handleSubmitFeedback() {580- if (!fbMessage().trim()) return;593+ const message = sanitizeFeedbackMessage(fbMessage());594+ if (!message) return;581595 582596 setFeedbackState('sending');583597 584598 try {599+ const category = FEEDBACK_CATEGORIES.includes(fbCategory())600+ ? fbCategory()601+ : FEEDBACK_CATEGORIES[0];585602 const res = await fetch('https://api.web3forms.com/submit', {586603 method: 'POST',587604 headers: { 'Content-Type': 'application/json' },588605 body: JSON.stringify({589606 access_key: WEB3FORMS_KEY,590- subject: `Peach Feedback — ${fbCategory()}`,591- message: fbMessage(),607+ subject: `Peach Feedback - ${category}`,608+ message,592609 from_name: 'Peach User',593610 // Honeypot — leave blank594611 botcheck: '',@@ -602,7 +619,6 @@602619 setFeedbackState('sent');603620 setFbMessage('');604621 setFbCategory(FEEDBACK_CATEGORIES[0]);605- setTimeout(() => setFeedbackState('idle'), 4000);606622 } catch (err) {607623 console.error('[feedback]', err);608624 setFeedbackState('error');@@ -1689,52 +1705,72 @@16891705 return (16901706 <div class="peach-settings-body">16911707 <div class="peach-settings-group peach-feedback-group">1692- <p class="peach-feedback-intro">1693- Help us improve Peach. Every report goes directly to our inbox.1694- </p>1708+ <Show1709+ when={feedbackState() === 'sent'}1710+ fallback={1711+ <>1712+ <p class="peach-feedback-intro">1713+ Help us improve Peach. Every report goes directly to our inbox.1714+ </p>16951715 1696- <p class="peach-settings-sub-label">TYPE</p>1697- <div class="peach-feedback-chips">1698- <For each={FEEDBACK_CATEGORIES}>1699- {(cat) => (1716+ <p class="peach-settings-sub-label">TYPE</p>1717+ <div class="peach-feedback-chips">1718+ <For each={FEEDBACK_CATEGORIES}>1719+ {(cat) => {1720+ const selected = () => fbCategory() === cat;1721+ return (1722+ <button1723+ type="button"1724+ class={`peach-feedback-chip ${selected() ? "is-active" : ""}`}1725+ aria-pressed={selected()}1726+ onClick={() => setFbCategory(cat)}1727+ >1728+ {cat}1729+ </button>1730+ );1731+ }}1732+ </For>1733+ </div>1734+1735+ <p class="peach-settings-sub-label">MESSAGE</p>1736+ <textarea1737+ class="peach-feedback-textarea"1738+ placeholder="Describe the issue or idea..."1739+ maxLength={MAX_FEEDBACK_MESSAGE_LENGTH}1740+ value={fbMessage()}1741+ onInput={(e) => setFbMessage(e.currentTarget.value)}1742+ />1743+1744+ <p class="peach-feedback-meta">1745+ Version {version} · will be included automatically1746+ </p>1747+17001748 <button1701- class={`peach-feedback-chip ${fbCategory() === cat ? "is-active" : ""}`}1702- onClick={() => setFbCategory(cat)}1749+ type="button"1750+ class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"1751+ disabled={!fbMessage().trim() || feedbackState() === 'sending'}1752+ onClick={() => void handleSubmitFeedback()}17031753 >1704- {cat}1754+ <Switch>1755+ <Match when={feedbackState() === 'idle'}>Send</Match>1756+ <Match when={feedbackState() === 'sending'}>Sending...</Match>1757+ <Match when={feedbackState() === 'error'}>Failed - try again</Match>1758+ </Switch>17051759 </button>1706- )}1707- </For>1708- </div>17091760 1710- <p class="peach-settings-sub-label">MESSAGE</p>1711- <textarea1712- class="peach-feedback-textarea"1713- placeholder="Describe the issue or idea..."1714- value={fbMessage()}1715- onInput={(e) => setFbMessage(e.currentTarget.value)}1716- />1717-1718- <p class="peach-feedback-meta">1719- Version {version} · will be included automatically1720- </p>1721-1722- <button1723- class="peach-btn peach-btn--accent peach-btn--sm peach-feedback-submit"1724- disabled={!fbMessage().trim() || feedbackState() === 'sending'}1725- onClick={() => void handleSubmitFeedback()}1761+ <p class="peach-feedback-anon">1762+ Sent anonymously · no account required1763+ </p>1764+ </>1765+ }17261766 >1727- <Switch>1728- <Match when={feedbackState() === 'idle'}>Send</Match>1729- <Match when={feedbackState() === 'sending'}>Sending…</Match>1730- <Match when={feedbackState() === 'sent'}>Sent ✓</Match>1731- <Match when={feedbackState() === 'error'}>Failed — try again</Match>1732- </Switch>1733- </button>1734-1735- <p class="peach-feedback-anon">1736- Sent anonymously · no account required1737- </p>1767+ <div class="peach-feedback-thanks" role="status" aria-live="polite">1768+ <p class="peach-feedback-thanks-title">Thank you</p>1769+ <p>1770+ Your note is in the queue. Someone will process it soon.1771+ </p>1772+ </div>1773+ </Show>17381774 </div>17391775 </div>17401776 );src/popup/views/contracts.test.tsx+78 −0
@@ -313,6 +313,84 @@313313 expect(saveButton.disabled).toBe(false);314314 });315315 316+ it("Settings feedback highlights the selected category and hides the form after submit", async () => {317+ sendMessageMock.mockResolvedValue({ ok: true, data: null });318+ const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => ({319+ ok: true,320+ json: async () => ({ success: true }),321+ }));322+ vi.stubGlobal("fetch", fetchMock);323+324+ const container = mount(325+ <SettingsView326+ entriesCount={1}327+ breachMonitoring={true}328+ lockOnClose={true}329+ autoLockMinutes={5}330+ trashRetentionDays={30}331+ theme="dark"332+ accentColor="peach"333+ textSize="medium"334+ reduceMotion={false}335+ initialSection="feedback"336+ onBreachMonitoringChange={vi.fn()}337+ onLockOnCloseChange={vi.fn()}338+ onAutoLockMinutesChange={vi.fn()}339+ onTrashRetentionChange={vi.fn()}340+ onThemeChange={vi.fn()}341+ onAccentChange={vi.fn()}342+ onTextSizeChange={vi.fn()}343+ onReduceMotionChange={vi.fn()}344+ shortcuts={DEFAULT_BINDINGS}345+ onShortcutsChange={vi.fn()}346+ onBack={vi.fn()}347+ onImport={vi.fn()}348+ onExport={vi.fn()}349+ onBreachCheckDone={vi.fn()}350+ />351+ );352+ await flushView();353+354+ const feedbackCard = container.querySelector('[data-settings-card-id="feedback"]') as HTMLElement;355+ expect(feedbackCard.classList.contains("is-active")).toBe(true);356+357+ const categoryButtons = Array.from(container.querySelectorAll(".peach-feedback-chip")) as HTMLButtonElement[];358+ const bugButton = categoryButtons.find((button) => button.textContent?.trim() === "Bug report")!;359+ const featureButton = categoryButtons.find((button) => button.textContent?.trim() === "Feature request")!;360+ expect(bugButton.getAttribute("aria-pressed")).toBe("true");361+ expect(featureButton.getAttribute("aria-pressed")).toBe("false");362+363+ featureButton.click();364+ await flushView();365+366+ expect(bugButton.getAttribute("aria-pressed")).toBe("false");367+ expect(featureButton.getAttribute("aria-pressed")).toBe("true");368+ expect(featureButton.classList.contains("is-active")).toBe(true);369+370+ inputText(371+ container.querySelector(".peach-feedback-textarea") as HTMLTextAreaElement,372+ '<script>alert("xss")</script> Please make this easier to see & safer.'373+ );374+ const sendButton = Array.from(container.querySelectorAll("button")).find(375+ (candidate) => candidate.textContent?.trim() === "Send"376+ ) as HTMLButtonElement;377+ sendButton.click();378+379+ await vi.waitFor(() => {380+ expect(container.textContent).toContain("Thank you");381+ });382+383+ const requestBody = JSON.parse(String((fetchMock.mock.calls[0][1] as RequestInit).body));384+ expect(requestBody.subject).toContain("Feature request");385+ expect(requestBody.message).toBe(386+ "<script>alert("xss")</script> Please make this easier to see & safer."387+ );388+ expect(requestBody.message).not.toContain("<script>");389+ expect(container.textContent).toContain("Someone will process it soon");390+ expect(container.querySelector(".peach-feedback-textarea")).toBeNull();391+ expect(container.querySelector(".peach-feedback-submit")).toBeNull();392+ });393+316394 it("Settings sends CHANGE_PASSWORD and forwards auto-lock/theme changes through callbacks", async () => {317395 sendMessageMock.mockImplementation(async (msg: unknown) => {318396 const m = msg as { type: string };