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
↓ peach-browser-ab5c9a63.tar.gzatlas at this save
files changed6 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 −0src/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.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();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 });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 }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;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 }