From e891f04017de7698def5447f4c700e0088c3d87b Mon Sep 17 00:00:00 2001 From: michaelcanova Date: Fri, 30 Oct 2026 12:55:36 -0400 Subject: [PATCH 1/4] Starting X Connect button --- app/api/auth/[...nextauth]/auth.config.ts | 15 +++++++---- app/auth/signin/page.tsx | 25 +++++++++++++++++-- app/author/[id]/page.tsx | 2 ++ .../About/ProfileInformationForm/index.tsx | 17 ++++++++++++- hooks/useXConnectCallback.ts | 21 ++++++++++++++++ services/auth.service.ts | 21 ++++++++++------ 6 files changed, 86 insertions(+), 15 deletions(-) create mode 100644 hooks/useXConnectCallback.ts diff --git a/app/api/auth/[...nextauth]/auth.config.ts b/app/api/auth/[...nextauth]/auth.config.ts index e9a90427f..6eb6a2f34 100644 --- a/app/api/auth/[...nextauth]/auth.config.ts +++ b/app/api/auth/[...nextauth]/auth.config.ts @@ -1,4 +1,5 @@ import type { NextAuthOptions } from 'next-auth'; +import { getServerSession } from 'next-auth/next'; import GoogleProvider from 'next-auth/providers/google'; import TwitterProvider from 'next-auth/providers/twitter'; import CredentialsProvider from 'next-auth/providers/credentials'; @@ -39,11 +40,15 @@ export const authOptions: NextAuthOptions = { throw new Error('Invalid X OAuth callback'); } - const data = await AuthService.loginWithX({ - code: params.code, - code_verifier: checks.code_verifier, - redirect_uri: provider.callbackUrl, - }); + const session = await getServerSession(authOptions); + const data = await AuthService.authenticateWithX( + { + code: params.code, + code_verifier: checks.code_verifier, + redirect_uri: provider.callbackUrl, + }, + session?.authToken + ); return { tokens: { diff --git a/app/auth/signin/page.tsx b/app/auth/signin/page.tsx index a9aead30c..daf81273d 100644 --- a/app/auth/signin/page.tsx +++ b/app/auth/signin/page.tsx @@ -4,17 +4,38 @@ import { useSearchParams } from 'next/navigation'; import AuthContent from '@/components/Auth/AuthContent'; import { useRouter } from 'next/navigation'; import Image from 'next/image'; -import { Suspense } from 'react'; +import { Suspense, useEffect } from 'react'; +import { useSession } from 'next-auth/react'; +import toast from 'react-hot-toast'; function SignInContent() { const router = useRouter(); const searchParams = useSearchParams(); + const { status } = useSession(); const authError = searchParams?.get('error'); const error = authError === 'OAuthCallback' ? 'Unable to sign in. Please try again. For X, confirm your email address and allow email access.' : authError; const callbackUrl = searchParams?.get('callbackUrl') || '/'; + const isFailedXConnect = status === 'authenticated' && !!authError; + + const getSafeCallbackUrl = () => { + const url = new URL(callbackUrl, window.location.origin); + url.searchParams.delete('x_connected'); + return url.origin === window.location.origin ? url.href : '/'; + }; + + useEffect(() => { + if (isFailedXConnect) { + toast.error('Unable to connect X. It may already be linked to a different account.'); + router.replace(getSafeCallbackUrl()); + } + }, [isFailedXConnect]); + + if (authError && status !== 'unauthenticated') { + return null; + } return (
@@ -47,7 +68,7 @@ function SignInContent() { router.push(callbackUrl)} + onSuccess={() => router.push(getSafeCallbackUrl())} showHeader={false} />
diff --git a/app/author/[id]/page.tsx b/app/author/[id]/page.tsx index 401e610db..05b3e6772 100644 --- a/app/author/[id]/page.tsx +++ b/app/author/[id]/page.tsx @@ -16,6 +16,7 @@ import { useActivityFeed } from '@/hooks/useActivityFeed'; import { useFeedScrollTracking } from '@/hooks/useFeedScrollTracking'; import { ModerationTab } from '@/components/profile/ModerationTab'; import { useOrcidCallback } from '@/components/Orcid/lib/hooks/useOrcidCallback'; +import { useXConnectCallback } from '@/hooks/useXConnectCallback'; import { ProfileHeroBanner, ProfileHeroBannerSkeleton, @@ -105,6 +106,7 @@ export default function AuthorProfilePage({ params }: { params: Promise<{ id: st const authorId = toNumberOrNull(resolvedParams.id); const [{ author: user, isLoading, error }, refetchAuthorInfo] = useAuthorInfo(authorId); useOrcidCallback({ onSuccess: refreshUser }); + useXConnectCallback(); const isHubEditor = !!currentUser?.authorProfile?.isHubEditor; // Tab state — lifted here so the tab bar can live in the hero banner diff --git a/components/profile/About/ProfileInformationForm/index.tsx b/components/profile/About/ProfileInformationForm/index.tsx index 2d1670798..e8c5a46df 100644 --- a/components/profile/About/ProfileInformationForm/index.tsx +++ b/components/profile/About/ProfileInformationForm/index.tsx @@ -26,6 +26,7 @@ import { Accordion, AccordionItem } from '@/components/ui/Accordion'; import { faUpload, faGraduationCap, faShareNodes, faUser } from '@fortawesome/pro-light-svg-icons'; import Icon from '@/components/ui/icons/Icon'; import { OrcidConnectButton } from '@/components/Orcid/OrcidConnectButton'; +import { signIn } from 'next-auth/react'; export type SocialLinkKey = 'linkedin' | 'google_scholar' | 'twitter' | 'orcid_id'; @@ -261,6 +262,7 @@ export function ProfileInformationForm({ .map((key) => { const meta = socialLinkMeta[key]; const isOrcid = key === 'orcid_id'; + const isX = key === 'twitter'; return (
@@ -277,6 +279,19 @@ export function ProfileInformationForm({ className="flex-grow justify-center !bg-orcid-100 text-orcid-700 hover:!bg-orcid-500 !border-orcid-500" showIcon={false} /> + ) : isX && !authorProfile?.twitter ? ( + ) : ( )}
diff --git a/hooks/useXConnectCallback.ts b/hooks/useXConnectCallback.ts new file mode 100644 index 000000000..0ca594a74 --- /dev/null +++ b/hooks/useXConnectCallback.ts @@ -0,0 +1,21 @@ +import { useEffect } from 'react'; +import { usePathname, useRouter, useSearchParams } from 'next/navigation'; +import toast from 'react-hot-toast'; + +export function useXConnectCallback() { + const pathname = usePathname(); + const router = useRouter(); + const searchParams = useSearchParams(); + const isXConnected = searchParams.get('x_connected') === 'true'; + + useEffect(() => { + if (!isXConnected) return; + + toast.success('X connected', { id: 'x-success' }); + + const params = new URLSearchParams(searchParams.toString()); + params.delete('x_connected'); + const query = params.toString(); + router.replace(query ? `${pathname}?${query}` : pathname); + }, [isXConnected, pathname, searchParams, router]); +} diff --git a/services/auth.service.ts b/services/auth.service.ts index b40315b0a..61084f9c9 100644 --- a/services/auth.service.ts +++ b/services/auth.service.ts @@ -188,22 +188,29 @@ export class AuthService { }; } - static async loginWithX(credentials: { - code: string; - code_verifier: string; - redirect_uri: string; - }): Promise<{ key: string; access_token: string }> { + static async authenticateWithX( + credentials: { + code: string; + code_verifier: string; + redirect_uri: string; + }, + authToken?: string + ): Promise<{ key: string; access_token: string }> { const response = await fetch( `${process.env.NEXT_PUBLIC_API_URL}${this.BASE_PATH}/auth/x/login/`, { method: 'POST', - headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + ...(authToken && { Authorization: `Token ${authToken}` }), + }, body: JSON.stringify(credentials), } ); if (!response.ok) { - throw new AuthError('X sign-in failed', response.status); + throw new AuthError('X authentication failed', response.status); } return response.json(); From 8bbeeeb34624da0db4c59e3e02241f0879b4bc6c Mon Sep 17 00:00:00 2001 From: michaelcanova Date: Fri, 30 Oct 2026 13:37:33 -0400 Subject: [PATCH 2/4] Refinement and forcing proper X connection instead of manual entry --- app/about/components/data/team.ts | 2 +- app/privacy/page.tsx | 2 +- components/landing/LandingPageFooter.tsx | 2 +- components/profile/About/ProfileInformationForm/index.tsx | 5 +++-- components/profile/ProfileSocialLinks.tsx | 8 +++++--- components/ui/AuthorTooltip.tsx | 6 +++--- types/authorProfile.ts | 2 ++ 7 files changed, 16 insertions(+), 11 deletions(-) diff --git a/app/about/components/data/team.ts b/app/about/components/data/team.ts index dc93d224e..9b1cb0e76 100644 --- a/app/about/components/data/team.ts +++ b/app/about/components/data/team.ts @@ -9,7 +9,7 @@ export interface TeamMember { } export const SOCIAL_LABELS: Record = { - x: 'X / Twitter', + x: 'X', linkedin: 'LinkedIn', github: 'GitHub', rh: 'ResearchHub', diff --git a/app/privacy/page.tsx b/app/privacy/page.tsx index f6d81c3d9..23e4a4cb1 100644 --- a/app/privacy/page.tsx +++ b/app/privacy/page.tsx @@ -196,7 +196,7 @@ const PrivacyPage = () => { establish with the third party that provides the social sharing feature. For more information about the purpose and scope of data collection and processing in connection with social sharing features, please visit the privacy policies of the - third parties that provide these social sharing features (e.g.Twitter). + third parties that provide these social sharing features (e.g. X). diff --git a/components/landing/LandingPageFooter.tsx b/components/landing/LandingPageFooter.tsx index 7b81686f1..1703d5571 100644 --- a/components/landing/LandingPageFooter.tsx +++ b/components/landing/LandingPageFooter.tsx @@ -48,7 +48,7 @@ export function LandingPageFooter() { { href: 'https://x.com/researchhub', icon: faXTwitter, - label: 'Follow us on X (Twitter)', + label: 'Follow us on X', }, { href: 'https://discord.com/invite/ZcCYgcnUp5', diff --git a/components/profile/About/ProfileInformationForm/index.tsx b/components/profile/About/ProfileInformationForm/index.tsx index e8c5a46df..2dff8afb6 100644 --- a/components/profile/About/ProfileInformationForm/index.tsx +++ b/components/profile/About/ProfileInformationForm/index.tsx @@ -64,7 +64,7 @@ export function ProfileInformationForm({ }, twitter: { icon: , - label: 'X (Twitter) Profile URL', + label: 'X Profile URL', }, orcid_id: { icon: , @@ -76,6 +76,7 @@ export function ProfileInformationForm({ const authorProfile = user?.authorProfile; const isOrcidConnected = authorProfile?.isOrcidConnected ?? false; + const isXConnected = authorProfile?.isXConnected ?? false; const methods = useForm({ resolver: zodResolver(getProfileInformationSchema({ fields })), @@ -279,7 +280,7 @@ export function ProfileInformationForm({ className="flex-grow justify-center !bg-orcid-100 text-orcid-700 hover:!bg-orcid-500 !border-orcid-500" showIcon={false} /> - ) : isX && !authorProfile?.twitter ? ( + ) : isX && !isXConnected ? ( - ) : ( - - )} + ); + } + + return ( + + ); + }; + + return ( +
+ + {renderSocialLinkField()}
); })} From 1ec3097e292415898dfcf03b4763ebf8c55e6c35 Mon Sep 17 00:00:00 2001 From: michaelcanova Date: Fri, 30 Oct 2026 14:41:17 -0400 Subject: [PATCH 4/4] CodeRabbit Fixes --- app/auth/signin/page.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/auth/signin/page.tsx b/app/auth/signin/page.tsx index daf81273d..85d936c30 100644 --- a/app/auth/signin/page.tsx +++ b/app/auth/signin/page.tsx @@ -18,7 +18,8 @@ function SignInContent() { ? 'Unable to sign in. Please try again. For X, confirm your email address and allow email access.' : authError; const callbackUrl = searchParams?.get('callbackUrl') || '/'; - const isFailedXConnect = status === 'authenticated' && !!authError; + const isFailedXConnect = + status === 'authenticated' && !!authError && callbackUrl.includes('x_connected=true'); const getSafeCallbackUrl = () => { const url = new URL(callbackUrl, window.location.origin); @@ -33,7 +34,7 @@ function SignInContent() { } }, [isFailedXConnect]); - if (authError && status !== 'unauthenticated') { + if ((authError && status === 'loading') || isFailedXConnect) { return null; }