d99308b4fb2a

fix: Polish and harden feedback flow (v3.6.63)

- Make selected feedback category visually and semantically obvious - Replace the form with a thank-you state after successful submission - Escape outbound feedback text to prevent HTML/JavaScript execution downstream - Add contract coverage for category state, one-shot submit, and script escaping

time
2026-05-20 11:36 UTC · 5 months ago
machine
koh2
parent
893de0a2c330
8 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 −0
manifest.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": {

view file

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",

view file

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",

view file

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": {

view file

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 {

view file

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,

view file

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, "&amp;")84+    .replace(/</g, "&lt;")85+    .replace(/>/g, "&gt;")86+    .replace(/"/g, "&quot;")87+    .replace(/'/g, "&#39;");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     );

view file

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+      "&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt; Please make this easier to see &amp; 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 };

view file