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/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..85d936c30 100644 --- a/app/auth/signin/page.tsx +++ b/app/auth/signin/page.tsx @@ -4,17 +4,39 @@ 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 && callbackUrl.includes('x_connected=true'); + + 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 === 'loading') || isFailedXConnect) { + return null; + } return (
@@ -47,7 +69,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/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 2d1670798..2045b608a 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'; @@ -63,7 +64,7 @@ export function ProfileInformationForm({ }, twitter: { icon: , - label: 'X (Twitter) Profile URL', + label: 'X Profile URL', }, orcid_id: { icon: , @@ -75,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 })), @@ -261,6 +263,49 @@ export function ProfileInformationForm({ .map((key) => { const meta = socialLinkMeta[key]; const isOrcid = key === 'orcid_id'; + const isX = key === 'twitter'; + + const renderSocialLinkField = () => { + if (isOrcid && !isOrcidConnected) { + return ( + + ); + } + + if (isX && !isXConnected) { + return ( + + ); + } + + return ( + + ); + }; return (
@@ -271,24 +316,7 @@ export function ProfileInformationForm({ size="sm" className="text-gray-500" /> - {isOrcid && !isOrcidConnected ? ( - - ) : ( - - )} + {renderSocialLinkField()}
); })} diff --git a/components/profile/ProfileSocialLinks.tsx b/components/profile/ProfileSocialLinks.tsx index b416582f4..26daa05e7 100644 --- a/components/profile/ProfileSocialLinks.tsx +++ b/components/profile/ProfileSocialLinks.tsx @@ -62,9 +62,11 @@ export function ProfileSocialLinks({ )} } - href={author.twitter} - label="Twitter" - className={author.twitter ? '[&>svg]:text-[#000] [&>svg]:hover:text-[#000] px-0' : 'px-0'} + href={author.isXConnected ? author.twitter : null} + label="X" + className={ + author.isXConnected ? '[&>svg]:text-[#000] [&>svg]:hover:text-[#000] px-0' : 'px-0' + } /> ); diff --git a/components/ui/AuthorTooltip.tsx b/components/ui/AuthorTooltip.tsx index 812ce5550..6a7934876 100644 --- a/components/ui/AuthorTooltip.tsx +++ b/components/ui/AuthorTooltip.tsx @@ -305,13 +305,13 @@ export const AuthorTooltip: React.FC = ({ {/* Social links */}