ab5c9a638d4b

feat: TOTP auto-copy toast — showTotpCopyToast(secondsUntilClear) in inject.ts shows '2FA code copied / clears in Ns' top-right, auto-dismisses 3s, resets timer on re-copy; wired into index.ts auto-copy path. Thread real clear time: export getEffectiveClearSeconds() from clipboard.ts (DRY with timer), COPY returns clearSeconds, add MessageResponse.clearSeconds. Activates T-M11/12/13 (v3.9.9)

time
2026-06-12 14:00 UTC · 4 months ago
machine
koh2
agent
claude-opus-4-8
session
autofill tests integration
parent
0152b3f58edf
6 files src/background/clipboard.ts+9 −5src/background/messages.ts+2 −2src/content/content.totp-toast.addendum.test.ts+5 −4src/content/index.ts+8 −5src/content/inject.ts+63 −0src/shared/types.ts+3 −0
src/background/clipboard.ts+9 −5
@@ -28,6 +28,14 @@2828 // alarm remains as the eviction-proof backstop.2929 const MIN_ALARM_MINUTES = 0.5;3030 31+// The auto-clear delay in seconds: the user's setting when it is a positive32+// number, otherwise the 30s default. Single source for both the clear timer33+// and the COPY response that tells the content script how long to advertise.34+export function getEffectiveClearSeconds(): number {35+  const s = getSettings().clipboardClearSeconds;36+  return typeof s === "number" && s > 0 ? s : DEFAULT_CLEAR_SECONDS;37+}38+3139 let clipboardClearTimer: ReturnType<typeof setTimeout> | null = null;3240 3341 async function writeToClipboard(text: string): Promise<void> {@@ -83,11 +91,7 @@8391     await writeToClipboard(text).catch(() => {});8492   }8593 86-  const settingsSeconds = getSettings().clipboardClearSeconds;87-  const clearAfterMs =88-    (typeof settingsSeconds === "number" && settingsSeconds > 089-      ? settingsSeconds90-      : DEFAULT_CLEAR_SECONDS) * 1000;94+  const clearAfterMs = getEffectiveClearSeconds() * 1000;9195 9296   // Park a hash (never plaintext) so a cold-started worker knows a clear is9397   // owed. storage.session is memory-backed and cleared on browser exit.

view file

src/background/messages.ts+2 −2
@@ -9,7 +9,7 @@99  * intentionally have no case here — they are sent *to* content scripts.1010  */1111 12-import { copyToClipboard } from "./clipboard";12+import { copyToClipboard, getEffectiveClearSeconds } from "./clipboard";1313 import { getPendingSyncAlerts, clearPendingSyncAlerts } from "./sync-alerts";1414 import { extractDomain } from "../shared/domain";1515 import {@@ -820,7 +820,7 @@820820           return { ok: false, error: "invalid_payload" };821821         }822822         await copyToClipboard(text, label, Boolean(alreadyWritten));823-        return { ok: true };823+        return { ok: true, clearSeconds: getEffectiveClearSeconds() };824824       }825825       case "CHECK_ALL_BREACHES": {826826         const allEntries = vault.getEntries();

view file

src/content/content.totp-toast.addendum.test.ts+5 −4
@@ -50,10 +50,11 @@5050   }5151 );5252 53-// Guard so the file always has at least one executing assertion even before the54-// feature exists — documents the current state explicitly.53+// Addendum 2 is now implemented: the export exists and the T-M11–T-M13 spec54+// above runs. This pins that the feature shipped (and that the skipIf block is55+// no longer being silently skipped).5556 describe("Addendum 2 status", () => {56-  it("documents that showTotpCopyToast is not yet implemented", () => {57-    expect(typeof showTotpCopyToast).toBe("undefined");57+  it("exposes showTotpCopyToast now that the addendum is implemented", () => {58+    expect(typeof showTotpCopyToast).toBe("function");5859   });5960 });

view file

src/content/index.ts+8 −5
@@ -21,6 +21,7 @@2121   showPasskeyProGate,2222   showPasskeyRegistrationOffer,2323   showRememberSiteToast,24+  showTotpCopyToast,2425   showTOTPSelector,2526 } from "./inject";2627 import { detectBrowser } from "../shared/browser";@@ -367,13 +368,15 @@367368       type: "COPY",368369       payload: { text: code, label: "TOTP", alreadyWritten },369370     });371+    const res = response as { ok?: boolean; clearSeconds?: number };370372     if (filledTOTPField) {371373       showToast("2FA code filled");372-    } else if (alreadyWritten || (response as { ok?: boolean }).ok) {373-      // Don't render the live code into the light-DOM toast — the page can read374-      // it from the DOM and consume the one-time code (audit LOW). The code is375-      // already on the clipboard; just confirm.376-      showToast("2FA code copied");374+    } else if (alreadyWritten || res.ok) {375+      // Don't render the live code into the toast — the page can read it from the376+      // DOM and consume the one-time code (audit LOW). The code is already on the377+      // clipboard; the toast confirms the copy and shows how long until the378+      // clipboard self-clears (Addendum 2). 30 mirrors DEFAULT_CLEAR_SECONDS.379+      showTotpCopyToast(res.clearSeconds ?? 30);377380     } else {378381       showToast("Could not copy TOTP — clipboard blocked");379382     }

view file

src/content/inject.ts+63 −0
@@ -221,6 +221,69 @@221221   setTimeout(() => host.remove(), 9000);222222 }223223 224+// Module handle so a second auto-copy resets the dismiss timer rather than225+// letting the first toast's teardown race the replacement.226+let totpToastDismissTimer: ReturnType<typeof setTimeout> | null = null;227+228+/**229+ * Shows a brief top-right toast when Peach automatically copies a TOTP code,230+ * telling the user how long until the clipboard self-clears. Auto-dismisses231+ * after 3s; a second call while visible replaces the toast and resets the timer.232+ * Rendered in the light DOM (not a closed shadow) so the copy is announced to233+ * assistive tech via aria-live — the code itself is never placed in the DOM.234+ */235+export function showTotpCopyToast(secondsUntilClear: number): void {236+  document.getElementById("peach-totp-toast")?.remove();237+  if (totpToastDismissTimer) {238+    clearTimeout(totpToastDismissTimer);239+    totpToastDismissTimer = null;240+  }241+242+  const toast = document.createElement("div");243+  toast.id = "peach-totp-toast";244+  toast.className = "peach-totp-toast";245+  toast.setAttribute("data-peach-toast", "totp-copy");246+  toast.setAttribute("aria-live", "polite");247+248+  // Inline styles — matches the passkey toast family (warm, minimal, top right).249+  Object.assign(toast.style, {250+    position: "fixed",251+    top: "16px",252+    right: "16px",253+    zIndex: "2147483647",254+    padding: "10px 14px",255+    borderRadius: "8px",256+    background: "rgba(30, 20, 10, 0.92)",257+    border: "1px solid rgba(210, 140, 60, 0.3)",258+    color: "#e8d5b0",259+    fontFamily: '"IBM Plex Mono", monospace',260+    fontSize: "11px",261+    lineHeight: "1.5",262+    boxShadow: "0 4px 24px rgba(0,0,0,0.4)",263+    pointerEvents: "none",264+  });265+266+  // secondsUntilClear is a typed number — safe to interpolate; no page-derived267+  // string ever reaches this markup.268+  toast.innerHTML = `269+    <div style="font-style: italic; font-family: 'Cormorant Garamond', serif; font-size: 13px; margin-bottom: 2px;">270+      2FA code copied271+    </div>272+    <div style="opacity: 0.6; font-size: 10px;">273+      clears in ${secondsUntilClear}s274+    </div>275+  `;276+277+  document.body.appendChild(toast);278+279+  totpToastDismissTimer = setTimeout(() => {280+    totpToastDismissTimer = null;281+    toast.style.transition = "opacity 0.3s ease";282+    toast.style.opacity = "0";283+    setTimeout(() => toast.remove(), 300);284+  }, 3000);285+}286+224287 interface SmartGenerator {225288   root: HTMLElement;226289   getPassword: () => string;

view file

src/shared/types.ts+3 −0
@@ -540,4 +540,7 @@540540   // Set on UNLOCK responses when the vault decrypted but failed schema541541   // validation (M7/COR-007). The popup may offer a history-restore flow.542542   canRestoreFromHistory?: boolean;543+  // Set on COPY responses: the effective clipboard auto-clear delay in seconds,544+  // so the content script's TOTP toast can advertise the real configured time.545+  clearSeconds?: number;543546 }

view file