From 1014f3e1767f21a4ab00b3dfd2990d5f455a7d81 Mon Sep 17 00:00:00 2001 From: arcabotai <261107832+arcabotai@users.noreply.github.com> Date: Mon, 22 Jun 2026 07:42:11 -0400 Subject: [PATCH] feat(accounts): re-enable adding & switching multiple Farcaster accounts Supercast's multi-account control was ~80% intact in the fork (data model, isAuthorized/asFid, the account switcher, sharing/delegation) but the "Add account" button was a hard stub (alert) wired to the retired super-auth redirect, and the SIWN handler reassigned the primary fid instead of attaching a sibling. - New POST /api/account/add-account: additive Sign-in-with-Neynar handler that attaches an additional owned account (upsert FarcasterAccount + create ConnectedAccount) and NEVER touches supercastPrivyUser.fid. Blocks attaching an account another Castora user already owns/shares (409), so the signer upsert only ever touches the user's own account. - New /add-account page + AddAccountForm (mirrors the proven onboarding SIWN pattern; invalidates user state, then switches into the new account). - Re-point the ProfileBar + MobileSidebar "Add account" buttons at it and remove the dead super-auth create-connection / AUTH_URL redirect code. Switching, acting-as (feed/notifications/cast via asFid), and sharing (delegate-access) already worked and are unchanged. Verified: typecheck + build green; adversarial review of data-mutation safety + end-to-end flow (collision guard tightened, redirect-during-load and switch-before-refetch fixed). --- CHANGELOG.md | 5 + src/app/add-account/page.tsx | 9 ++ src/app/api/account/add-account/route.ts | 111 +++++++++++++ src/app/changelog/entries.ts | 10 ++ src/components/ProfileBar.tsx | 37 +---- src/components/auth/AddAccountForm.tsx | 169 ++++++++++++++++++++ src/components/navigation/MobileSidebar.tsx | 29 +--- 7 files changed, 314 insertions(+), 56 deletions(-) create mode 100644 src/app/add-account/page.tsx create mode 100644 src/app/api/account/add-account/route.ts create mode 100644 src/components/auth/AddAccountForm.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 113fea0..4d45dd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/app/add-account/page.tsx b/src/app/add-account/page.tsx new file mode 100644 index 0000000..1ec68d7 --- /dev/null +++ b/src/app/add-account/page.tsx @@ -0,0 +1,9 @@ +import dynamic from "next/dynamic" + +const AddAccountForm = dynamic(() => import("@/components/auth/AddAccountForm"), { + ssr: false, +}) + +export default function AddAccount() { + return +} diff --git a/src/app/api/account/add-account/route.ts b/src/app/api/account/add-account/route.ts new file mode 100644 index 0000000..ee2b8db --- /dev/null +++ b/src/app/api/account/add-account/route.ts @@ -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 }); +} diff --git a/src/app/changelog/entries.ts b/src/app/changelog/entries.ts index 096dad5..5c6ee31 100644 --- a/src/app/changelog/entries.ts +++ b/src/app/changelog/entries.ts @@ -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', diff --git a/src/components/ProfileBar.tsx b/src/components/ProfileBar.tsx index e8bfaa6..2641db3 100644 --- a/src/components/ProfileBar.tsx +++ b/src/components/ProfileBar.tsx @@ -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" @@ -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" @@ -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(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) => { @@ -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 ? - - : - - } + {"Add account"} )} diff --git a/src/components/auth/AddAccountForm.tsx b/src/components/auth/AddAccountForm.tsx new file mode 100644 index 0000000..0841abc --- /dev/null +++ b/src/components/auth/AddAccountForm.tsx @@ -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( + '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 ( +
+

Add a Farcaster account

+

+ Sign in with Neynar to connect another Farcaster account. You'll be able to switch between your accounts and post from any of them. +

+
+
+ {loadingClientId ? ( +

loading Farcaster sign-in...

+ ) : clientId ? ( +
+ ) : ( +

Neynar sign-in is unavailable.

+ )} + {connecting &&

adding your account...

} +
+ +
+
+ ); +} diff --git a/src/components/navigation/MobileSidebar.tsx b/src/components/navigation/MobileSidebar.tsx index efa1046..d5cdb24 100644 --- a/src/components/navigation/MobileSidebar.tsx +++ b/src/components/navigation/MobileSidebar.tsx @@ -29,10 +29,7 @@ import { Button } from '../ui/button' import { Drawer, DrawerClose, DrawerContent, DrawerFooter, DrawerHeader, DrawerTitle, DrawerTrigger } from '../ui/drawer' import { ScrollArea } from '../ui/scroll-area' import { useIosPwa } from "@/providers/iOSPwaProvider" -import axios from 'axios' -import { HOST_URL } from "@/utils/hostURL" -import { AUTH_URL } from "@/utils/authURL" -import { usePrivy } from "@privy-io/react-auth" +import { useRouter } from 'next/navigation' import { useCommunityDot } from '@/hooks/useCommunityDot' import { Dog } from 'lucide-react' import { toast } from 'sonner' @@ -47,11 +44,10 @@ export default function MobileSidebar({ const { supercastUserState, switchAccount, getCurrentProfile, isRegularUser, isGuest } = useSupercastUserState(); const { isSupercastMember } = useSupercastMember() const { isIosPwa } = useIosPwa(); - const { getAccessToken } = usePrivy() + const router = useRouter() const currentProfile = getCurrentProfile(); const [openDrawer, setOpenDrawer] = useState(false); - const [loadingConnectSession, setLoadingConnectSession] = useState(false); const { showDot, handleCommunityClick } = useCommunityDot(); @@ -69,27 +65,10 @@ export default function MobileSidebar({ setOpenSidebar(false); }; - const handleAddAccount = async () => { - - alert('Temporarily unavailable, coming back soon!') - return - + const handleAddAccount = () => { setOpenDrawer(false); setOpenSidebar(false); - 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); - }); + router.push('/add-account'); }; return (