From 5322b8820c9368f71bd6aed718678387371137ab Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Tue, 28 Jul 2026 03:27:58 +0000 Subject: [PATCH] feat(leads): let the user answer a seed login's verification code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A gated directory interrupts a sign-in with "enter the six-digit code we just sent you", and that was reported as unanswerable. It is not: the server cannot answer it, and should not be able to, because the point of the code is that it reaches the account's owner. But the owner is right there. So the browser session is held open on the challenge page, the prompt is surfaced in the Seed logins panel, and the code the user types is entered into that same live form. The sign-in then carries on. This is the pattern lib/sp/verificationChallenge.ts already uses for browser-automated social posts, and its detector and handler are generic enough to reuse unchanged — only the waiter needed rebinding from sp_post to the credential row. The code is never a stored secret. It is written to a column, read once by the waiter, and cleared in the same breath so it cannot be replayed. A timeout hands the browser slot back rather than pinning it, and clears the prompt so the UI stops claiming something is waiting. A challenge that is not a code — a device approval, a captcha — still reports as what it is, now distinguished from "nobody was there to answer". Co-Authored-By: Claude Opus 5 (1M context) --- app/actions/seedCredentials.ts | 33 ++++++++++ components/leads/seed-logins.tsx | 51 ++++++++++++++ lib/outreach/discover.ts | 21 ++++-- lib/outreach/render.ts | 5 +- lib/outreach/seedCredentials.ts | 66 +++++++++++++++++-- lib/outreach/seedLogin.ts | 49 ++++++++++++-- ...728040000_seed_credential_verification.sql | 26 ++++++++ 7 files changed, 236 insertions(+), 15 deletions(-) create mode 100644 supabase/migrations/20260728040000_seed_credential_verification.sql diff --git a/app/actions/seedCredentials.ts b/app/actions/seedCredentials.ts index e38b2dd3..8cbc8f56 100644 --- a/app/actions/seedCredentials.ts +++ b/app/actions/seedCredentials.ts @@ -99,6 +99,39 @@ export async function saveSeedCredentialAction(input: { }; } +/** + * Hand a verification code to a sign-in that is paused waiting for one. + * + * The browser session is still open on the other side of this, holding the + * challenge page; the runner polls this row and types whatever lands here + * into the live form. Nothing is stored — the waiter clears the column the + * moment it reads it. + */ +export async function submitSeedVerificationCodeAction(input: { + projectId: string; + host: string; + code: string; +}): Promise<{ ok: true } | Err> { + const access = await requireOrg(input.projectId); + if (!access.ok) return access; + + const code = input.code.trim(); + if (!code) return { ok: false, error: "Enter the code the site sent you." }; + if (!/^[A-Za-z0-9-]{4,12}$/.test(code)) { + return { ok: false, error: "That doesn\u2019t look like a verification code." }; + } + + const { error } = await serviceClient() + .from("outreach_seed_credentials") + .update({ verification_code: code }) + .eq("organization_id", access.organizationId) + .eq("host", normalizeHost(input.host)); + if (error) return { ok: false, error: error.message }; + + revalidatePath(`/projects/${input.projectId}/leads`); + return { ok: true }; +} + export async function deleteSeedCredentialAction(input: { projectId: string; host: string; diff --git a/components/leads/seed-logins.tsx b/components/leads/seed-logins.tsx index 21d207ce..34a07602 100644 --- a/components/leads/seed-logins.tsx +++ b/components/leads/seed-logins.tsx @@ -5,6 +5,7 @@ import { useRouter } from "next/navigation"; import { deleteSeedCredentialAction, saveSeedCredentialAction, + submitSeedVerificationCodeAction, } from "@/app/actions/seedCredentials"; import type { StoredSeedCredential } from "@/lib/outreach/seedCredentials"; @@ -34,6 +35,7 @@ export function SeedLogins({ const [password, setPassword] = useState(""); const [note, setNote] = useState(null); const [error, setError] = useState(null); + const [codes, setCodes] = useState>({}); const save = (targetHost?: string) => start(async () => { @@ -57,6 +59,24 @@ export function SeedLogins({ router.refresh(); }); + const sendCode = (h: string) => + start(async () => { + setError(null); + setNote(null); + const res = await submitSeedVerificationCodeAction({ + projectId, + host: h, + code: codes[h] ?? "", + }); + if (!res.ok) { + setError(res.error); + return; + } + setCodes((prev) => ({ ...prev, [h]: "" })); + setNote("Code sent to the waiting sign-in."); + router.refresh(); + }); + const remove = (h: string) => start(async () => { setError(null); @@ -187,6 +207,37 @@ export function SeedLogins({

{c.lastError}

)} + {c.verificationPrompt && ( +
+

+ Waiting for a verification code. {c.verificationPrompt} +

+
+ + setCodes((prev) => ({ ...prev, [c.host]: e.target.value })) + } + /> + +
+

+ The sign-in is held open while you fetch it. It gives up after a few + minutes so the browser is not pinned. +

+
+ )} +