Skip to content
Open
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
5 changes: 3 additions & 2 deletions .github/workflows/cypress_tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ jobs:
steps:
- uses: actions/setup-node@v4
with:
node-version: 18
node-version: 22
- name: Checkout
uses: actions/checkout@v4
- name: Cypress run
Expand All @@ -40,7 +40,8 @@ jobs:
CYPRESS_NODE_ENV: test
NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }}
NEXT_PUBLIC_ORCID_API_URL: ${{ secrets.NEXT_PUBLIC_ORCID_API_URL }}
NEXT_PUBLIC_JWT_PUBLIC_KEY: ${{ secrets.CYPRESS_JWT_PUBLIC_KEY_2025}}
SITEMAPS_URL: ${{ secrets.SITEMAPS_URL }}
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
CYPRESS_USER_COOKIE: ${{ secrets.CYPRESS_USER_COOKIE }}
CYPRESS_userCookie: ${{ secrets.CYPRESS_USER_COOKIE_2025 }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
4 changes: 2 additions & 2 deletions cypress.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@ export default defineConfig({
projectId: 'yur1cf',
retries: 2,
e2e: {
setupNodeEvents(on, config) {},
setupNodeEvents() {},
baseUrl: 'http://localhost:3000',
specPattern: 'cypress/e2e/**/*.test.*',
},
component: {
setupNodeEvents(on, config) { },
setupNodeEvents() { },
specPattern: 'src/components/**/*.test.*',
devServer: {
bundler: 'webpack',
Expand Down
38 changes: 38 additions & 0 deletions cypress/e2e/session.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
describe('useSession behavior', () => {
beforeEach(() => {
cy.setCookie('_consent', 'true');
});

it('shows logged in state with valid token', () => {
cy.then(() => {
let userCookie = Cypress.env('userCookie');
if (typeof userCookie === 'object') {
userCookie = JSON.stringify(userCookie);
}
Cypress.log({
name: 'diagnostics:userCookie',
message: [
`type=${typeof userCookie}`,
`present=${Boolean(userCookie)}`,
`stringLength=${typeof userCookie === 'string' ? userCookie.length : 'n/a'}`,
`preview=${userCookie.substring(0, 50)}...`,
],
});
});

cy.setCookie('_datacite', String(Cypress.env('userCookie')), { log: false });
cy.visit('/');
cy.get('#sign-in').should('contain.text', 'DataCite Test User'); // Match your JWT payload name
});

it('shows logged out state without token', () => {
cy.visit('/');
cy.get('#sign-in').should('contain.text', 'Sign In');
});

it('shows logged out state with invalid token', () => {
cy.setCookie('_datacite', '{"authenticated":{"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.invalid.signature"}}', { log: false });
cy.visit('/');
cy.get('#sign-in').should('contain.text', 'Sign In');
});
});
3 changes: 1 addition & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,6 @@
"@testing-library/user-event": "^11.2.1",
"@types/gtag.js": "^0.0.3",
"@types/js-cookie": "^2.2.6",
"@types/jsonwebtoken": "^8.5.0",
"@types/lodash": "^4.14.154",
"@types/react-content-loader": "^4.0.0",
"@types/testing-library__cypress": "^5.0.5",
Expand All @@ -56,8 +55,8 @@
"html-react-parser": "^5.1.12",
"iso-3166-1": "^2.1.1",
"iso-639-1": "^3.1.3",
"jose": "^6.1.3",
"js-yaml-loader": "^1.2.2",
"jsonwebtoken": "^8.5.1",
"linkify-react": "^4.2.0",
"linkifyjs": "^4.2.0",
"maltipoo": "https://github.com/datacite/maltipoo#2.0.3",
Expand Down
3 changes: 2 additions & 1 deletion src/app/(main)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ const sourceSans3 = Source_Sans_3({
})

export default async function RootLayout({ children }: PropsWithChildren) {
const authToken = await getAuthToken()
return (
<html lang="en" className={sourceSans3.className}>
<head>
Expand All @@ -54,7 +55,7 @@ export default async function RootLayout({ children }: PropsWithChildren) {
</Suspense>
</head>
<body className={sourceSans3.className}>
<Providers authToken={getAuthToken()} >
<Providers authToken={authToken} >
<Header />
<DiscoverWorksAlert />
<div className="container-fluid flex-grow-1">{children}</div>
Expand Down
4 changes: 2 additions & 2 deletions src/components/Claim/Claim.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { useEffect } from 'react';
import { useMutation, ApolloCache } from '@apollo/client'
import { faOrcid } from '@fortawesome/free-brands-svg-icons'

import { session, User } from 'src/utils/session'
import { useSession, User } from 'src/utils/session'
import { Claim as ClaimType } from 'src/data/types'
import Error from 'src/components/Error/Error'
import ClaimStatus from 'src/components/ClaimStatus/ClaimStatus'
Expand Down Expand Up @@ -80,7 +80,7 @@ export default function Claim({ doi_id }: Props) {
}
})

const user = session()
const { user } = useSession()

const claim: ClaimType = data?.work.claims[0] || {
id: null,
Expand Down
4 changes: 2 additions & 2 deletions src/components/DiscoverWorksAlert/DiscoverWorksAlert.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,12 @@ import DataCiteButton from 'src/components/DataCiteButton/DataCiteButton'
import { faSearch } from '@fortawesome/free-solid-svg-icons'
import { PROFILES_SETTINGS_URL } from 'src/data/constants'

import { session } from "src/utils/session";
import { useSession } from "src/utils/session";
import styles from './DiscoverWorksAlert.module.scss'


export default function DiscoverWorksAlert() {
const user = session()
const { user } = useSession()

const [show, setShow] = useState(true);

Expand Down
6 changes: 3 additions & 3 deletions src/components/Header/ClientButtons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@ import { faAddressCard } from '@fortawesome/free-solid-svg-icons'
import { faOrcid } from '@fortawesome/free-brands-svg-icons'

import { ORCID_URL } from 'src/data/constants';
import { session } from "src/utils/session";
import { useSession } from "src/utils/session";

export function UserCommonsPageButton() {
const user = session()
const { user } = useSession()
if (!user) throw new Error("User not signed in")
Comment on lines +13 to 14
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Handle loading state to prevent premature error throwing.

Both UserCommonsPageButton and UserOrcidButton throw errors when user is null, but useSession() returns null during the initial loading phase. This causes these components to throw errors during normal operation before the session is loaded.

Apply this diff to handle the loading state properly:

 export function UserCommonsPageButton() {
-  const { user } = useSession()
+  const { user, loading } = useSession()
+  if (loading) return null // Or a loading skeleton
   if (!user) throw new Error("User not signed in")
   const href = '/orcid.org/' + user.uid
 
   return <DropdownItem href={href} eventKey={3.3} data-cy="commons-page">
     <FontAwesomeIcon icon={faAddressCard} /> Commons Page
   </DropdownItem>
 }
 
 
 export function UserOrcidButton() {
-  const { user } = useSession()
+  const { user, loading } = useSession()
+  if (loading) return null // Or a loading skeleton
   if (!user) throw new Error("User not signed in")
   const href = `${ORCID_URL}/${user.uid}`

Also applies to: 24-25

🤖 Prompt for AI Agents
In src/components/Header/ClientButtons.tsx around lines 13-14 and 24-25, the
components currently throw an Error when user is falsy but useSession() can be
null during initial loading; update the components to handle the loading state
by checking the session status (or user presence) before throwing: if session is
undefined or status is "loading" return null or a lightweight loading
placeholder, only throw or render error UI when status is "unauthenticated" or
when you explicitly know loading finished and user is still null; replace direct
throw new Error("User not signed in") with guarded logic that first checks
loading and returns early to avoid premature errors.

const href = '/orcid.org/' + user.uid

Expand All @@ -21,7 +21,7 @@ export function UserCommonsPageButton() {


export function UserOrcidButton() {
const user = session()
const { user } = useSession()
if (!user) throw new Error("User not signed in")
const href = `${ORCID_URL}/${user.uid}`

Expand Down
5 changes: 3 additions & 2 deletions src/components/Header/Dropdown.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
'use client'

import React, { PropsWithChildren } from 'react'
import { session } from 'src/utils/session'
import { useSession } from 'src/utils/session'
import NavDropdown from 'react-bootstrap/NavDropdown'

export default function UserDropdown({ children }: PropsWithChildren) {
const user = session()
const { user, loading } = useSession()
if (loading) return null // Or return a loading skeleton
if (!user) throw new Error("User not signed in")

return <NavDropdown title={user.name} className="my-4" id="basic-nav-dropdown">
Expand Down
4 changes: 2 additions & 2 deletions src/components/Header/NavRight.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
'use client'

import React from 'react';
import { session } from 'src/utils/session';
import { useSession } from 'src/utils/session';

interface Props {
signedInContent: React.ReactNode
signedOutContent: React.ReactNode
}

export default function NavRight({ signedInContent, signedOutContent }: Props) {
const user = session()
const { user } = useSession()

if (!user) return signedOutContent
return signedInContent
Comment on lines +12 to 15
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Handle loading state to prevent UI flicker.

The component ignores the loading state from useSession(), causing it to render signedOutContent during the initial load even when a user is authenticated. This creates a visible flash of the "Sign In" UI before the user information appears.

Apply this diff to handle the loading state:

 export default function NavRight({ signedInContent, signedOutContent }: Props) {
-  const { user } = useSession()
+  const { user, loading } = useSession()
 
+  if (loading) return signedOutContent // Or a loading skeleton
   if (!user) return signedOutContent
   return signedInContent
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const { user } = useSession()
if (!user) return signedOutContent
return signedInContent
const { user, loading } = useSession()
if (loading) return signedOutContent
if (!user) return signedOutContent
return signedInContent
🤖 Prompt for AI Agents
In src/components/Header/NavRight.tsx around lines 12 to 15, the component
currently only checks user and returns signedOutContent which causes a flash;
update the logic to also read the loading flag from useSession() and while
loading return a neutral placeholder (e.g., null or a spinner) to avoid
rendering signedOutContent, then when loading is false return signedInContent if
user exists otherwise signedOutContent; ensure you destructure loading from
useSession(), use it first in the conditional, and keep existing
signedInContent/signedOutContent flows intact.

Expand Down
7 changes: 4 additions & 3 deletions src/utils/apolloClient/apolloClient.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { cookies, type UnsafeUnwrappedCookies } from 'next/headers';
import { cookies } from 'next/headers';
import apolloClientBuilder from './builder'

export function getAuthToken() {
const sessionCookie = JSON.parse(((cookies() as unknown as UnsafeUnwrappedCookies).get('_datacite') as any)?.value || '{}')
export async function getAuthToken() {
const cookieStore = await cookies()
const sessionCookie = JSON.parse((cookieStore.get('_datacite') as any)?.value || '{}')
return sessionCookie?.authenticated?.access_token
}

Expand Down
6 changes: 3 additions & 3 deletions src/utils/apolloClient/builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,16 @@ import { DATACITE_API_URL } from 'src/data/constants'
* this throws an error unless the token is returned from a function that is called
* in the authLink setContext. I'm not sure why
*/
export default function apolloClientBuilder(getToken: () => string) {
export default function apolloClientBuilder(getToken: () => Promise<string | null>) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# First, let's check the builder.ts file to see the function signature and implementation
cd src/utils/apolloClient && cat -n builder.ts | head -30

Repository: datacite/akita

Length of output: 1356


🏁 Script executed:

# Now let's examine provider.tsx to see how apolloClientBuilder is being called
cd src/utils/apolloClient && cat -n provider.tsx | head -20

Repository: datacite/akita

Length of output: 574


🏁 Script executed:

# Search for all calls to apolloClientBuilder throughout the codebase
rg -nP --type=ts --type=tsx 'apolloClientBuilder\s*\(' --no-heading -A 2

Repository: datacite/akita

Length of output: 85


🏁 Script executed:

# Search for apolloClientBuilder calls with adjusted file types
rg -nP --type ts -A 2 'apolloClientBuilder' --no-heading

Repository: datacite/akita

Length of output: 1278


🏁 Script executed:

# Also search in JS/JSX files just in case
rg -nP --type js -A 2 'apolloClientBuilder' --no-heading

Repository: datacite/akita

Length of output: 40


Type mismatch: provider.tsx passes synchronous function where async function is expected.

The getToken parameter in apolloClientBuilder (line 11) now requires () => Promise<string | null>, but provider.tsx (line 7) passes () => token which returns a synchronous string. This will cause a TypeScript type error.

The call in apolloClient.ts (line 10) correctly passes getAuthToken, an async function that matches the expected signature. However, the provider.tsx implementation must be updated to wrap the token in a Promise to maintain type compatibility.

🤖 Prompt for AI Agents
In src/utils/apolloClient/builder.ts around line 11, the builder currently
expects getToken: () => Promise<string | null> but provider.tsx passes a
synchronous function; update provider.tsx (around line 7) to return a Promise by
wrapping the existing synchronous token return in a Promise (e.g., return
Promise.resolve(token) or make the function async and return token) so its
signature matches () => Promise<string | null>, ensuring the types align with
apolloClientBuilder.

// needed for CORS, see https://www.apollographql.com/docs/react/networking/authentication/#cookie
const httpLink = createHttpLink({
uri: DATACITE_API_URL + '/graphql',
credentials: 'include'
})

const authLink = setContext((_, { headers }) => {
const authLink = setContext(async (_, { headers }) => {
// return the headers to the context so httpLink can read them
const token = getToken()
const token = await getToken()

return {
headers: {
Expand Down
4 changes: 2 additions & 2 deletions src/utils/apolloClient/provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ import React, { PropsWithChildren } from 'react'
import apolloClientBuilder from 'src/utils/apolloClient/builder'
import { ApolloNextAppProvider } from "@apollo/experimental-nextjs-app-support";

export default function ApolloProvider({ token, children }: PropsWithChildren<{ token: string }>) {
export default function ApolloProvider({ token, children }: PropsWithChildren<{ token: string | null }>) {
return (
<ApolloNextAppProvider makeClient={() => apolloClientBuilder(() => token)}>
<ApolloNextAppProvider makeClient={() => apolloClientBuilder(async () => token)}>
{children}
</ApolloNextAppProvider>
);
Expand Down
50 changes: 32 additions & 18 deletions src/utils/session.ts
Original file line number Diff line number Diff line change
@@ -1,32 +1,46 @@
import { useState, useEffect } from 'react'
import { Cookies } from 'react-cookie-consent'
import JsonWebToken from 'jsonwebtoken'
import { jwtVerify, importSPKI } from 'jose'
import { JWT_KEY } from 'src/data/constants'

export type User = {
uid: string,
name: string
} | null

export const session = () => {
// RSA public key
if (!JWT_KEY) return null
export const useSession = () => {
const [user, setUser] = useState<User>(null)
const [loading, setLoading] = useState(true)

const sessionCookie = Cookies.getJSON('_datacite')
const token = sessionCookie?.authenticated?.access_token
if (!token) return null
useEffect(() => {
const fetchUser = async () => {
// RSA public key
if (!JWT_KEY) {
setLoading(false)
return
}

let user: any = null
function setUser(error: any, payload: any) {
if (error) {
console.log('JWT verification error: ' + error.message)
return
}
const sessionCookie = Cookies.getJSON('_datacite')
const token = sessionCookie?.authenticated?.access_token
if (!token) {
setLoading(false)
return
}

user = payload
}
try {
const publicKey = await importSPKI(JWT_KEY, 'RS256')
const { payload } = await jwtVerify(token, publicKey)
setUser(payload as User)
} catch (error: any) {
console.log('JWT verification error: ' + error.message)
setUser(null)
Comment on lines +30 to +36
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Validate JWT payload structure before casting to User.

Line 33 casts the JWT payload to User without validation. If the JWT contains unexpected fields or is missing required fields (uid, name), this could cause runtime errors in components that depend on these properties.

Apply this diff to add validation:

       try {
         const publicKey = await importSPKI(JWT_KEY, 'RS256')
         const { payload } = await jwtVerify(token, publicKey)
-        setUser(payload as User)
+        // Validate payload structure
+        if (payload && typeof payload.uid === 'string' && typeof payload.name === 'string') {
+          setUser({ uid: payload.uid, name: payload.name })
+        } else {
+          console.error('Invalid JWT payload structure:', payload)
+          setUser(null)
+        }
       } catch (error: any) {
🤖 Prompt for AI Agents
In src/utils/session.ts around lines 30 to 36, the JWT payload is being cast
directly to User after verification without checking required fields; validate
the payload shape before calling setUser by confirming required properties
(e.g., uid and name) exist and have the expected types, and only then call
setUser(payload as User); if validation fails, log a descriptive message and
call setUser(null) to avoid runtime errors in components.

} finally {
setLoading(false)
}
}

// verify asymmetric token, using RSA with SHA-256 hash algorithm
JsonWebToken.verify(token, JWT_KEY, { algorithms: ['RS256'] }, setUser)
fetchUser()
}, [])

return user as User
return { user, loading }
}
Loading
Loading