Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
31aed05
First steps for connecting to orcid
Dec 9, 2025
368df80
Merge branch 'main' of https://github.com/ResearchHub/web into orcid_…
Dec 10, 2025
7cf7b10
[ORCID connection] Cleanup, adding Connection page for callback inst…
Dec 10, 2025
6bc1d76
Updated state management for callback page and user profile
Dec 10, 2025
cb907e2
[ORCID Connection] Updating types from number to ID for author id
Dec 12, 2025
03497dc
[ORCID Connection] Tweaks and style updates
Dec 14, 2025
efb6a6e
[ORCID Connect] Moved Callback functionality to backend, added Connec…
Dec 15, 2025
667a26d
Fixing Merge Conflicts
Dec 15, 2025
ea9bc01
[ORCID Connection] Adding toast message const to deliver proper toast…
Dec 15, 2025
fd87b32
Merge branch 'main' of https://github.com/ResearchHub/web into orcid_…
Dec 15, 2025
38198e9
[ORCID Connection] Linter Fixes
Dec 15, 2025
5635634
[ORCID Integration] Adding in Moderation details, fixing typing for i…
Dec 18, 2025
8d05e31
[ORCID Integration] Removing Connect to ORCID from User Menu and upda…
Dec 19, 2025
07c6255
[ORCID Integration] More Small Cleanup
Dec 19, 2025
d507050
[ORCID Integration] Removing extra white line spacing
Dec 19, 2025
5f629a8
[ORCID Integration] Optimizations, adding orcid color scheme to tailw…
Dec 19, 2025
227b2c1
[ORCID Integration] Linter Fixes
Dec 19, 2025
2a5b647
[ORCID Integration] globalThis linter fix
Dec 19, 2025
9c1914d
Merge branch 'main' of https://github.com/ResearchHub/web into orcid_…
Dec 19, 2025
6bc8583
[ORCID Integration] Updating remaining parts not using new orcid tai…
Dec 19, 2025
730b4a8
[ORCID Integration] Banner style overrides
Dec 19, 2025
7ea517c
[ORCID Integration] Updating error messaging for ORCID connection fai…
Dec 19, 2025
ec74e89
[ORCID Integration] Optimizing connection setup for potential errors
Dec 19, 2025
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
6 changes: 4 additions & 2 deletions app/author/[id]/components/AuthorProfile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -230,10 +230,12 @@ const AuthorProfile: React.FC<AuthorProfileProps> = ({ author, refetchAuthorInfo
/>
<SocialIcon
icon={<FontAwesomeIcon icon={faOrcid} className="h-6 w-6" />}
href={author.orcidId}
href={author.isOrcidConnected ? author.orcidId : null}
label="ORCID"
className={
author.orcidId ? '[&>svg]:text-[#A6CE39] [&>svg]:hover:text-[#82A629] px-0' : 'px-0'
author.isOrcidConnected
? '[&>svg]:text-orcid-500 [&>svg]:hover:text-orcid-600 px-0'
: 'px-0'
}
/>
<SocialIcon
Expand Down
12 changes: 10 additions & 2 deletions app/author/[id]/components/Moderation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,15 @@ export function ModerationSkeleton() {

<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="flex flex-col gap-2 text-sm w-full md:max-w-[300px]">
{Array.from({ length: 4 }).map((_, i) => (
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center justify-between">
<span className="font-medium whitespace-nowrap bg-gray-200 rounded h-4 w-24 animate-pulse" />
<span className="bg-gray-200 rounded h-4 w-32 animate-pulse" />
</div>
))}
</div>
<div className="flex flex-col gap-2 text-sm">
{Array.from({ length: 4 }).map((_, i) => (
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center justify-between gap-2">
<span className="font-medium whitespace-nowrap bg-gray-200 rounded h-4 w-28 animate-pulse" />
<span className="bg-gray-200 rounded h-4 w-36 animate-pulse" />
Expand Down Expand Up @@ -241,6 +241,10 @@ export default function Moderation({ userId, authorId, refetchAuthorInfo }: Mode
<span className="font-medium whitespace-nowrap">Suspended?</span>
<span>{userDetails.isSuspended ? 'Yes' : 'No'}</span>
</div>
<div className="flex items-center justify-between">
<span className="font-medium whitespace-nowrap">ORCID Connected?</span>
<span>{userDetails.isOrcidConnected ? 'Yes' : 'No'}</span>
</div>
</div>

<div className="flex flex-col gap-2 text-sm">
Expand Down Expand Up @@ -275,6 +279,10 @@ export default function Moderation({ userId, authorId, refetchAuthorInfo }: Mode
<span className="font-medium whitespace-nowrap">Verified status:</span>
<span>{userDetails.verification?.status || 'N/A'}</span>
</div>
<div className="flex items-center justify-between gap-2">
<span className="font-medium whitespace-nowrap">ORCID Email:</span>
<span className="truncate">{userDetails.orcidVerifiedEduEmail || 'N/A'}</span>
</div>
</div>
</div>
</div>
Expand Down
13 changes: 10 additions & 3 deletions app/author/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ import AuthorProfile from './components/AuthorProfile';
import { useAuthorPublications } from '@/hooks/usePublications';
import { transformPublicationToFeedEntry } from '@/types/publication';
import PinnedFundraise from './components/PinnedFundraise';

import { OrcidSyncBanner } from '@/components/Orcid/OrcidSyncBanner';
import { useOrcidCallback } from '@/components/Orcid/lib/hooks/useOrcidCallback';
function toNumberOrNull(value: any): number | null {
if (value === '' || value === null || value === undefined) return null;
const num = Number(value);
Expand Down Expand Up @@ -255,8 +256,8 @@ export default function AuthorProfilePage({ params }: { params: Promise<{ id: st
const { isLoading: isUserLoading, error: userError } = useUser();
const authorId = toNumberOrNull(resolvedParams.id);
const [{ author: user, isLoading, error }, refetchAuthorInfo] = useAuthorInfo(authorId);
const { user: currentUser } = useUser();
// Determine if current user is a hub editor
const { user: currentUser, refreshUser } = useUser();
useOrcidCallback({ onSuccess: refreshUser });
const isHubEditor = !!currentUser?.authorProfile?.isHubEditor;
const [{ achievements, isLoading: isAchievementsLoading, error: achievementsError }] =
useAuthorAchievements(authorId);
Expand Down Expand Up @@ -291,8 +292,14 @@ export default function AuthorProfilePage({ params }: { params: Promise<{ id: st
return <AuthorProfileError error="Author not found" />;
}

const isOwnProfile = Boolean(
currentUser?.authorProfile?.id && user.authorProfile.id === currentUser.authorProfile.id
);
const isOrcidConnected = Boolean(currentUser?.authorProfile?.isOrcidConnected);

return (
<>
<OrcidSyncBanner isOwnProfile={isOwnProfile} isOrcidConnected={isOrcidConnected} />
<Card className="mt-4 bg-gray-50">
<AuthorProfile author={user.authorProfile} refetchAuthorInfo={refetchAuthorInfo} />
</Card>
Expand Down
7 changes: 7 additions & 0 deletions app/styles/colors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,11 @@ export const colors = {
900: '#1e3a8a',
950: '#172554',
},
orcid: {
50: '#F5FAEB', // Pale lemon background
100: '#E3F3C1', // Light green
200: '#DCEEC4', // Border green
500: '#A6CE39', // ORCID brand green
600: '#82A629', // Darker green for hover
},
};
38 changes: 38 additions & 0 deletions components/Orcid/OrcidConnectButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
'use client';

import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faOrcid } from '@fortawesome/free-brands-svg-icons';
import { Loader2 } from 'lucide-react';
import { Button, ButtonProps } from '@/components/ui/Button';
import { useConnectOrcid } from '@/components/Orcid/lib/hooks/useConnectOrcid';

interface OrcidConnectButtonProps {
readonly variant?: ButtonProps['variant'];
readonly size?: ButtonProps['size'];
readonly className?: string;
}

export function OrcidConnectButton({
variant = 'outlined',
size = 'default',
className,
}: OrcidConnectButtonProps) {
const { connect, isConnecting } = useConnectOrcid();

return (
<Button
onClick={connect}
disabled={isConnecting}
variant={variant}
size={size}
className={className}
>
{isConnecting ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<FontAwesomeIcon icon={faOrcid} className="mr-2 h-4 w-4" />
)}
{isConnecting ? 'Connecting...' : 'Connect ORCID'}
</Button>
);
}
57 changes: 57 additions & 0 deletions components/Orcid/OrcidSyncBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
'use client';

import { X } from 'lucide-react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faOrcid } from '@fortawesome/free-brands-svg-icons';
import { OrcidConnectButton } from '@/components/Orcid/OrcidConnectButton';
import { useDismissableFeature } from '@/hooks/useDismissableFeature';
import { Button } from '@/components/ui/Button';

const FEATURE_NAME = 'orcid_sync_banner';

interface OrcidSyncBannerProps {
readonly isOwnProfile: boolean;
readonly isOrcidConnected: boolean;
}

export function OrcidSyncBanner({ isOwnProfile, isOrcidConnected }: OrcidSyncBannerProps) {
const { isDismissed, dismissFeature, dismissStatus } = useDismissableFeature(FEATURE_NAME);

if (!isOwnProfile || isOrcidConnected || dismissStatus !== 'checked' || isDismissed) {
return null;
}

return (
<div className="relative mb-6 rounded-lg border border-orcid-200 bg-orcid-50 p-4">
<Button
variant="ghost"
size="icon"
onClick={dismissFeature}
className="absolute right-2 top-2 h-6 w-6 rounded-lg p-0.5 text-gray-500 hover:bg-orcid-100"
aria-label="Dismiss banner"
>
<X className="h-4 w-4" />
</Button>

<div className="flex flex-col gap-3 sm:!flex-row sm:!items-center sm:!pr-8">
<div className="flex flex-1 items-start gap-3 pr-8 sm:!pr-0">
<div className="flex shrink-0 items-center justify-center rounded-lg bg-orcid-100 p-2">
<FontAwesomeIcon icon={faOrcid} className="h-5 w-5 text-orcid-500" />
</div>

<div className="min-w-0 flex-1">
<h3 className="font-medium text-gray-900">Sync your ORCID account</h3>
<p className="mt-1 hidden text-sm text-gray-700 sm:!block">
Sync your ORCID publications with your ResearchHub profile
</p>
</div>
</div>

<OrcidConnectButton
variant="default"
className="w-full shrink-0 !bg-orcid-500 !text-white hover:!bg-orcid-600 !border-orcid-500 sm:!w-auto"
/>
</div>
</div>
);
}
31 changes: 31 additions & 0 deletions components/Orcid/lib/hooks/useConnectOrcid.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { useState, useCallback } from 'react';
import { usePathname, useSearchParams } from 'next/navigation';
import { connectOrcidAccount } from '@/components/Orcid/lib/services/orcid.service';
import { extractApiErrorMessage } from '@/services/lib/serviceUtils';
import toast from 'react-hot-toast';

export function useConnectOrcid() {
const [isConnecting, setIsConnecting] = useState(false);
const pathname = usePathname();
const searchParams = useSearchParams();

const connect = useCallback(async () => {
const url = new URL(pathname, globalThis.location.origin);
const query = searchParams.toString();
if (query) {
url.search = query;
}

setIsConnecting(true);
const result = await connectOrcidAccount(url.toString());

if (result.success) {
globalThis.location.href = result.authUrl;
} else {
toast.error(extractApiErrorMessage(result.error, 'Unable to connect to ORCID.'));
setIsConnecting(false);
}
}, [pathname, searchParams]);

return { connect, isConnecting };
}
42 changes: 42 additions & 0 deletions components/Orcid/lib/hooks/useOrcidCallback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { useEffect } from 'react';
import { useSearchParams, usePathname, useRouter } from 'next/navigation';
import toast from 'react-hot-toast';

const ORCID_MESSAGES = {
success: 'ORCID connected',
error: 'Unable to connect to ORCID',
already_linked: 'This ORCID ID is already linked',
} as const;

interface UseOrcidCallbackOptions {
readonly onSuccess?: () => void;
}

export function useOrcidCallback({ onSuccess }: UseOrcidCallbackOptions = {}) {
const pathname = usePathname();
const router = useRouter();
const searchParams = useSearchParams();

const orcidSuccess = searchParams.get('orcid_connected') === 'true';
const orcidError = searchParams.get('orcid_error');

useEffect(() => {
if (!orcidSuccess && !orcidError) return;

if (orcidSuccess) {
toast.success(ORCID_MESSAGES.success, { id: 'orcid-success' });
onSuccess?.();
} else if (orcidError) {
const errorMessage =
ORCID_MESSAGES[orcidError as keyof typeof ORCID_MESSAGES] || decodeURIComponent(orcidError);
toast.error(errorMessage, { id: 'orcid-error' });
}

const params = new URLSearchParams(searchParams.toString());
params.delete('orcid_connected');
params.delete('orcid_error');
const query = params.toString();
const url = query ? `${pathname}?${query}` : pathname;
router.replace(url);
}, [orcidSuccess, orcidError, pathname, searchParams, router, onSuccess]);
}
19 changes: 19 additions & 0 deletions components/Orcid/lib/services/orcid.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { ApiClient } from '@/services/client';

type ConnectResult = { success: true; authUrl: string } | { success: false; error: string };

export async function connectOrcidAccount(returnUrl: string): Promise<ConnectResult> {
try {
const response = await ApiClient.post<{ auth_url: string }>('/api/orcid/connect/', {
return_url: returnUrl,
});

if (!response.auth_url) {
return { success: false, error: 'Unable to connect to ORCID.' };
}

return { success: true, authUrl: response.auth_url };
} catch {
return { success: false, error: 'Unable to connect to ORCID.' };
}
}
36 changes: 22 additions & 14 deletions components/profile/About/ProfileInformationForm/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import toast from 'react-hot-toast';
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';

interface ProfileInformationFormProps {
onSubmit: (data: ProfileInformationFormValues) => void;
Expand Down Expand Up @@ -52,20 +53,20 @@ export function ProfileInformationForm({
icon: <FontAwesomeIcon icon={faGoogle} className="h-6 w-6 text-[#4285F4]" />,
label: 'Google Scholar Profile URL',
},
orcid_id: {
icon: <FontAwesomeIcon icon={faOrcid} className="h-6 w-6 text-[#A6CE39]" />,
label: 'ORCID URL',
},

twitter: {
icon: <FontAwesomeIcon icon={faXTwitter} className="h-6 w-6 text-[#000]" />,
label: 'X (Twitter) Profile URL',
},
orcid_id: {
icon: <FontAwesomeIcon icon={faOrcid} className="h-6 w-6 text-orcid-500" />,
label: 'ORCID URL',
},
} as const;

const { user } = useUser();

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

const methods = useForm<ProfileInformationFormValues>({
resolver: zodResolver(getProfileInformationSchema({ fields })),
Expand Down Expand Up @@ -249,6 +250,8 @@ export function ProfileInformationForm({
<div className="space-y-3">
{(Object.keys(socialLinkMeta) as Array<keyof typeof socialLinkMeta>).map((key) => {
const meta = socialLinkMeta[key];
const isOrcid = key === 'orcid_id';

return (
<div key={key} className="flex items-center gap-3">
<SocialIcon
Expand All @@ -258,15 +261,20 @@ export function ProfileInformationForm({
size="sm"
className="text-gray-500"
/>
<Input
id={key}
name={key}
value={socialLinks[key] || ''}
onChange={handleSocialLinkChange}
error={errors[key]?.message}
placeholder={meta.label}
className="flex-grow"
/>
{isOrcid && !isOrcidConnected ? (
<OrcidConnectButton className="flex-grow justify-center" />
) : (
<Input
id={key}
name={key}
value={socialLinks[key] || ''}
onChange={handleSocialLinkChange}
error={errors[key]?.message}
placeholder={meta.label}
className="flex-grow"
disabled={isOrcid}
/>
)}
</div>
);
})}
Expand Down
9 changes: 6 additions & 3 deletions components/ui/AuthorTooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { cn } from '@/utils/styles';
import { InfoIcon } from 'lucide-react';
import { SocialIcon } from '@/components/ui/SocialIcon';
import { VerifiedBadge } from '@/components/ui/VerifiedBadge';
import { colors } from '@/app/styles/colors';

interface AuthorTooltipProps {
authorId?: number;
Expand Down Expand Up @@ -344,12 +345,14 @@ export const AuthorTooltip: React.FC<AuthorTooltipProps> = ({
label="Google Scholar"
/>
<SocialIcon
href={userData.authorProfile.orcidId}
href={userData.authorProfile.isOrcidConnected ? userData.authorProfile.orcidId : null}
icon={
<svg className="w-5 h-5" viewBox="0 0 24 24" aria-hidden="true">
{/* ORCID icon with its official color */}
{/* ORCID icon - green if connected, gray if not */}
<path
fill="#A6CE39"
fill={
userData.authorProfile.isOrcidConnected ? colors.orcid[500] : 'currentColor'
}
d="M12 0C5.372 0 0 5.372 0 12s5.372 12 12 12 12-5.372 12-12S18.628 0 12 0zM7.369 4.378c.525 0 .947.431.947.947s-.422.947-.947.947a.95.95 0 0 1-.947-.947c0-.525.422-.947.947-.947zm-.722 3.038h1.444v10.041H6.647V7.416zm4.163 0h3.909c3.947 0 5.894 2.788 5.894 5.022 0 2.628-2.009 5.022-5.897 5.022h-3.906V7.416zm1.444 1.368v7.303h2.463c3.103 0 4.453-1.641 4.453-3.652 0-1.838-1.2-3.653-4.453-3.653h-2.463v.002z"
/>
</svg>
Expand Down
Loading