Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ the public changelog at [castora.social/changelog](https://castora.social/change
> `src/app/changelog/entries.ts` (the website source of truth, newest first) and
> mirror it here. Keep wording user-friendly. See `AGENTS.md`.

## 2026-06-22 — Multiple accounts are back
- Connect more than one Farcaster account and switch between them from the profile menu — browse, post, and get notifications as any of your accounts.
- The "Add account" button works again (sign in with Neynar): a new account is attached alongside your existing ones instead of replacing them.
- You can also share access to an account with another Castora user from Settings — handy for teams managing accounts together.

## 2026-06-22 — Sign-in reliability
- Fixed a rare issue where a brief backend hiccup could empty your timeline or bounce you to the connect-account screen even while you were still signed in. The app now treats a momentary outage as something to retry, instead of mistaking it for a sign-out.
- If a hiccup does happen, the app now retries automatically and shows a simple “try again” screen instead of leaving you on a blank or stuck page.
Expand Down
9 changes: 9 additions & 0 deletions src/app/add-account/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import dynamic from "next/dynamic"

const AddAccountForm = dynamic(() => import("@/components/auth/AddAccountForm"), {
ssr: false,
})

export default function AddAccount() {
return <AddAccountForm />
}
111 changes: 111 additions & 0 deletions src/app/api/account/add-account/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { isAuthenticated } from "@/utils/auth/isAuthenticated";
import { prisma } from "@/prisma/client";

// Attach an ADDITIONAL owned Farcaster account to the signed-in user, after they
// prove control of it via Sign in with Neynar. Unlike /api/account/siwn (which is
// the onboarding/first-account path and reassigns the user's primary fid), this is
// purely additive: it upserts the SupercastFarcasterAccount and creates a
// ConnectedAccount for the current user, and NEVER touches supercastPrivyUser.fid.

const parseFid = (fid: unknown): number | null => {
const value = typeof fid === "string" ? Number.parseInt(fid, 10) : fid;
return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : null;
};

const parseSignerUUID = (signerUUID: unknown): string | null => {
return typeof signerUUID === "string" && signerUUID.trim().length > 0
? signerUUID.trim()
: null;
};

export async function POST(req: Request) {
const { authenticated, supercastUser } = await isAuthenticated(req);

if (!authenticated || !supercastUser) {
return Response.json({ error: "Not authenticated" }, { status: 401 });
}

// Only an onboarded user can attach a sibling account. Guests must finish
// onboarding (which establishes their primary account) first.
if (supercastUser.fid === 0) {
return Response.json(
{ error: "NOT_ONBOARDED", message: "Finish setting up your first account before adding more." },
{ status: 400 },
);
}

const body = await req.json().catch(() => null);
const fid = parseFid(body?.fid ?? body?.user?.fid);
const signerUUID = parseSignerUUID(body?.signer_uuid ?? body?.signerUUID);

if (!fid || !signerUUID) {
return Response.json({ error: "Missing fid or signer_uuid from Neynar sign-in" }, { status: 400 });
}

// Don't let a user attach a Farcaster account that another Castora user already
// controls — as their primary, as a connected (owned) account, or as one they've
// shared with others. Attaching it here would overwrite that account's signer and
// let one user co-opt another's account. Team access to someone else's account is
// the "share access" feature instead, not owned-add. (So the signer upsert below
// can only ever touch THIS user's own account.)
const [ownedByOtherPrimary, existingAccount] = await Promise.all([
prisma.supercastPrivyUser.findFirst({
where: { fid, id: { not: supercastUser.id } },
select: { id: true },
}),
prisma.supercastFarcasterAccount.findUnique({
where: { fid },
include: {
ConnectedAccount: {
where: { supercastPrivyUserId: { not: supercastUser.id } },
select: { id: true },
take: 1,
},
SharedAccount: {
where: { sharedById: { not: supercastUser.id } },
select: { id: true },
take: 1,
},
},
}),
]);

const claimedByOther =
!!ownedByOtherPrimary ||
(existingAccount?.ConnectedAccount.length ?? 0) > 0 ||
(existingAccount?.SharedAccount.length ?? 0) > 0;

if (claimedByOther) {
return Response.json(
{
error: "ACCOUNT_OWNED_BY_OTHER_USER",
message: "This Farcaster account is already registered to another Castora user.",
},
{ status: 409 },
);
}

await prisma.$transaction(async (tx) => {
const farcasterAccount = await tx.supercastFarcasterAccount.upsert({
where: { fid },
update: { signerUUID },
create: { fid, signerUUID },
});

await tx.connectedAccount.upsert({
where: {
supercastFarcasterAccountId_supercastPrivyUserId: {
supercastFarcasterAccountId: farcasterAccount.id,
supercastPrivyUserId: supercastUser.id,
},
},
update: {},
create: {
supercastFarcasterAccountId: farcasterAccount.id,
supercastPrivyUserId: supercastUser.id,
},
});
});

return Response.json({ success: true, fid });
}
10 changes: 10 additions & 0 deletions src/app/changelog/entries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,16 @@ export type ChangelogEntry = {
}

export const changelog: ChangelogEntry[] = [
{
date: '2026-06-22',
title: 'Multiple accounts are back',
tag: 'feature',
items: [
'Connect more than one Farcaster account and switch between them from the profile menu — browse, post, and get notifications as any of your accounts.',
'The “Add account” button works again (sign in with Neynar): a new account is attached alongside your existing ones instead of replacing them.',
'You can also share access to an account with another Castora user from Settings — handy for teams managing accounts together.',
],
},
{
date: '2026-06-22',
title: 'Sign-in reliability',
Expand Down
37 changes: 6 additions & 31 deletions src/components/ProfileBar.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Fragment, useEffect, useState } from "react"
import { Fragment } from "react"
import { Menu, Transition } from '@headlessui/react'
import Link from "next/link"
import axios from "axios"
import { useRouter } from "next/navigation"

import { classNames } from "@/utils/classNames"
import { PlusIcon, StarIcon, UserPlusIcon } from "@heroicons/react/24/solid"
Expand All @@ -13,9 +13,6 @@ import { truncateLongWord } from "@/utils/textUtils"
import { useSupercastUserState } from "@/providers/SupercastUserStateProvider"
import { useLogin, usePrivy } from "@privy-io/react-auth"
import { UserCircleIcon } from "@heroicons/react/24/solid"
import { HOST_URL } from "@/utils/hostURL"
import { AUTH_URL } from "@/utils/authURL"
import Spinner from "./Spinner"
import { useDisconnect } from 'wagmi'
import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar"
import { Skeleton } from "./ui/skeleton"
Expand All @@ -28,34 +25,16 @@ import { useSuperLogin } from "@/hooks/useSuperLogin"
export default function ProfileBar() {

const { supercastUserState, getCurrentProfile, switchAccount, isAuthenticated, isGuest } = useSupercastUserState()
const { getAccessToken } = usePrivy()
const [loadingConnectSession, setLoadingConnectSession] = useState<boolean>(false)
const { isSupercastMember } = useSupercastMember();
const router = useRouter()

const currentAccount = getCurrentProfile()

const { disconnect } = useDisconnect()
const { login } = useSuperLogin()

const handleAddAccount = async () => {

alert('Temporarily unavailable, coming back soon!')
return

setLoadingConnectSession(true)
const accessToken = await getAccessToken()

axios.post(`${HOST_URL}/api/account/create-connection`, {}, {
headers: {
'Authorization': `Bearer ${accessToken}`,
'asFid': supercastUserState.userFid
}
}).then((response) => {
const sessionId = response.data.connectionSession
window.location.href = `${AUTH_URL}?sessionId=${sessionId}`
}).finally(() => {
setLoadingConnectSession(false)
})
const handleAddAccount = () => {
router.push('/add-account')
}

const handleSwitchAccount = (fid: number) => {
Expand Down Expand Up @@ -133,11 +112,7 @@ export default function ProfileBar() {
'px-4 py-2 text-sm w-full text-left flex flex-row items-center font-semibold'
)}
>
{loadingConnectSession ?
<Spinner height="h-5" width="w-5" padding="p-0" />
:
<UserPlusIcon className="w-5 h-5 mr-2" />
}
<UserPlusIcon className="w-5 h-5 mr-2" />
{"Add account"}
</button>
)}
Expand Down
169 changes: 169 additions & 0 deletions src/components/auth/AddAccountForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
'use client'

import axios from "axios";
import { useEffect, useState } from "react";
import { usePrivy } from "@privy-io/react-auth";
import { toast } from "sonner";
import { useQueryClient } from "react-query";
import { Button } from "../ui/button";
import { useRouter } from "next/navigation";
import { useSupercastUserState } from "@/providers/SupercastUserStateProvider";

type NeynarSignInPayload = {
fid?: number | string;
signer_uuid?: string;
signerUUID?: string;
user?: {
fid?: number | string;
};
};

declare global {
interface Window {
onCastoraAddAccountSignIn?: (data: NeynarSignInPayload) => void;
}
}

// Mirrors ConnectAccountForm (the onboarding first-account flow), but posts to the
// additive /api/account/add-account endpoint so it ATTACHES a sibling account
// instead of reassigning the user's primary, then switches into the new account.
export default function AddAccountForm() {
const { getAccessToken } = usePrivy();
const queryClient = useQueryClient();
const router = useRouter();
const { switchAccount, isAuthenticated, isRegularUser, isReconnecting, hasLoadError } = useSupercastUserState();

const [clientId, setClientId] = useState("");
const [loadingClientId, setLoadingClientId] = useState(true);
const [connecting, setConnecting] = useState(false);

// Only an onboarded (regular) user can add a sibling account. Send guests to
// onboarding — but only once user/state has DEFINITIVELY loaded, so we don't
// bounce a legit user during the brief loading/reconnecting window.
useEffect(() => {
if (isAuthenticated() && !isReconnecting() && !hasLoadError() && !isRegularUser()) {
router.replace("/onboarding");
}
}, [isAuthenticated, isReconnecting, hasLoadError, isRegularUser, router]);

useEffect(() => {
let cancelled = false;

const fetchClientId = async () => {
try {
const response = await axios.get("/api/account/siwn");
if (!cancelled) {
setClientId(response.data.clientId);
}
} catch (error) {
console.error(error);
toast.error("Neynar sign-in is not configured yet");
} finally {
if (!cancelled) {
setLoadingClientId(false);
}
}
};

fetchClientId();

return () => {
cancelled = true;
};
}, []);

useEffect(() => {
if (!clientId) return;

window.onCastoraAddAccountSignIn = async (data: NeynarSignInPayload) => {
const fid = data?.fid ?? data?.user?.fid;
const signerUUID = data?.signer_uuid ?? data?.signerUUID;

if (!fid || !signerUUID) {
toast.error("Neynar did not return a Farcaster signer");
return;
}

setConnecting(true);

try {
const accessToken = await getAccessToken();
await axios.post(
"/api/account/add-account",
{ fid, signer_uuid: signerUUID },
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
);

// Refresh the accounts list FIRST so the new account exists before we
// switch into it (avoids getCurrentProfile() returning null mid-switch).
await queryClient.invalidateQueries("supercastUserState");
switchAccount(Number(fid));
toast.success("Account added");
router.push("/");
router.refresh();
} catch (error: any) {
console.error(error);
if (error?.response?.data?.error === "ACCOUNT_OWNED_BY_OTHER_USER") {
toast.error("That account is already registered to another Castora user.");
} else {
toast.error("Could not add your Farcaster account");
}
} finally {
setConnecting(false);
}
};

const existingScript = document.querySelector<HTMLScriptElement>(
'script[src="https://neynarxyz.github.io/siwn/raw/1.2.0/index.js"]',
);

if (!existingScript) {
const script = document.createElement("script");
script.src = "https://neynarxyz.github.io/siwn/raw/1.2.0/index.js";
script.async = true;
document.body.appendChild(script);
}

return () => {
delete window.onCastoraAddAccountSignIn;
};
}, [clientId, getAccessToken, queryClient, router, switchAccount]);

return (
<div className="flex flex-col items-center justify-center pt-20">
<h1 className="font-semibold text-2xl tracking-tight mb-2 text-center max-w-xs">Add a Farcaster account</h1>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-6 max-w-xs text-center leading">
Sign in with Neynar to connect another Farcaster account. You&apos;ll be able to switch between your accounts and post from any of them.
</p>
<div className="w-full max-w-md">
<div className="space-y-4 p-4 flex flex-col items-center">
{loadingClientId ? (
<p className="text-gray-400 text-xs">loading Farcaster sign-in...</p>
) : clientId ? (
<div
className="neynar_signin"
data-client_id={clientId}
data-success-callback="onCastoraAddAccountSignIn"
data-theme="dark"
/>
) : (
<p className="text-red-500 text-sm text-center">Neynar sign-in is unavailable.</p>
)}
{connecting && <p className="text-gray-400 text-xs">adding your account...</p>}
</div>
<Button
onClick={() => router.push("/")}
variant="ghost"
className="w-full mt-6"
disabled={connecting}
>
Go back
</Button>
</div>
</div>
);
}
Loading
Loading