2f850bd105b2

add TOTP tray to silver platter — ··· button expands tray with grouped TOTP code, accent-colored countdown ring, copy button; cleanupHooks ensures the 1s refresh interval is cleared on dismiss

time
2026-04-26 19:04 UTC · 6 months ago
machine
koh2
parent
e088bc468451
1 file src/content/inject.ts+160 −0
src/content/inject.ts+160 −0
@@ -11,6 +11,7 @@1111 1212 import { ACCENT_COLORS, type PasswordPolicy } from "../shared/types";1313 import { generateSmartPassword } from "../shared/generate";14+import { generateTOTP } from "../crypto/totp";1415 import { detectPasswordPolicy } from "./policy";1516 1617 let accentHex = "#E8956D";@@ -582,6 +583,51 @@582583     background: color-mix(in srgb, var(--peach-accent) 12%, transparent);583584   }584585 586+  /* TOTP tray — shown below the silver-platter secondary actions */587+  .sp-tray {588+    margin-top: 8px;589+    padding: 10px 12px;590+    border-radius: 9px;591+    background: var(--peach-input-bg);592+    border: 1px solid var(--peach-input-border);593+    display: flex; align-items: center; gap: 12px;594+    animation: peach-slide-in 180ms cubic-bezier(0.16,1,0.3,1) both;595+  }596+  .sp-tray-label {597+    font-size: 8px; letter-spacing: 0.12em; text-transform: uppercase;598+    color: var(--peach-text-dim); font-family: "IBM Plex Mono", monospace;599+    flex-shrink: 0;600+  }601+  .sp-totp-code {602+    font-size: 18px; font-weight: 500;603+    color: var(--peach-text);604+    font-family: "IBM Plex Mono", monospace;605+    letter-spacing: 0.12em;606+    flex: 1;607+    text-align: center;608+  }609+  .sp-totp-ring {610+    width: 22px; height: 22px; flex-shrink: 0;611+  }612+  .sp-totp-ring .ring-track { stroke: var(--peach-input-border); }613+  .sp-totp-ring .ring-fill {614+    stroke: var(--peach-accent);615+    transition: stroke-dashoffset 1s linear;616+    transform: rotate(-90deg);617+    transform-origin: center;618+  }619+  .sp-totp-copy {620+    background: transparent; border: none; cursor: pointer;621+    padding: 4px 8px; border-radius: 5px;622+    color: var(--peach-text-dim); font-size: 11px;623+    font-family: Inter, sans-serif;624+    transition: background 120ms, color 120ms;625+  }626+  .sp-totp-copy:hover {627+    background: color-mix(in srgb, var(--peach-text) 5%, transparent);628+    color: var(--peach-text);629+  }630+585631   /* Card silver platter (State 7) — reuses .sp-* primary action classes */586632   .card-platter {587633     padding: 12px 14px 10px;@@ -691,6 +737,9 @@691737   let focusedIndex = -1;692738   let currentMode: "list" | "create" | "success" | "silver-platter" = "list";693739   let searchInput: HTMLInputElement | null = null;740+  // Hooks any sub-state owns for cleanup at dismiss time. Today: TOTP tray741+  // setInterval. Add to this list — don't fan out the dismiss function.742+  const cleanupHooks: Array<() => void> = [];694743 695744   // Pre-capture the anchor field value for username prefill696745   const preUsername = (anchorEl.type !== "password") ? (anchorEl.value?.trim() ?? "") : "";@@ -1092,6 +1141,113 @@10921141     });10931142     actions.appendChild(copyP);10941143 1144+    let trayEl: HTMLDivElement | null = null;1145+    let trayInterval: ReturnType<typeof setInterval> | null = null;1146+1147+    function closeTray() {1148+      if (trayInterval) { clearInterval(trayInterval); trayInterval = null; }1149+      trayEl?.remove();1150+      trayEl = null;1151+    }1152+    // Register so dismiss() will close the tray and clear the interval1153+    // even if the user dismisses without explicitly closing the tray.1154+    cleanupHooks.push(closeTray);1155+1156+    async function openTOTPTray() {1157+      // Fetch the entry to get the TOTP secret. The selector summary doesn't1158+      // carry it (and shouldn't — passing secrets only at fill/copy time keeps1159+      // the message-passing surface minimal per Projects.md §5.2).1160+      const resp = await chrome.runtime.sendMessage({1161+        type: "GET_ENTRY_FOR_FILL",1162+        payload: { id: entry.id },1163+      }).catch(() => null) as { ok: boolean; data?: { totp?: string } } | null;1164+      const secret = resp?.ok ? resp.data?.totp : undefined;1165+      if (!secret) return;1166+1167+      trayEl = document.createElement("div");1168+      trayEl.className = "sp-tray";1169+1170+      const label = document.createElement("span");1171+      label.className = "sp-tray-label";1172+      label.textContent = "2FA";1173+      trayEl.appendChild(label);1174+1175+      const codeEl = document.createElement("div");1176+      codeEl.className = "sp-totp-code";1177+      trayEl.appendChild(codeEl);1178+1179+      const ringNs = "http://www.w3.org/2000/svg";1180+      const ring = document.createElementNS(ringNs, "svg");1181+      ring.setAttribute("class", "sp-totp-ring");1182+      ring.setAttribute("viewBox", "0 0 24 24");1183+      const trackC = document.createElementNS(ringNs, "circle");1184+      trackC.setAttribute("class", "ring-track");1185+      trackC.setAttribute("cx", "12"); trackC.setAttribute("cy", "12"); trackC.setAttribute("r", "10");1186+      trackC.setAttribute("fill", "none"); trackC.setAttribute("stroke-width", "2");1187+      const fillC = document.createElementNS(ringNs, "circle");1188+      fillC.setAttribute("class", "ring-fill");1189+      fillC.setAttribute("cx", "12"); fillC.setAttribute("cy", "12"); fillC.setAttribute("r", "10");1190+      fillC.setAttribute("fill", "none"); fillC.setAttribute("stroke-width", "2");1191+      fillC.setAttribute("stroke-linecap", "round");1192+      fillC.setAttribute("stroke-dasharray", "62.83");1193+      ring.append(trackC, fillC);1194+      trayEl.appendChild(ring);1195+1196+      const copyBtn = document.createElement("button");1197+      copyBtn.className = "sp-totp-copy";1198+      copyBtn.type = "button";1199+      copyBtn.textContent = "Copy";1200+      trayEl.appendChild(copyBtn);1201+1202+      function refresh() {1203+        const { code, remaining } = generateTOTP(secret!);1204+        // Group as "847 291" — easier to read than "847291"1205+        codeEl.textContent = code.length === 61206+          ? `${code.slice(0, 3)} ${code.slice(3)}`1207+          : code;1208+        // Ring fills counter-clockwise as time elapses: dashoffset goes from1209+        // 0 (full ring) to 62.83 (empty) over the 30s window.1210+        const fraction = (30 - remaining) / 30;1211+        fillC.setAttribute("stroke-dashoffset", String(62.83 * fraction));1212+      }1213+      refresh();1214+      trayInterval = setInterval(refresh, 1000);1215+1216+      copyBtn.addEventListener("click", async (e) => {1217+        e.stopPropagation();1218+        const { code } = generateTOTP(secret!);1219+        let alreadyWritten = false;1220+        try { await navigator.clipboard.writeText(code); alreadyWritten = true; } catch {}1221+        await chrome.runtime.sendMessage({1222+          type: "COPY",1223+          payload: { text: code, label: "TOTP", alreadyWritten },1224+        }).catch(() => {});1225+        copyBtn.textContent = "Copied";1226+        setTimeout(() => { if (copyBtn.isConnected) copyBtn.textContent = "Copy"; }, 1200);1227+      });1228+1229+      platter.appendChild(trayEl);1230+    }1231+1232+    if (entry.hasTotp) {1233+      const moreBtn = document.createElement("button");1234+      moreBtn.className = "sp-action-btn";1235+      moreBtn.type = "button";1236+      moreBtn.title = "Show 2FA code";1237+      moreBtn.textContent = "···";1238+      moreBtn.addEventListener("click", (e) => {1239+        e.stopPropagation();1240+        if (trayEl) {1241+          closeTray();1242+          moreBtn.classList.remove("flashed");1243+        } else {1244+          moreBtn.classList.add("flashed");1245+          void openTOTPTray();1246+        }1247+      });1248+      actions.appendChild(moreBtn);1249+    }1250+10951251     const closeBtn = document.createElement("button");10961252     closeBtn.className = "sp-action-btn";10971253     closeBtn.type = "button";@@ -1099,6 +1255,7 @@10991255     closeBtn.textContent = "✕";11001256     closeBtn.addEventListener("click", (e) => {11011257       e.stopPropagation();1258+      closeTray();11021259       dismiss(true);11031260     });11041261     actions.appendChild(closeBtn);@@ -1308,6 +1465,9 @@13081465     if (captureKeydownRef) {13091466       document.removeEventListener("keydown", captureKeydownRef, true);13101467       captureKeydownRef = null;1468+    }1469+    for (const cleanup of cleanupHooks) {1470+      try { cleanup(); } catch { /* defensive — one hook must not block others */ }13111471     }13121472     if (userAction) options.onDismiss?.();13131473     container.classList.add("is-exiting");

view file