diff --git a/packages/openneuro-app/src/scripts/routes.tsx b/packages/openneuro-app/src/scripts/routes.tsx index 4c1783a68a..971669d870 100644 --- a/packages/openneuro-app/src/scripts/routes.tsx +++ b/packages/openneuro-app/src/scripts/routes.tsx @@ -18,6 +18,9 @@ import { ImportDataset } from "./pages/import-dataset" import { DatasetMetadata } from "./pages/metadata/dataset-metadata" import { TermsPage } from "./pages/terms" import { UserQuery } from "./users/user-query" +import LoggedIn from "../scripts/authentication/logged-in" +import LoggedOut from "../scripts/authentication/logged-out" +import FourOThreePage from "./errors/403page" const AppRoutes: React.VoidFunctionComponent = () => ( @@ -34,7 +37,19 @@ const AppRoutes: React.VoidFunctionComponent = () => ( } /> } /> } /> - } /> + + + + + + + + + } + /> } diff --git a/packages/openneuro-app/src/scripts/users/__tests__/user-account-view.spec.tsx b/packages/openneuro-app/src/scripts/users/__tests__/user-account-view.spec.tsx index e373825558..c990e5c45f 100644 --- a/packages/openneuro-app/src/scripts/users/__tests__/user-account-view.spec.tsx +++ b/packages/openneuro-app/src/scripts/users/__tests__/user-account-view.spec.tsx @@ -1,4 +1,5 @@ import React from "react" +import { MockedProvider } from "@apollo/client/testing" import { fireEvent, render, @@ -7,8 +8,10 @@ import { within, } from "@testing-library/react" import { UserAccountView } from "../user-account-view" +import { GET_USER_BY_ORCID, UPDATE_USER } from "../user-query" const baseUser = { + id: "1", name: "John Doe", email: "johndoe@example.com", orcid: "0000-0001-2345-6789", @@ -18,61 +21,132 @@ const baseUser = { github: "johndoe", } +const mocks = [ + { + request: { + query: GET_USER_BY_ORCID, + variables: { userId: baseUser.id }, + }, + result: { + data: { + user: baseUser, + }, + }, + }, + { + request: { + query: UPDATE_USER, + variables: { + id: baseUser.id, + location: "Marin, CA", + links: ["https://newlink.com"], + institution: "New University", + }, + }, + result: { + data: { + updateUser: { + id: baseUser.id, + location: "Marin, CA", + links: ["https://newlink.com"], + institution: "New University", + }, + }, + }, + }, +] + describe("", () => { it("should render the user details correctly", () => { - render() - - // Check if user details are rendered + render( + + + , + ) expect(screen.getByText("Name:")).toBeInTheDocument() expect(screen.getByText("John Doe")).toBeInTheDocument() expect(screen.getByText("Email:")).toBeInTheDocument() expect(screen.getByText("johndoe@example.com")).toBeInTheDocument() expect(screen.getByText("ORCID:")).toBeInTheDocument() expect(screen.getByText("0000-0001-2345-6789")).toBeInTheDocument() + expect(screen.getByText("GitHub:")).toBeInTheDocument() expect(screen.getByText("johndoe")).toBeInTheDocument() }) - it("should render links with EditableContent", async () => { - render() - const institutionSection = within( - screen.getByText("Institution").closest(".user-meta-block"), + it("should render location with EditableContent", async () => { + render( + + + , ) + const locationSection = within(screen.getByTestId("location-section")) + expect(screen.getByText("Location")).toBeInTheDocument() + const editButton = locationSection.getByText("Edit") + fireEvent.click(editButton) + const textbox = locationSection.getByRole("textbox") + fireEvent.change(textbox, { target: { value: "Marin, CA" } }) + const saveButton = locationSection.getByText("Save") + fireEvent.click(saveButton) + await waitFor(() => { + expect(locationSection.getByText("Marin, CA")).toBeInTheDocument() + }) + }) + + it("should render institution with EditableContent", async () => { + render( + + + , + ) + const institutionSection = within(screen.getByTestId("institution-section")) expect(screen.getByText("Institution")).toBeInTheDocument() const editButton = institutionSection.getByText("Edit") fireEvent.click(editButton) const textbox = institutionSection.getByRole("textbox") fireEvent.change(textbox, { target: { value: "New University" } }) const saveButton = institutionSection.getByText("Save") - const closeButton = institutionSection.getByText("Close") fireEvent.click(saveButton) - fireEvent.click(closeButton) - // Add debug step - await waitFor(() => screen.debug()) - // Use a flexible matcher to check for text - await waitFor(() => + await waitFor(() => { expect(institutionSection.getByText("New University")).toBeInTheDocument() - ) + }) }) - it("should render location with EditableContent", async () => { - render() - const locationSection = within( - screen.getByText("Location").closest(".user-meta-block"), + it("should render links with EditableContent and validation", async () => { + render( + + + , ) - expect(screen.getByText("Location")).toBeInTheDocument() - const editButton = locationSection.getByText("Edit") + const linksSection = within(screen.getByTestId("links-section")) + expect(screen.getByText("Links")).toBeInTheDocument() + const editButton = linksSection.getByText("Edit") fireEvent.click(editButton) - const textbox = locationSection.getByRole("textbox") - fireEvent.change(textbox, { target: { value: "Marin, CA" } }) - const saveButton = locationSection.getByText("Save") - const closeButton = locationSection.getByText("Close") + const textbox = linksSection.getByRole("textbox") + fireEvent.change(textbox, { target: { value: "https://newlink.com" } }) + const saveButton = linksSection.getByText("Add") fireEvent.click(saveButton) - fireEvent.click(closeButton) - // Add debug step - await waitFor(() => screen.debug()) - // Use a flexible matcher to check for text - await waitFor(() => - expect(locationSection.getByText("Marin, CA")).toBeInTheDocument() + await waitFor(() => { + expect(linksSection.getByText("https://newlink.com")).toBeInTheDocument() + }) + }) + + it("should show an error message when invalid URL is entered in links section", async () => { + render( + + + , ) + const linksSection = within(screen.getByTestId("links-section")) + const editButton = linksSection.getByText("Edit") + fireEvent.click(editButton) + const textbox = linksSection.getByRole("textbox") + fireEvent.change(textbox, { target: { value: "invalid-url" } }) + const saveButton = linksSection.getByText("Add") + fireEvent.click(saveButton) + await waitFor(() => { + expect( + linksSection.getByText("Invalid URL format. Please use a valid link."), + ).toBeInTheDocument() + }) }) }) diff --git a/packages/openneuro-app/src/scripts/users/__tests__/user-routes.spec.tsx b/packages/openneuro-app/src/scripts/users/__tests__/user-routes.spec.tsx index a2fb9fed72..2e3bad3e43 100644 --- a/packages/openneuro-app/src/scripts/users/__tests__/user-routes.spec.tsx +++ b/packages/openneuro-app/src/scripts/users/__tests__/user-routes.spec.tsx @@ -1,8 +1,10 @@ import React from "react" import { cleanup, render, screen } from "@testing-library/react" import { MemoryRouter } from "react-router-dom" +import { MockedProvider } from "@apollo/client/testing" import { UserRoutes } from "../user-routes" import type { User } from "../user-routes" +import { UPDATE_USER } from "../user-query" const defaultUser: User = { id: "1", @@ -16,11 +18,40 @@ const defaultUser: User = { links: [], } +const mocks = [ + { + request: { + query: UPDATE_USER, + variables: { + id: "1", + name: "John Doe", + location: "Unknown", + github: "", + institution: "Unknown Institution", + email: "john.doe@example.com", + avatar: "https://dummyimage.com/200x200/000/fff", + orcid: "0000-0000-0000-0000", + links: [], + }, + }, + result: { + data: { + updateUser: { + id: "1", + name: "John Doe", + }, + }, + }, + }, +] + const renderWithRouter = (user: User, route: string, hasEdit: boolean) => { return render( - - - , + + + + + , ) } diff --git a/packages/openneuro-app/src/scripts/users/components/edit-list.tsx b/packages/openneuro-app/src/scripts/users/components/edit-list.tsx index c2526983a8..10341cc5b2 100644 --- a/packages/openneuro-app/src/scripts/users/components/edit-list.tsx +++ b/packages/openneuro-app/src/scripts/users/components/edit-list.tsx @@ -6,6 +6,8 @@ interface EditListProps { placeholder?: string elements?: string[] setElements: (elements: string[]) => void + validation?: RegExp + validationMessage?: string } /** @@ -13,32 +15,45 @@ interface EditListProps { * Allows adding and removing strings from a list. */ export const EditList: React.FC = ( - { placeholder = "Enter item", elements = [], setElements }, + { + placeholder = "Enter item", + elements = [], + setElements, + validation, + validationMessage, + }, ) => { const [newElement, setNewElement] = useState("") const [warnEmpty, setWarnEmpty] = useState(false) + const [warnValidation, setWarnValidation] = useState(null) - /** - * Remove an element from the list by index - * @param index - The index of the element to remove - */ const removeElement = (index: number): void => { setElements(elements.filter((_, i) => i !== index)) } - /** - * Add a new element to the list - */ + // Add a new element to the list const addElement = (): void => { if (!newElement.trim()) { setWarnEmpty(true) + setWarnValidation(null) + } else if (validation && !validation.test(newElement.trim())) { + setWarnValidation(validationMessage || "Invalid input format") + setWarnEmpty(false) } else { setElements([...elements, newElement.trim()]) setWarnEmpty(false) + setWarnValidation(null) setNewElement("") } } + // Handle Enter/Return key press to add element + const handleKeyDown = (e: React.KeyboardEvent): void => { + if (e.key === "Enter") { + addElement() + } + } + return (
@@ -48,6 +63,7 @@ export const EditList: React.FC = ( placeholder={placeholder} value={newElement} onChange={(e) => setNewElement(e.target.value)} + onKeyDown={handleKeyDown} />
- {warnEmpty && ( - The input cannot be empty + {/* Show empty value warning only if content was deleted */} + {warnEmpty && currentValue === "" && ( + {warnEmpty} + )} + {/* Show validation error */} + {warnValidation && ( + {warnValidation} )}
) diff --git a/packages/openneuro-app/src/scripts/users/components/editable-content.tsx b/packages/openneuro-app/src/scripts/users/components/editable-content.tsx index 00fd379e8f..68f98d6f5d 100644 --- a/packages/openneuro-app/src/scripts/users/components/editable-content.tsx +++ b/packages/openneuro-app/src/scripts/users/components/editable-content.tsx @@ -11,6 +11,9 @@ interface EditableContentProps { setRows: React.Dispatch> className: string heading: string + validation?: RegExp + validationMessage?: string + "data-testid"?: string } export const EditableContent: React.FC = ({ @@ -18,15 +21,30 @@ export const EditableContent: React.FC = ({ setRows, className, heading, + validation, + validationMessage, + "data-testid": testId, }) => { const [editing, setEditing] = useState(false) + const closeEditing = () => { + setEditing(false) + } + + // Function to handle validation of user input + const handleValidation = (value: string): boolean => { + if (validation && !validation.test(value)) { + return false + } + return true + } + return ( -
+

{heading}

{editing - ? setEditing(false)} /> + ? : setEditing(true)} />}
{editing @@ -40,15 +58,22 @@ export const EditableContent: React.FC = ({ setElements={setRows as React.Dispatch< React.SetStateAction >} + validation={validation} + validationMessage={validationMessage} /> ) : ( - >} + setValue={(newValue: string) => { + if (handleValidation(newValue)) { + setRows(newValue) + } + }} placeholder="Edit content" + closeEditing={closeEditing} + validation={validation} + validationMessage={validationMessage} /> )} diff --git a/packages/openneuro-app/src/scripts/users/user-account-view.tsx b/packages/openneuro-app/src/scripts/users/user-account-view.tsx index 92bd02a6c0..0a75727d60 100644 --- a/packages/openneuro-app/src/scripts/users/user-account-view.tsx +++ b/packages/openneuro-app/src/scripts/users/user-account-view.tsx @@ -1,6 +1,8 @@ import React, { useState } from "react" +import { useMutation } from "@apollo/client" import { EditableContent } from "./components/editable-content" import styles from "./scss/useraccountview.module.scss" +import { GET_USER_BY_ORCID, UPDATE_USER } from "./user-query" interface UserAccountViewProps { user: { @@ -20,6 +22,69 @@ export const UserAccountView: React.FC = ({ user }) => { const [userInstitution, setInstitution] = useState( user.institution || "", ) + const [updateUser] = useMutation(UPDATE_USER) + + const handleLinksChange = async (newLinks: string[]) => { + setLinks(newLinks) + try { + await updateUser({ + variables: { + id: user.orcid, + links: newLinks, + }, + refetchQueries: [ + { + query: GET_USER_BY_ORCID, + variables: { id: user.orcid }, + }, + ], + }) + } catch { + // Error handling can be implemented here if needed + } + } + + const handleLocationChange = async (newLocation: string) => { + setLocation(newLocation) + + try { + await updateUser({ + variables: { + id: user.orcid, + location: newLocation, + }, + refetchQueries: [ + { + query: GET_USER_BY_ORCID, + variables: { id: user.orcid }, + }, + ], + }) + } catch { + // Error handling can be implemented here if needed + } + } + + const handleInstitutionChange = async (newInstitution: string) => { + setInstitution(newInstitution) + + try { + await updateUser({ + variables: { + id: user.orcid, + institution: newInstitution, + }, + refetchQueries: [ + { + query: GET_USER_BY_ORCID, + variables: { id: user.orcid }, + }, + ], + }) + } catch { + // Error handling can be implemented here if needed + } + } return (
@@ -40,32 +105,37 @@ export const UserAccountView: React.FC = ({ user }) => { {user.github ? (
  • - github: + GitHub: {user.github}
  • ) - :
  • Connect your github
  • } + :
  • Connect your GitHub
  • } + - setLocation(newLocation)} + setRows={handleLocationChange} className="custom-class" heading="Location" + data-testid="location-section" /> - setInstitution(newInstitution)} + setRows={handleInstitutionChange} className="custom-class" heading="Institution" + data-testid="institution-section" />
    ) diff --git a/packages/openneuro-app/src/scripts/users/user-query.tsx b/packages/openneuro-app/src/scripts/users/user-query.tsx index e5d98b65a0..d7321d75aa 100644 --- a/packages/openneuro-app/src/scripts/users/user-query.tsx +++ b/packages/openneuro-app/src/scripts/users/user-query.tsx @@ -4,6 +4,9 @@ import { UserRoutes } from "./user-routes" import FourOFourPage from "../errors/404page" import { isValidOrcid } from "../utils/validationUtils" import { gql, useQuery } from "@apollo/client" +import { isAdmin } from "../authentication/admin-user" +import { useCookies } from "react-cookie" +import { getProfile } from "../authentication/profile" // GraphQL query to fetch user by ORCID export const GET_USER_BY_ORCID = gql` @@ -14,10 +17,24 @@ export const GET_USER_BY_ORCID = gql` orcid email avatar + location + institution + links } } ` +export const UPDATE_USER = gql` +mutation updateUser($id: ID!, $location: String, $links: [String], $institution: String) { + updateUser(id: $id, location: $location, links: $links, institution: $institution) { + id + location + links + institution + } +} +` + export interface User { id: string name: string @@ -38,6 +55,9 @@ export const UserQuery: React.FC = () => { skip: !isOrcidValid, }) + const [cookies] = useCookies() + const profile = getProfile(cookies) + if (!isOrcidValid) { return } @@ -48,8 +68,8 @@ export const UserQuery: React.FC = () => { return } - // Assuming 'hasEdit' is true for now (you can modify this based on your logic) - const hasEdit = true + // is admin or profile matches id from the user data being returned + const hasEdit = isAdmin || data.user.id !== profile.sub ? true : false // Render user data with UserRoutes return diff --git a/packages/openneuro-server/src/graphql/resolvers/mutation.ts b/packages/openneuro-server/src/graphql/resolvers/mutation.ts index 1cc46d5a38..7c3bf887d4 100644 --- a/packages/openneuro-server/src/graphql/resolvers/mutation.ts +++ b/packages/openneuro-server/src/graphql/resolvers/mutation.ts @@ -16,7 +16,7 @@ import { deprecateSnapshot, undoDeprecateSnapshot, } from "./snapshots.js" -import { removeUser, setAdmin, setBlocked } from "./user.js" +import { removeUser, setAdmin, setBlocked, updateUser } from "./user.js" import { updateSummary } from "./summary" import { revalidate, updateValidation } from "./validation.js" import { @@ -88,6 +88,7 @@ const Mutation = { deleteRelation, importRemoteDataset, finishImportRemoteDataset, + updateUser, } export default Mutation diff --git a/packages/openneuro-server/src/graphql/resolvers/user.ts b/packages/openneuro-server/src/graphql/resolvers/user.ts index cab75e6ba7..714e004b97 100644 --- a/packages/openneuro-server/src/graphql/resolvers/user.ts +++ b/packages/openneuro-server/src/graphql/resolvers/user.ts @@ -2,12 +2,11 @@ * User resolvers */ import User from "../../models/user" +function isValidOrcid(orcid: string): boolean { + return /^[0-9]{4}-[0-9]{4}-[0-9]{4}-[0-9]{3}[0-9X]$/.test(orcid || "") +} export const user = (obj, { id }) => { - function isValidOrcid(orcid: string): boolean { - return /^[0-9]{4}-[0-9]{4}-[0-9]{4}-[0-9]{3}[0-9X]$/.test(orcid || "") - } - if (isValidOrcid(id)) { return User.findOne({ $or: [{ "orcid": id }, { "providerId": id }], @@ -54,6 +53,36 @@ export const setBlocked = (obj, { id, blocked }, { userInfo }) => { } } +export const updateUser = async (obj, { id, location, institution, links }) => { + try { + let user // Declare user outside the if block + + if (isValidOrcid(id)) { + user = await User.findOne({ + $or: [{ "orcid": id }, { "providerId": id }], + }).exec() + } else { + user = await User.findOne({ "id": id }).exec() + } + + if (!user) { + throw new Error("User not found") + } + + // Update user fields (optional values based on provided inputs) + if (location !== undefined) user.location = location + if (institution !== undefined) user.institution = institution + if (links !== undefined) user.links = links + + // Save the updated user + await user.save() + + return user // Return the updated user object + } catch (err) { + throw new Error("Failed to update user: " + err.message) + } +} + const UserResolvers = { id: (obj) => obj.id, provider: (obj) => obj.provider, @@ -66,6 +95,9 @@ const UserResolvers = { name: (obj) => obj.name, admin: (obj) => obj.admin, blocked: (obj) => obj.blocked, + location: (obj) => obj.location, + institution: (obj) => obj.institution, + links: (obj) => obj.links, } export default UserResolvers diff --git a/packages/openneuro-server/src/graphql/schema.ts b/packages/openneuro-server/src/graphql/schema.ts index 9aa6b14794..c626ffb0a4 100644 --- a/packages/openneuro-server/src/graphql/schema.ts +++ b/packages/openneuro-server/src/graphql/schema.ts @@ -132,6 +132,8 @@ export const typeDefs = ` setAdmin(id: ID!, admin: Boolean!): User # Sets a users admin status setBlocked(id: ID!, blocked: Boolean!): User + # Mutation for updating user data + updateUser(id: ID!, location: String, institution: String, links: [String]): User # Tracks a view or download for a dataset trackAnalytics(datasetId: ID!, tag: String, type: AnalyticTypes): Boolean # Follow dataset @@ -322,6 +324,10 @@ export const typeDefs = ` name: String admin: Boolean blocked: Boolean + location: String + institution: String + github: String + links: [String] } # Which provider a user login comes from @@ -832,5 +838,4 @@ schemaComposer.addTypeDefs(typeDefs) schemaComposer.addResolveMethods(resolvers) schemaComposer.Query.addFields(datasetSearch) schemaComposer.Query.addFields(advancedDatasetSearch) - export default schemaComposer.buildSchema() diff --git a/packages/openneuro-server/src/models/user.ts b/packages/openneuro-server/src/models/user.ts index 4738a066e9..9cf1fda5e2 100644 --- a/packages/openneuro-server/src/models/user.ts +++ b/packages/openneuro-server/src/models/user.ts @@ -15,6 +15,10 @@ export interface UserDocument extends Document { blocked: boolean created: Date lastSeen: Date + location: string + institution: string + github: string + links: string[] } const userSchema = new Schema({ @@ -29,10 +33,14 @@ const userSchema = new Schema({ blocked: { type: Boolean, default: false }, created: { type: Date, default: Date.now }, lastSeen: { type: Date, default: Date.now }, + location: { type: String, default: "" }, + institution: { type: String, default: "" }, + github: { type: String, default: "" }, + links: { type: [String], default: [] }, }) userSchema.index({ id: 1, provider: 1 }, { unique: true }) -// Allow case insensitive email queries +// Allow case-insensitive email queries userSchema.index( { email: 1 }, {