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
2 changes: 1 addition & 1 deletion app/about/components/data/team.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export interface TeamMember {
}

export const SOCIAL_LABELS: Record<SocialKey, string> = {
x: 'X / Twitter',
x: 'X',
linkedin: 'LinkedIn',
github: 'GitHub',
rh: 'ResearchHub',
Expand Down
15 changes: 10 additions & 5 deletions app/api/auth/[...nextauth]/auth.config.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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: {
Expand Down
26 changes: 24 additions & 2 deletions app/auth/signin/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div className="min-h-screen flex flex-col items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
Expand Down Expand Up @@ -47,7 +69,7 @@ function SignInContent() {

<AuthContent
initialError={error}
onSuccess={() => router.push(callbackUrl)}
onSuccess={() => router.push(getSafeCallbackUrl())}
showHeader={false}
/>
</div>
Expand Down
2 changes: 2 additions & 0 deletions app/author/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion app/privacy/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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).
</li>
</ul>

Expand Down
2 changes: 1 addition & 1 deletion components/landing/LandingPageFooter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
66 changes: 47 additions & 19 deletions components/profile/About/ProfileInformationForm/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -63,7 +64,7 @@ export function ProfileInformationForm({
},
twitter: {
icon: <FontAwesomeIcon icon={faXTwitter} className="h-6 w-6 text-[#000]" />,
label: 'X (Twitter) Profile URL',
label: 'X Profile URL',
},
orcid_id: {
icon: <FontAwesomeIcon icon={faOrcid} className="h-6 w-6 text-orcid-500" />,
Expand All @@ -75,6 +76,7 @@ export function ProfileInformationForm({

const authorProfile = user?.authorProfile;
const isOrcidConnected = authorProfile?.isOrcidConnected ?? false;
const isXConnected = authorProfile?.isXConnected ?? false;

const methods = useForm<ProfileInformationFormValues>({
resolver: zodResolver(getProfileInformationSchema({ fields })),
Expand Down Expand Up @@ -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 (
<OrcidConnectButton
variant="outlined"
className="flex-grow justify-center !bg-orcid-100 text-orcid-700 hover:!bg-orcid-500 !border-orcid-500"
showIcon={false}
/>
);
}

if (isX && !isXConnected) {
return (
<Button
type="button"
variant="dark"
className="flex-grow"
onClick={() => {
const callbackUrl = new URL(window.location.href);
callbackUrl.searchParams.set('x_connected', 'true');
void signIn('x', { callbackUrl: callbackUrl.href });
}}
>
Connect X
</Button>
);
}

return (
<Input
id={key}
name={key}
value={socialLinks[key] || ''}
onChange={handleSocialLinkChange}
error={errors[key]?.message}
placeholder={meta.label}
className="flex-grow"
disabled={isOrcid || isX}
/>
);
};

return (
<div key={key} className="flex items-center gap-3">
Expand All @@ -271,24 +316,7 @@ export function ProfileInformationForm({
size="sm"
className="text-gray-500"
/>
{isOrcid && !isOrcidConnected ? (
<OrcidConnectButton
variant="outlined"
className="flex-grow justify-center !bg-orcid-100 text-orcid-700 hover:!bg-orcid-500 !border-orcid-500"
showIcon={false}
/>
) : (
<Input
id={key}
name={key}
value={socialLinks[key] || ''}
onChange={handleSocialLinkChange}
error={errors[key]?.message}
placeholder={meta.label}
className="flex-grow"
disabled={isOrcid}
/>
)}
{renderSocialLinkField()}
</div>
);
})}
Expand Down
8 changes: 5 additions & 3 deletions components/profile/ProfileSocialLinks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,11 @@ export function ProfileSocialLinks({
)}
<SocialIcon
icon={<FontAwesomeIcon icon={faXTwitter} className="h-6 w-6" />}
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'
}
/>
</div>
);
Expand Down
6 changes: 3 additions & 3 deletions components/ui/AuthorTooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -305,13 +305,13 @@ export const AuthorTooltip: React.FC<AuthorTooltipProps> = ({
{/* Social links */}
<div className="mt-3 flex items-center justify-center border-t pt-3">
<SocialIcon
href={userData.authorProfile.twitter}
href={userData.authorProfile.isXConnected ? userData.authorProfile.twitter : null}
icon={
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path d="M8.29 20.251c7.547 0 11.675-6.253 11.675-11.675 0-.178 0-.355-.012-.53A8.348 8.348 0 0022 5.92a8.19 8.19 0 01-2.357.646 4.118 4.118 0 001.804-2.27 8.224 8.224 0 01-2.605.996 4.107 4.107 0 00-6.993 3.743 11.65 11.65 0 01-8.457-4.287 4.106 4.106 0 001.27 5.477A4.072 4.072 0 012.8 9.713v.052a4.105 4.105 0 003.292 4.022 4.095 4.095 0 01-1.853.07 4.108 4.108 0 003.834 2.85A8.233 8.233 0 012 18.407a11.616 11.616 0 006.29 1.84" />
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
</svg>
}
label="Twitter"
label="X"
/>
<SocialIcon
href={userData.authorProfile.linkedin}
Expand Down
21 changes: 21 additions & 0 deletions hooks/useXConnectCallback.ts
Original file line number Diff line number Diff line change
@@ -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]);
}
21 changes: 14 additions & 7 deletions services/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
2 changes: 2 additions & 0 deletions types/authorProfile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export interface AuthorProfile {
createdDate?: string;
education?: Education[];
twitter?: string | null;
isXConnected?: boolean;
facebook?: string | null;
linkedin?: string | null;
googleScholar?: string | null;
Expand Down Expand Up @@ -91,6 +92,7 @@ export const transformAuthorProfile = createTransformer<any, AuthorProfile>((raw
createdDate: raw.created_date || undefined,
education: raw.education || undefined,
twitter: raw.twitter || undefined,
isXConnected: raw.is_x_connected || false,
facebook: raw.facebook || undefined,
linkedin: raw.linkedin || undefined,
googleScholar: raw.google_scholar || undefined,
Expand Down
Loading