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
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import jwtDecode from "jwt-decode"
export interface OpenNeuroTokenProfile {
sub: string
admin: boolean
email?: string
iat: number
exp: number
scopes?: string[]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { MockedProvider } from "@apollo/client/testing"
import { ContributorsListDisplay } from "../contributors-list"
import { GET_USERS } from "../../queries/users"
import type { Contributor } from "../../types/datacite"
import * as profileModule from "../../authentication/profile"
import { vi } from "vitest"

describe("ContributorsListDisplay", () => {
const baseContributors: Contributor[] = [
Expand Down Expand Up @@ -34,13 +36,15 @@ describe("ContributorsListDisplay", () => {
result: {
data: {
users: {
users: [{
id: "1",
name: "Jane Doe",
avatar: "",
orcid: "",
__typename: "User",
}],
users: [
{
id: "1",
name: "Jane Doe",
avatar: "",
orcid: "",
__typename: "User",
},
],
totalCount: 1,
__typename: "UsersResponse",
},
Expand All @@ -55,13 +59,15 @@ describe("ContributorsListDisplay", () => {
result: {
data: {
users: {
users: [{
id: "2",
name: "John Smith",
avatar: "",
orcid: "",
__typename: "User",
}],
users: [
{
id: "2",
name: "John Smith",
avatar: "",
orcid: "",
__typename: "User",
},
],
totalCount: 1,
__typename: "UsersResponse",
},
Expand All @@ -81,6 +87,17 @@ describe("ContributorsListDisplay", () => {
},
]

// Mock getProfile so hasEdit === true
beforeEach(() => {
vi.spyOn(profileModule, "getProfile").mockReturnValue({
sub: "123",
email: "[email protected]",
admin: false,
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + 3600,
})
})

const renderComponent = (props = {}) =>
render(
<MockedProvider mocks={mocks} addTypename={false}>
Expand All @@ -95,14 +112,12 @@ describe("ContributorsListDisplay", () => {

it("renders contributors list", async () => {
renderComponent()
// Match Jane Doe
expect(
await screen.findByText((content) =>
["Jane", "Doe"].every((part) => content.includes(part))
),
).toBeInTheDocument()

// Match John Smith
expect(
screen.getByText((content) =>
["John", "Smith"].every((part) => content.includes(part))
Expand Down Expand Up @@ -136,9 +151,10 @@ describe("ContributorsListDisplay", () => {
const addButton = await screen.findByText("Add Contributor")
fireEvent.click(addButton)

expect(
await screen.findAllByPlaceholderText("Type name or ORCID (or add new)"),
).toHaveLength(3)
const nameInputs = await screen.findAllByPlaceholderText(
"Type name or ORCID (or add new)",
)
expect(nameInputs).toHaveLength(3)
})

it("shows an error when trying to submit with empty name", async () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import React, { useEffect, useState } from "react"
import { toast } from "react-toastify"
import { gql, useMutation } from "@apollo/client"
import * as Sentry from "@sentry/react"
import type { Contributor } from "../types/datacite"
import { SingleContributorDisplay } from "./contributor"
import ToastContent from "../common/partials/toast-content"
import { Loading } from "../components/loading/Loading"
import { ContributorFormRow } from "./contributor-form-row"
import { cloneContributor } from "./contributor-utils"
import { CREATE_CONTRIBUTOR_CITATION_EVENT } from "../queries/datasetEvents"
import { useCookies } from "react-cookie"
import { getProfile } from "../authentication/profile"

interface ContributorsListDisplayProps {
contributors: Contributor[] | null | undefined
Expand Down Expand Up @@ -60,8 +64,37 @@ export const ContributorsListDisplay: React.FC<ContributorsListDisplayProps> = (
const [editingContributors, setEditingContributors] = useState<
Contributor[]
>(contributors?.map((c) => ({ ...c, order: c.order ?? 0 })) || [])

const [errors, setErrors] = useState<Record<number, string>>({})
const [cookies] = useCookies()
const profile = getProfile(cookies)

const hasEdit = Boolean(profile?.email)

const handleEditClick = () => {
if (!hasEdit) {
toast.error(
<ToastContent
title="Connect an Email"
body={
<>
Connect an email to make contributions to OpenNeuro. See our{" "}
<a
href="https://docs.openneuro.org/orcid.html#enabling-trusted-access-to-emails"
target="_blank"
rel="noopener noreferrer"
style={{ color: "#007bff", textDecoration: "underline" }}
>
ORCID documentation
</a>{" "}
for detailed instructions.
</>
}
/>,
)
return
}
setIsEditing(true)
}

useEffect(() => {
if (contributors) {
Expand Down Expand Up @@ -106,6 +139,7 @@ export const ContributorsListDisplay: React.FC<ContributorsListDisplayProps> = (
},
},
)

const [createContributorCitationEvent] = useMutation(
CREATE_CONTRIBUTOR_CITATION_EVENT,
{
Expand All @@ -114,6 +148,7 @@ export const ContributorsListDisplay: React.FC<ContributorsListDisplayProps> = (
},
},
)

const handleChange = (
index: number,
field: keyof Contributor,
Expand Down Expand Up @@ -276,7 +311,7 @@ export const ContributorsListDisplay: React.FC<ContributorsListDisplayProps> = (
{editable && !isEditing && (
<button
className="on-button on-button--small on-button--primary"
onClick={() => setIsEditing(true)}
onClick={handleEditClick}
style={{
width: 60,
top: -5,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ export const DatasetEventItem: React.FC<DatasetEventItemProps> = ({
} else if (event.event.type === "contributorCitation") {
return (
<>
{event.event.resolutionStatus} contributorship
Contributor added to authors
</>
)
} else {
Expand Down
2 changes: 1 addition & 1 deletion packages/openneuro-app/src/scripts/types/event-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ export const mapRawEventToMappedNotification = (
needsReview = approval === "pending"
break
case "contributorCitation":
title = "is requesting"
title = "added"
adminUser = user
// fallback to rawNotification.user if target is missing (but not the admin)
targetUser = target ||
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export const NotificationHeader: React.FC<NotificationHeaderProps> = ({
<>
<Username user={adminUser} /> {title}{" "}
<Username user={targetUser} />
{" be added to "}
{" as a contributor to "}
</>
)
: (
Expand All @@ -63,7 +63,7 @@ export const NotificationHeader: React.FC<NotificationHeaderProps> = ({
<a href={datasetLink} className={styles.titlelink}>
{datasetId}
</a>{" "}
{resStatus}
{type != "contributorCitation" ? resStatus : ""}
</span>
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,15 @@ export const NotificationBodyContent: React.FC<NotificationBodyContentProps> = (
)
}

if (approval === "accepted" && isContributorCitation) {
return (
<div>
The following user has been added as a contributor to {datasetId}.
{renderContribInfo()}
</div>
)
}

if (approval === "accepted" && isContributorRequest) {
return (
<div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,9 +133,24 @@ describe("user resolvers", () => {
})

describe("users()", () => {
it("rejects data for non-admin context", async () => {
await expect(users(null, {}, nonAdminContext)).rejects.toThrow(
"You must be a site admin to retrieve users",
it("returns sanitized data for non-admin context", async () => {
const result = await users(null, {}, nonAdminContext)

// Should return all non-migrated users (same as admin)
expect(result.users.length).toBe(6)
expect(result.totalCount).toBe(6)

// Sensitive fields should be hidden
result.users.forEach((u) => {
expect(u.email).toBeNull()
expect(u.blocked).toBeNull()
expect(u.admin).toBeNull()
})

// Non-sensitive fields should still be populated
const userIds = result.users.map((u) => u.id)
expect(userIds).toEqual(
expect.arrayContaining(["u1", "u2", "u3", "u4", "u6", "u7"]),
)
})

Expand Down
78 changes: 39 additions & 39 deletions packages/openneuro-server/src/graphql/resolvers/datasetEvents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,8 @@ export async function updateEventStatus(obj, { eventId, status }, { user }) {

/**
* Create a 'contributor citation' event
* Immediately adds the contributor to datacite.yml
* Automatically sets the resolutionStatus to 'accepted'
*/
export async function createContributorCitationEvent(
obj,
Expand All @@ -373,6 +375,39 @@ export async function createContributorCitationEvent(
contributorType: contributorData.contributorType || "Researcher",
}

// --- Immediately add to datacite.yml ---
const existingDatacite = await getDataciteYml(datasetId)
const existingContributors = existingDatacite?.data.attributes.contributors ||
[]

const mappedExisting: Contributor[] = existingContributors.map((
c,
index,
) => ({
name: c.name || "Unknown Contributor",
givenName: c.givenName || "",
familyName: c.familyName || "",
orcid: c.nameIdentifiers?.[0]?.nameIdentifier,
contributorType: c.contributorType || "Researcher",
order: index + 1,
}))

const newContributor: Contributor = {
name: finalContributorData.name || "Unknown Contributor",
givenName: finalContributorData.givenName || "",
familyName: finalContributorData.familyName || "",
orcid: finalContributorData.orcid,
contributorType: finalContributorData.contributorType || "Researcher",
order: mappedExisting.length + 1,
}

await updateContributorsUtil(
datasetId,
[...mappedExisting, newContributor],
user,
)

// --- Log dataset event ---
const event = new DatasetEvent({
datasetId,
userId: user,
Expand All @@ -382,19 +417,21 @@ export async function createContributorCitationEvent(
addedBy: user,
targetUserId,
contributorData: finalContributorData,
resolutionStatus: "pending",
resolutionStatus: "accepted", // auto-approved
},
success: true,
note: `User ${user} added a contributor citation for user ${targetUserId}.`,
})

await event.save()
await event.populate("user")

return event
}

/**
* Process a contributor citation (accept or deny)
* No longer updates datacite.yml — only logs a response event
*/
export async function processContributorCitation(
obj,
Expand Down Expand Up @@ -432,6 +469,7 @@ export async function processContributorCitation(
citationEvent.event.resolutionStatus = status
await citationEvent.save()

// --- Only log response event ---
const responseEvent = new DatasetEvent({
datasetId: citationEvent.datasetId,
userId: user,
Expand All @@ -452,43 +490,5 @@ export async function processContributorCitation(
await responseEvent.save()
await responseEvent.populate("user")

if (status === "accepted") {
const { contributorData } = citationEvent.event
if (!contributorData) {
throw new Error("Contributor data missing in citation event.")
}

const existingDatacite = await getDataciteYml(citationEvent.datasetId)
const existingContributors =
existingDatacite?.data.attributes.contributors || []

const mappedExisting: Contributor[] = existingContributors.map((
c,
index,
) => ({
name: c.name || "Unknown Contributor",
givenName: c.givenName || "",
familyName: c.familyName || "",
orcid: c.nameIdentifiers?.[0]?.nameIdentifier,
contributorType: c.contributorType || "Researcher",
order: index + 1,
}))

const newContributor: Contributor = {
name: contributorData.name || "Unknown Contributor",
givenName: contributorData.givenName || "",
familyName: contributorData.familyName || "",
orcid: contributorData.orcid,
contributorType: contributorData.contributorType || "Researcher",
order: mappedExisting.length + 1,
}

await updateContributorsUtil(
citationEvent.datasetId,
[...mappedExisting, newContributor],
user,
)
}

return responseEvent
}
Loading