diff --git a/app/(app)/projects/[id]/social/setup/page.tsx b/app/(app)/projects/[id]/social/setup/page.tsx
index 59ec7b06..3c8f8c14 100644
--- a/app/(app)/projects/[id]/social/setup/page.tsx
+++ b/app/(app)/projects/[id]/social/setup/page.tsx
@@ -12,6 +12,17 @@ import { AppPasswordReveal } from "./app-password-reveal";
export const metadata = { title: "Social · Connect accounts" };
+// Where to send the user to log in when a cookie session has expired, so they
+// can re-export fresh cookies. Mastodon is instance-specific, so omitted.
+const PLATFORM_LOGIN_URLS: Record = {
+ reddit: "https://www.reddit.com/login",
+ facebook_page: "https://www.facebook.com/login",
+ threads: "https://www.threads.net/login",
+ instagram: "https://www.instagram.com/accounts/login/",
+ x: "https://x.com/login",
+ linkedin: "https://www.linkedin.com/login",
+};
+
type SetupSearchParams = Promise<{
connected?: string;
error?: string;
@@ -131,6 +142,25 @@ export default async function SocialSetupPage({
Last post {new Date(a.last_post_at).toLocaleString()}
)}
+ {a.status !== "active" && a.platform !== "bluesky" && (
+
+ ⚠ Session expired.{" "}
+ {PLATFORM_LOGIN_URLS[a.platform] && (
+ <>
+
+ Log in to {a.platform} ↗
+
+ , then re-export cookies with Cookie-Editor and paste
+ them in the {a.platform} section below to reconnect.
+ >
+ )}
+
+ )}
{a.platform === "bluesky" && a.enc_app_password && (
)}
diff --git a/app/(marketing)/recent/page.tsx b/app/(marketing)/recent/page.tsx
index 1b81db5b..3e9b0790 100644
--- a/app/(marketing)/recent/page.tsx
+++ b/app/(marketing)/recent/page.tsx
@@ -363,5 +363,20 @@ async function fetchOutreachHistory(
list.push(item);
byAudit.set(row.audit_id, list);
}
+
+ // Collapse to one row per channel+provider (the latest attempt), so repeated
+ // sends/retries update in place instead of piling up. Rows are already
+ // ordered newest-first, so the first occurrence per key is the current one.
+ for (const [auditId, list] of byAudit) {
+ const seen = new Set();
+ const deduped: OutreachHistoryItem[] = [];
+ for (const item of list) {
+ const key = `${item.channel}:${item.provider}`;
+ if (seen.has(key)) continue;
+ seen.add(key);
+ deduped.push(item);
+ }
+ byAudit.set(auditId, deduped);
+ }
return byAudit;
}
diff --git a/lib/sp/browserPost.ts b/lib/sp/browserPost.ts
index 5c45bc50..2e3a1ca2 100644
--- a/lib/sp/browserPost.ts
+++ b/lib/sp/browserPost.ts
@@ -13,6 +13,7 @@ import type { SupabaseClient } from "@supabase/supabase-js";
import type OpenAI from "openai";
import { decryptSecret } from "@/lib/sp/vault";
import {
+ LOGIN_WALL_PREFIX,
parseCookies,
redditBrowserPost,
facebookBrowserPost,
@@ -198,9 +199,16 @@ async function fail(
.select("consecutive_failures")
.eq("id", accountId)
.maybeSingle();
+ // A login-wall failure means the stored cookies are dead — flag the account
+ // token_expired so the UI prompts a reconnect (re-export cookies) and stops
+ // posting to it until then.
+ const sessionExpired = message.startsWith(LOGIN_WALL_PREFIX);
await supabase
.from("sp_account")
- .update({ consecutive_failures: ((acct?.consecutive_failures ?? 0) as number) + 1 })
+ .update({
+ consecutive_failures: ((acct?.consecutive_failures ?? 0) as number) + 1,
+ ...(sessionExpired ? { status: "token_expired" } : {}),
+ })
.eq("id", accountId);
}
await supabase.from("sp_publish_attempt").insert({
diff --git a/lib/sp/platforms/browser.ts b/lib/sp/platforms/browser.ts
index c732b376..6b5c2ef0 100644
--- a/lib/sp/platforms/browser.ts
+++ b/lib/sp/platforms/browser.ts
@@ -14,7 +14,12 @@
// scraping flows. Add playwright-extra + stealth plugin if detection becomes
// a problem.
-import { chromium, type Browser, type BrowserContext } from "playwright";
+import {
+ chromium,
+ type Browser,
+ type BrowserContext,
+ type Page,
+} from "playwright";
import { browserSemaphore } from "@/lib/sp/browserSemaphore";
import { handleCodeChallenge, type CodeWaiter } from "@/lib/sp/verificationChallenge";
@@ -24,6 +29,31 @@ function codePrompt(platform: string): string {
return `${platform} is asking for a verification code. Enter the code it just sent (email / SMS / authenticator) to finish posting.`;
}
+// Prefix on errors thrown when the cookie session is dead and the platform is
+// showing a login wall. browserPost.ts recognizes it and flags the account as
+// token_expired so the user is prompted to reconnect (re-export cookies).
+export const LOGIN_WALL_PREFIX = "SESSION_EXPIRED";
+
+// After navigating with cookies (and clearing any code challenge), bail out
+// with a clear, recognizable error if we landed on a login page instead of the
+// app — otherwise the composer selectors just time out opaquely. Detects a
+// login URL or a visible password field.
+async function assertLoggedIn(page: Page, platform: string): Promise {
+ const url = page.url().toLowerCase();
+ const onLogin =
+ /\/login|\/signin|\/sign_in|\/uas\/login|accounts\/login|\/auth\/login/.test(url);
+ const passwordVisible = await page
+ .locator('input[type="password"]')
+ .first()
+ .isVisible({ timeout: 1500 })
+ .catch(() => false);
+ if (onLogin || passwordVisible) {
+ throw new Error(
+ `${LOGIN_WALL_PREFIX}: ${platform} session expired — reconnect the account with fresh cookies.`,
+ );
+ }
+}
+
export type BrowserCookie = {
name: string;
value: string;
@@ -133,6 +163,7 @@ export async function redditBrowserPost(args: {
waitUntil: "domcontentloaded",
});
if (waitForCode) await handleCodeChallenge(page, waitForCode, codePrompt("Reddit"));
+ await assertLoggedIn(page, "Reddit");
// Select "Text" tab
const textTab = page.getByRole("tab", { name: /text/i });
@@ -183,6 +214,7 @@ export async function facebookBrowserPost(args: {
waitUntil: "domcontentloaded",
});
if (waitForCode) await handleCodeChallenge(page, waitForCode, codePrompt("Facebook"));
+ await assertLoggedIn(page, "Facebook");
// Click the "Write something..." composer
const composer = page.getByPlaceholder(/write something/i)
@@ -246,6 +278,7 @@ export async function threadsBrowserPost(args: {
const page = await ctx.newPage();
await page.goto("https://www.threads.net", { waitUntil: "domcontentloaded" });
if (waitForCode) await handleCodeChallenge(page, waitForCode, codePrompt("Threads"));
+ await assertLoggedIn(page, "Threads");
// New Thread button
const newThreadBtn = page
@@ -308,6 +341,7 @@ export async function instagramBrowserPost(args: {
const page = await ctx.newPage();
await page.goto("https://www.instagram.com", { waitUntil: "domcontentloaded" });
if (waitForCode) await handleCodeChallenge(page, waitForCode, codePrompt("Instagram"));
+ await assertLoggedIn(page, "Instagram");
// Download image to temp file
const imgRes = await fetch(imageUrl);
@@ -377,6 +411,7 @@ export async function xBrowserPost(args: {
const page = await ctx.newPage();
await page.goto("https://x.com/home", { waitUntil: "domcontentloaded" });
if (waitForCode) await handleCodeChallenge(page, waitForCode, codePrompt("X"));
+ await assertLoggedIn(page, "X");
// Click the compose box
const compose = page
@@ -435,6 +470,7 @@ export async function linkedinBrowserPost(args: {
const page = await ctx.newPage();
await page.goto("https://www.linkedin.com/feed/", { waitUntil: "domcontentloaded" });
if (waitForCode) await handleCodeChallenge(page, waitForCode, codePrompt("LinkedIn"));
+ await assertLoggedIn(page, "LinkedIn");
// Start a post
const startPost = page
@@ -504,6 +540,7 @@ export async function mastodonBrowserPost(args: {
const page = await ctx.newPage();
await page.goto(base, { waitUntil: "domcontentloaded" });
if (waitForCode) await handleCodeChallenge(page, waitForCode, codePrompt("Mastodon"));
+ await assertLoggedIn(page, "Mastodon");
// Mastodon web app — compose textarea. Match the compose box specifically:
// a bare getByRole("textbox") also matched the "Search or paste URL" input
@@ -532,9 +569,12 @@ export async function mastodonBrowserPost(args: {
}
}
- // Toot / Publish button
+ // Toot / Publish button. Mastodon labels it "Publish!" / "Toot!"; also
+ // accept the compose form's submit button as a fallback.
await page
.getByRole("button", { name: /publish|toot/i })
+ .or(page.locator("button.compose-form__submit"))
+ .or(page.locator(".compose-form button[type='submit']"))
.last()
.click();