Skip to content
2 changes: 2 additions & 0 deletions app/author/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import AuthorProfile from './components/AuthorProfile';
import { useAuthorPublications } from '@/hooks/usePublications';
import { transformPublicationToFeedEntry } from '@/types/publication';
import PinnedFundraise from './components/PinnedFundraise';
import { OrcidSyncBanner } from '@/components/banners/OrcidSyncBanner';

function toNumberOrNull(value: any): number | null {
if (value === '' || value === null || value === undefined) return null;
Expand Down Expand Up @@ -276,6 +277,7 @@ export default function AuthorProfilePage({ params }: { params: Promise<{ id: st

return (
<>
<OrcidSyncBanner />
<Card className="mt-4 bg-gray-50">
<AuthorProfile author={user.authorProfile} refetchAuthorInfo={refetchAuthorInfo} />
</Card>
Expand Down
80 changes: 80 additions & 0 deletions components/banners/OrcidSyncBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
'use client';

import { useState } from 'react';
import { useParams } from 'next/navigation';
import { X, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faOrcid } from '@fortawesome/free-brands-svg-icons';
import { useDismissableFeature } from '@/hooks/useDismissableFeature';
import { useUser } from '@/contexts/UserContext';
import { handleOrcidSync } from '@/services/orcid.service';

export function OrcidSyncBanner() {
const { user } = useUser();
const params = useParams<{ id: string }>();
const { isDismissed, dismissFeature, dismissStatus } = useDismissableFeature('orcid_sync_banner');
const [loading, setLoading] = useState(false);

// Only show banner on user's own profile
const routeAuthorId = params?.id?.toString();
const viewerAuthorId = user?.authorProfile?.id?.toString();
const isOwnProfile = routeAuthorId && viewerAuthorId && routeAuthorId === viewerAuthorId;

if (!isOwnProfile) return null;

if (dismissStatus !== 'checked' || isDismissed) return null;

const onClick = async () => {
setLoading(true);
await handleOrcidSync();
dismissFeature();
setLoading(false);
};

return (
<div className="bg-[#F5FAEB] p-4 pr-12 sm:pr-20 rounded-lg border border-[#DCEEC4] mb-6 relative">
<button
onClick={dismissFeature}
className="absolute top-3 right-3 z-10 text-[#7FBF27] hover:text-[#5FA71E] transition-colors"
aria-label="Dismiss"
title="Dismiss"
>
<X className="h-4 w-4" />
</button>

{/* Stack on mobile, row on sm+ */}
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
{/* Icon + text block */}
<div className="flex items-start gap-3 flex-1">
<div className="bg-[#E3F3C1] rounded-lg p-2 shrink-0 flex items-center justify-center">
<FontAwesomeIcon icon={faOrcid} className="block h-5 w-5" color="#A6CE39" />
</div>

<div className="flex-1">
<h3 className="font-medium text-gray-900">
Connect your ORCID iD to auto-sync authorship
</h3>
<p className="text-sm text-gray-700 mt-1">
Securely sync publications from{' '}
<span className="font-medium text-[#6BAA1D]">ORCID</span> into your ResearchHub
profile.
</p>
</div>
</div>

{/* CTA — full width on mobile, auto on desktop */}
<div className="w-full sm:w-auto">
<Button
onClick={onClick}
disabled={loading}
className="w-full sm:w-auto bg-[#A6CE39] hover:bg-[#95BC33] text-white focus-visible:ring-[#A6CE39]"
>
<RefreshCw className="h-4 w-4 mr-3 text-white" />
{loading ? 'Checking…' : 'Sync with ORCID'}
</Button>
</div>
</div>
</div>
);
}
41 changes: 37 additions & 4 deletions components/menus/UserMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
'use client';

import { User as UserIcon, LogOut, BadgeCheck, Bell, Shield, UserPlus } from 'lucide-react';
import {
User as UserIcon,
LogOut,
BadgeCheck,
Bell,
Shield,
UserPlus,
RefreshCw,
} from 'lucide-react';
import { useState, useEffect } from 'react';
import type { User } from '@/types/user';
import VerificationBanner from '@/components/banners/VerificationBanner';
Expand All @@ -16,7 +24,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faPen } from '@fortawesome/free-solid-svg-icons';
import { Button } from '@/components/ui/Button';
import { useVerification } from '@/contexts/VerificationContext';

import { handleOrcidSync } from '@/services/orcid.service';
interface UserMenuProps {
user: User;
onViewProfile: () => void;
Expand Down Expand Up @@ -71,6 +79,11 @@ export default function UserMenu({
setMenuOpenState(false);
};

const onOrcidSync = async () => {
const navigating = !(await handleOrcidSync());
if (!navigating) setMenuOpenState(false);
};

// Apply different avatar size for avatar-only mode
const effectiveAvatarSize =
showAvatarOnly && typeof avatarSize === 'number' ? avatarSize * 1.25 : avatarSize;
Expand Down Expand Up @@ -233,7 +246,20 @@ export default function UserMenu({
</div>
</div>
)}

<div
className="px-6 py-2 hover:bg-gray-50"
onClick={onOrcidSync}
tabIndex={0}
role="button"
aria-label="Sync Authorship"
>
<div className="flex items-center justify-between w-full">
<div className="flex items-center">
<RefreshCw className="h-5 w-5 mr-3 text-gray-500" />
<span className="text-base text-gray-700">Sync Authorship</span>
</div>
</div>
</div>
<div
className="px-6 py-2 hover:bg-gray-50"
onClick={() => AuthSharingService.signOutFromBothApps()}
Expand Down Expand Up @@ -397,7 +423,14 @@ export default function UserMenu({
</div>
</BaseMenuItem>
)}

<BaseMenuItem onClick={onOrcidSync} className="w-full px-4 py-2">
<div className="flex items-center justify-between w-full">
<div className="flex items-center">
<RefreshCw className="h-4 w-4 mr-3 text-gray-500" />
<span className="text-sm text-gray-700">Sync Authorship</span>
</div>
</div>
</BaseMenuItem>
<BaseMenuItem
onClick={() => AuthSharingService.signOutFromBothApps()}
className="w-full px-4 py-2"
Expand Down
25 changes: 25 additions & 0 deletions contexts/UserContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import { createContext, useContext, ReactNode, useState, useEffect } from 'react';
import { useSession } from 'next-auth/react';
import { useSearchParams, usePathname, useRouter } from 'next/navigation';
import { toast } from 'react-hot-toast';
import { AuthError, AuthService } from '@/services/auth.service';
import type { User } from '@/types/user';
import { AuthSharingService } from '@/services/auth-sharing.service';
Expand All @@ -19,6 +21,9 @@ const UserContext = createContext<UserContextType | null>(null);

export function UserProvider({ children }: { children: ReactNode }) {
const { data: session, status } = useSession();
const search = useSearchParams();
const pathname = usePathname();
const router = useRouter();
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
Expand Down Expand Up @@ -89,6 +94,26 @@ export function UserProvider({ children }: { children: ReactNode }) {
}
}, [user, isLoading, isAnalyticsInitialized]);

// Handle ORCID OAuth redirect notifications
useEffect(() => {
const url = new URL(window.location.href);
const syncResult = url.searchParams.get('orcid_sync');
if (!syncResult) return;

const errorMessage = url.searchParams.get('error');

url.searchParams.delete('orcid_sync');
url.searchParams.delete('error');
window.history.replaceState({}, '', url.toString());

if (syncResult === 'ok') {
toast.success("Sync started! We'll refresh your authorship shortly.");
} else if (syncResult === 'fail') {
const message = errorMessage ? decodeURIComponent(errorMessage) : 'ORCID sync failed.';
toast.error(message);
}
}, []);

return (
<UserContext.Provider value={{ user, isLoading, error, refreshUser }}>
{children}
Expand Down
36 changes: 36 additions & 0 deletions services/orcid.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { ApiClient } from './client';
import { toast } from 'react-hot-toast';

export async function isOrcidConnected(): Promise<boolean> {
try {
const { connected, needs_reauth } = await ApiClient.post<any>('/api/orcid/check');
return connected && !needs_reauth;
} catch {
return false;
}
}

export async function resyncOrcidPublications(): Promise<void> {
await ApiClient.post('/api/orcid/sync');
}

export async function connectOrcidAccount(returnUrl = window.location.href): Promise<void> {
const { auth_url } = await ApiClient.post<any>('/api/orcid/connect', { return_to: returnUrl });
window.location.href = auth_url;
}

export async function handleOrcidSync(): Promise<boolean> {
try {
if (await isOrcidConnected()) {
toast.success("Resync started! We'll refresh your authorship shortly.");
await resyncOrcidPublications();
return true;
} else {
await connectOrcidAccount();
return false;
}
} catch {
toast.error('Could not start ORCID operation. Please try again.');
return false;
}
}