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
↓ peach-browser-2f850bd1.tar.gzatlas at this save
files changed1 file
src/content/inject.ts+160 −0src/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");