@openneuro/app 4.34.2 → 4.35.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/package.json +3 -3
  2. package/src/scripts/authentication/profile.ts +3 -5
  3. package/src/scripts/common/containers/header.tsx +1 -2
  4. package/src/scripts/common/partials/freshdesk-widget.jsx +5 -1
  5. package/src/scripts/components/header/Header.tsx +4 -10
  6. package/src/scripts/components/header/LandingExpandedHeader.tsx +11 -9
  7. package/src/scripts/components/header/header.scss +4 -2
  8. package/src/scripts/components/logo/Logo.tsx +1 -1
  9. package/src/scripts/components/modal/UserLoginModal.tsx +12 -11
  10. package/src/scripts/components/modal/__tests__/UserLoginModal.spec.tsx +1 -1
  11. package/src/scripts/components/page/Page.tsx +1 -2
  12. package/src/scripts/components/search-page/SearchResultItem.tsx +14 -13
  13. package/src/scripts/components/search-page/SearchResultsList.tsx +0 -19
  14. package/src/scripts/dataset/draft-container.tsx +0 -1
  15. package/src/scripts/dataset/snapshot-container.tsx +13 -11
  16. package/src/scripts/errors/freshdesk-widget.jsx +5 -1
  17. package/src/scripts/index.tsx +15 -2
  18. package/src/scripts/pages/__tests__/orcid-link.spec.tsx +13 -0
  19. package/src/scripts/pages/orcid-link.tsx +60 -0
  20. package/src/scripts/queries/user.ts +71 -0
  21. package/src/scripts/routes.tsx +2 -0
  22. package/src/scripts/scss/variables.scss +13 -9
  23. package/src/scripts/search/search-container.tsx +0 -9
  24. package/src/scripts/types/user-types.ts +2 -0
  25. package/src/scripts/users/__tests__/user-account-view.spec.tsx +36 -22
  26. package/src/scripts/users/__tests__/user-query.spec.tsx +3 -3
  27. package/src/scripts/users/__tests__/user-routes.spec.tsx +28 -11
  28. package/src/scripts/users/__tests__/user-tabs.spec.tsx +12 -9
  29. package/src/scripts/users/scss/user-menu.scss +133 -0
  30. package/src/scripts/users/scss/usernotifications.module.scss +1 -1
  31. package/src/scripts/users/user-account-view.tsx +35 -21
  32. package/src/scripts/users/user-container.tsx +2 -1
  33. package/src/scripts/users/user-menu.tsx +114 -0
  34. package/src/scripts/users/user-notification-accordion.tsx +2 -2
  35. package/src/scripts/users/user-query.tsx +6 -36
  36. package/src/scripts/users/user-routes.tsx +7 -5
  37. package/src/scripts/users/user-tabs.tsx +4 -3
  38. package/src/scripts/validation/__tests__/__snapshots__/validation-issues.spec.tsx.snap +10 -1
  39. package/src/scripts/validation/validation-issues.tsx +7 -3
  40. package/src/scripts/components/user/UserMenu.tsx +0 -72
  41. package/src/scripts/components/user/user-menu.scss +0 -88
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openneuro/app",
3
- "version": "4.34.2",
3
+ "version": "4.35.0-alpha.0",
4
4
  "description": "React JS web frontend for the OpenNeuro platform.",
5
5
  "license": "MIT",
6
6
  "main": "public/client.js",
@@ -69,11 +69,11 @@
69
69
  "sass": "^1.32.8",
70
70
  "stream-browserify": "^3.0.0",
71
71
  "typescript": "5.6.3",
72
- "vite": "5.4.18",
72
+ "vite": "5.4.19",
73
73
  "vitest": "2.1.2"
74
74
  },
75
75
  "publishConfig": {
76
76
  "access": "public"
77
77
  },
78
- "gitHead": "6420eabb1a1b5068990523eb57991597faffb73e"
78
+ "gitHead": "aef6b8ec1097cd7cd19ca9de82e17d8dfa87be71"
79
79
  }
@@ -2,12 +2,10 @@ import jwtDecode from "jwt-decode"
2
2
 
3
3
  export interface OpenNeuroTokenProfile {
4
4
  sub: string
5
- email: string
6
- provider: string
7
- name: string
8
5
  admin: boolean
9
6
  iat: number
10
7
  exp: number
8
+ scopes?: string[]
11
9
  }
12
10
 
13
11
  /**
@@ -21,9 +19,9 @@ export const parseJwt = jwtDecode
21
19
  */
22
20
  export function getProfile(cookies): OpenNeuroTokenProfile {
23
21
  const accessToken = cookies["accessToken"]
24
- return accessToken ? parseJwt(accessToken) : null
22
+ if (!accessToken) return null
23
+ return parseJwt(accessToken) as OpenNeuroTokenProfile
25
24
  }
26
-
27
25
  /**
28
26
  * Return profile if token is not expired.
29
27
  * @param {*} cookies
@@ -105,9 +105,8 @@ export const HeaderContainer: FC = () => {
105
105
  </UploaderContext.Consumer>
106
106
  )}
107
107
  renderOnFreshDeskWidget={() => <FreshdeskWidget />}
108
- renderOnExpanded={(profile) => (
108
+ renderOnExpanded={() => (
109
109
  <LandingExpandedHeader
110
- user={profile}
111
110
  loginUrls={loginUrls}
112
111
  renderAggregateCounts={(modality: string) => (
113
112
  <AggregateCountsContainer modality={modality} />
@@ -3,6 +3,7 @@ import PropTypes from "prop-types"
3
3
  import { useCookies } from "react-cookie"
4
4
  import { getProfile } from "../../authentication/profile"
5
5
  import { config } from "../../config"
6
+ import { useUser } from "../../queries/user"
6
7
 
7
8
  const buildCustomQuery = (customText, prepopulatedFields) => {
8
9
  const customizerQueries = [
@@ -31,11 +32,14 @@ function FreshdeskWidget({ subject, error, sentryId, description }) {
31
32
  screenshot: "No",
32
33
  captcha: "yes",
33
34
  }
35
+ const { user } = useUser()
36
+
34
37
  const prepopulatedFields = {
35
- requester: profile && profile.email,
38
+ requester: profile && user?.email,
36
39
  subject,
37
40
  description: joinedDescription,
38
41
  }
42
+
39
43
  return (
40
44
  <>
41
45
  <script
@@ -3,16 +3,12 @@ import { NavLink } from "react-router-dom"
3
3
  import { Button } from "../button/Button"
4
4
  import { Logo } from "../logo/Logo"
5
5
  import { Modal } from "../modal/Modal"
6
- import { UserMenu } from "../user/UserMenu"
6
+ import { UserMenu } from "../../users/user-menu"
7
+ import type { OpenNeuroTokenProfile } from "../../authentication/profile"
7
8
  import "./header.scss"
8
9
 
9
10
  export interface HeaderProps {
10
- profile?: {
11
- name: string
12
- admin: boolean
13
- email: string
14
- provider: string
15
- }
11
+ profile: OpenNeuroTokenProfile
16
12
  expanded?: boolean
17
13
  isOpenSupport: boolean
18
14
  toggleLoginModal: (
@@ -39,7 +35,6 @@ export const Header = ({
39
35
  renderUploader,
40
36
  }: HeaderProps) => {
41
37
  const [isOpen, setOpen] = React.useState(false)
42
-
43
38
  return (
44
39
  <>
45
40
  <header>
@@ -109,7 +104,6 @@ export const Header = ({
109
104
  ? (
110
105
  <div className="header-account-btn">
111
106
  <UserMenu
112
- profile={profile}
113
107
  signOutAndRedirect={signOutAndRedirect}
114
108
  />
115
109
  </div>
@@ -120,7 +114,7 @@ export const Header = ({
120
114
  navbar
121
115
  onClick={toggleLoginModal}
122
116
  label="Sign in"
123
- size="large"
117
+ size="small"
124
118
  />
125
119
  </>
126
120
  )}
@@ -6,11 +6,12 @@ import { ModalityCube } from "../modality-cube/ModalityCube"
6
6
  import { frontPage } from "../../common/content/front-page-content"
7
7
  import { cubeData } from "../../common/content/modality-cube-content"
8
8
  import orcidIcon from "../../../assets/orcid_24x24.png"
9
+ import { loginCheck } from "../../authentication/loginCheck"
10
+ import { useCookies } from "react-cookie"
9
11
 
10
12
  import "./header.scss"
11
13
 
12
14
  export interface LandingExpandedHeaderProps {
13
- user?: object
14
15
  loginUrls?: Record<string, string>
15
16
  renderAggregateCounts?: (modality?: string) => React.ReactNode
16
17
  renderFacetSelect: () => React.ReactNode
@@ -19,7 +20,6 @@ export interface LandingExpandedHeaderProps {
19
20
  }
20
21
 
21
22
  export const LandingExpandedHeader: React.FC<LandingExpandedHeaderProps> = ({
22
- user,
23
23
  loginUrls,
24
24
  renderAggregateCounts,
25
25
  renderFacetSelect,
@@ -29,6 +29,7 @@ export const LandingExpandedHeader: React.FC<LandingExpandedHeaderProps> = ({
29
29
  const aggregateCounts = (modality: string): React.ReactNode =>
30
30
  renderAggregateCounts ? renderAggregateCounts(modality) : null
31
31
  const navigate = useNavigate()
32
+ const [cookies] = useCookies()
32
33
  const hexGrid = (
33
34
  <ul id="hexGrid">
34
35
  {cubeData.map((item, index) => (
@@ -47,6 +48,7 @@ export const LandingExpandedHeader: React.FC<LandingExpandedHeaderProps> = ({
47
48
  ))}
48
49
  </ul>
49
50
  )
51
+ const isLoggedIn = loginCheck(cookies)
50
52
 
51
53
  return (
52
54
  <div className="expaned-header" style={{ minHeight: "720px" }}>
@@ -72,28 +74,28 @@ export const LandingExpandedHeader: React.FC<LandingExpandedHeaderProps> = ({
72
74
  </div>
73
75
  </div>
74
76
 
75
- {!user
77
+ {!isLoggedIn
76
78
  ? (
77
79
  <div className="grid grid-start hero-signin">
78
80
  <div className=" hero-sigin-label">
79
81
  <h3>SIGN IN</h3>
80
82
  </div>
81
83
  <div>
82
- <a href={loginUrls.google}>
84
+ <a href={loginUrls.orcid}>
83
85
  <Button
84
- label="Google"
86
+ label="ORCID"
85
87
  color="#fff"
86
- icon="fab fa-google"
88
+ imgSrc={orcidIcon}
87
89
  iconSize="23px"
88
90
  />
89
91
  </a>
90
92
  </div>
91
93
  <div>
92
- <a href={loginUrls.orcid}>
94
+ <a href={loginUrls.google}>
93
95
  <Button
94
- label="ORCID"
96
+ label="Google"
95
97
  color="#fff"
96
- imgSrc={orcidIcon}
98
+ icon="fab fa-google"
97
99
  iconSize="23px"
98
100
  />
99
101
  </a>
@@ -34,6 +34,8 @@ header {
34
34
  }
35
35
 
36
36
  .navbar-navigation {
37
+ margin-left: auto;
38
+ margin-right: 20px;
37
39
  @media (max-width: 800px) {
38
40
  margin-top: 20px;
39
41
  margin-left: auto;
@@ -133,6 +135,7 @@ header {
133
135
  right: 8px;
134
136
  top: 8px;
135
137
  }
138
+
136
139
  &.nav-open {
137
140
  .navbar-navigation ul {
138
141
  transform: translateY(0);
@@ -200,8 +203,7 @@ header {
200
203
  align-items: center;
201
204
  }
202
205
  > div {
203
- flex-basis: 50%;
204
- max-width: 50%;
206
+ flex-basis: 100%;
205
207
  margin: 0 10px 0 0;
206
208
  &:last-child {
207
209
  margin: 0 0 0 10px;
@@ -13,7 +13,7 @@ export interface LogoProps {
13
13
 
14
14
  export const Logo: React.FC<LogoProps> = ({
15
15
  dark = true,
16
- width = "300px",
16
+ width = "225px",
17
17
  horizontal = true,
18
18
  ...props
19
19
  }) => {
@@ -29,17 +29,6 @@ export const UserLoginModal = ({
29
29
  <h2>Sign in</h2>
30
30
  </div>
31
31
  <div className="sign-in-modal-content">
32
- <div>
33
- <a href={loginUrls.google + `?redirectPath=${btoa(redirectPath)}`}>
34
- <Button
35
- className="login-button"
36
- primary
37
- label="Google"
38
- icon="fab fa-google"
39
- iconSize="23px"
40
- />
41
- </a>
42
- </div>
43
32
  <div>
44
33
  <a href={loginUrls.orcid + `?redirectPath=${btoa(redirectPath)}`}>
45
34
  <Button
@@ -66,6 +55,18 @@ export const UserLoginModal = ({
66
55
  }
67
56
  />
68
57
  </AccordionWrap>
58
+ <div>
59
+ <a
60
+ href={loginUrls.google + `?redirectPath=${btoa(redirectPath)}`}
61
+ >
62
+ <Button
63
+ className="login-button"
64
+ label="Migrate Google to ORCID"
65
+ icon="fab fa-google"
66
+ iconSize="23px"
67
+ />
68
+ </a>
69
+ </div>
69
70
  </div>
70
71
  </div>
71
72
  </Modal>
@@ -31,7 +31,7 @@ describe("UserLoginModal component", () => {
31
31
  </MemoryRouter>,
32
32
  )
33
33
  expect(
34
- screen.getByRole("link", { name: /orcid/i }).getAttribute("href"),
34
+ screen.getByRole("link", { name: "ORCID" }).getAttribute("href"),
35
35
  ).toBe("https://openneuro.org/crn/auth/orcid?redirectPath=L2ltcG9ydA==")
36
36
  })
37
37
  })
@@ -32,9 +32,8 @@ export const Page = ({ children, headerArgs, className }: PageProps) => {
32
32
  navigateToNewSearch={() => console.log("go to /search")}
33
33
  renderUploader={() => <li>Upload</li>}
34
34
  renderOnFreshDeskWidget={() => <>This is a freshdesk widget</>}
35
- renderOnExpanded={(profile) => (
35
+ renderOnExpanded={() => (
36
36
  <LandingExpandedHeader
37
- user={profile}
38
37
  renderFacetSelect={() => <>front facet example</>}
39
38
  renderSearchInput={() => (
40
39
  <Input
@@ -3,14 +3,15 @@ import bytes from "bytes"
3
3
  import parseISO from "date-fns/parseISO"
4
4
  import formatDistanceToNow from "date-fns/formatDistanceToNow"
5
5
  import { Link } from "react-router-dom"
6
-
7
6
  import { Tooltip } from "../../components/tooltip/Tooltip"
8
7
  import { Icon } from "../../components/icon/Icon"
9
-
8
+ import { useCookies } from "react-cookie"
9
+ import { getProfile } from "../../authentication/profile"
10
+ import { useUser } from "../../queries/user"
10
11
  import "./search-result.scss"
11
12
  import activityPulseIcon from "../../../assets/activity-icon.png"
12
13
  import { ModalityLabel } from "../../components/formatting/modality-label"
13
-
14
+ import { hasEditPermissions } from "../../authentication/profile"
14
15
  /**
15
16
  * Return an equivalent to moment(date).format('L') without moment
16
17
  * @param {*} dateObject
@@ -110,20 +111,20 @@ export interface SearchResultItemProps {
110
111
  },
111
112
  ]
112
113
  }
113
- /* eslint-disable-next-line @typescript-eslint/no-explicit-any */
114
- profile: Record<string, any> // TODO - Use the actual user type here
115
114
  datasetTypeSelected?: string
116
- hasEditPermissions: (permissions: object, userId: string) => boolean
117
115
  }
118
116
 
119
117
  export const SearchResultItem = ({
120
118
  node,
121
- profile,
122
119
  datasetTypeSelected,
123
- hasEditPermissions,
124
120
  }: SearchResultItemProps) => {
125
- const isAdmin = profile?.admin
126
- const hasEdit = hasEditPermissions(node.permissions, profile?.sub) || isAdmin
121
+ const { user } = useUser()
122
+ const [cookies] = useCookies()
123
+ const profile = getProfile(cookies)
124
+ const profileSub = profile?.sub
125
+
126
+ const isAdmin = user?.admin
127
+ const hasEdit = hasEditPermissions(node.permissions, profileSub) || isAdmin
127
128
 
128
129
  const heading = node.latestSnapshot.description?.Name
129
130
  const summary = node.latestSnapshot?.summary
@@ -209,7 +210,7 @@ export const SearchResultItem = ({
209
210
  const uploader = (
210
211
  <div className="uploader">
211
212
  <span>Uploaded by:</span>
212
- {node.uploader.name} on {dateAdded} - {dateAddedDifference} ago
213
+ {node.uploader?.name} on {dateAdded} - {dateAddedDifference} ago
213
214
  </div>
214
215
  )
215
216
  const downloads = node.analytics.downloads
@@ -318,11 +319,11 @@ export const SearchResultItem = ({
318
319
  // Test if there's any schema validator errors
319
320
  invalid = node.latestSnapshot.validation?.errors > 0
320
321
  }
321
- const shared = !node.public && node.uploader.id !== profile.sub
322
+ const shared = !node.public && node.uploader?.id !== profileSub
322
323
 
323
324
  const MyDatasetsPage = datasetTypeSelected === "My Datasets"
324
325
  const datasetPerms = node.permissions.userPermissions.map((item) => {
325
- if (item.user.id === profile?.sub && item.access !== null) {
326
+ if (item.user.id === profileSub && item.access !== null) {
326
327
  if (item.access === "ro") {
327
328
  return "Read Only"
328
329
  } else if (item.access === "rw") {
@@ -1,32 +1,15 @@
1
1
  import React from "react"
2
-
3
2
  import { SearchResultItem } from "./SearchResultItem"
4
-
5
3
  import "./search-page.scss"
6
4
 
7
- // TODO - unify this type with the one in the app package
8
- export interface OpenNeuroTokenProfile {
9
- sub: string
10
- email: string
11
- provider: string
12
- name: string
13
- admin: boolean
14
- iat: number
15
- exp: number
16
- }
17
-
18
5
  export interface SearchResultsListProps {
19
6
  items
20
- profile?: OpenNeuroTokenProfile
21
7
  datasetTypeSelected: string
22
- hasEditPermissions: (permissions: object, userId: string) => boolean
23
8
  }
24
9
 
25
10
  export const SearchResultsList = ({
26
11
  items,
27
- profile,
28
12
  datasetTypeSelected,
29
- hasEditPermissions,
30
13
  }: SearchResultsListProps) => {
31
14
  return (
32
15
  <div className="search-results">
@@ -36,8 +19,6 @@ export const SearchResultsList = ({
36
19
  <SearchResultItem
37
20
  node={data.node}
38
21
  key={data.node.id}
39
- profile={profile}
40
- hasEditPermissions={hasEditPermissions}
41
22
  datasetTypeSelected={datasetTypeSelected}
42
23
  />
43
24
  )
@@ -50,7 +50,6 @@ const DraftContainer: React.FC<DraftContainerProps> = ({ dataset }) => {
50
50
  const activeDataset = snapshotVersion(location) || "draft"
51
51
 
52
52
  const [selectedVersion, setSelectedVersion] = React.useState(activeDataset)
53
-
54
53
  const summary = dataset.draft.summary
55
54
  const description = dataset.draft.description
56
55
  const datasetId = dataset.id
@@ -80,7 +80,7 @@ export const SnapshotContainer: React.FC<SnapshotContainerProps> = ({
80
80
  dataset.snapshots[dataset.snapshots.length - 1].hexsha
81
81
  const modality: string = summary?.modalities[0] || ""
82
82
  const hasDerivatives = dataset?.derivatives.length > 0
83
-
83
+ const isAnonymousReviewer = profile?.scopes?.includes("dataset:reviewer")
84
84
  return (
85
85
  <>
86
86
  <Helmet>
@@ -263,16 +263,18 @@ export const SnapshotContainer: React.FC<SnapshotContainerProps> = ({
263
263
  />
264
264
  </>
265
265
  ))}
266
-
267
- <MetaDataBlock
268
- heading="Uploaded by"
269
- item={
270
- <>
271
- <Username user={dataset.uploader} /> on{" "}
272
- <DateDistance date={dataset.created} />
273
- </>
274
- }
275
- />
266
+ {!isAnonymousReviewer &&
267
+ (
268
+ <MetaDataBlock
269
+ heading="Uploaded by"
270
+ item={
271
+ <>
272
+ <Username user={dataset.uploader} /> on{" "}
273
+ <DateDistance date={dataset.created} />
274
+ </>
275
+ }
276
+ />
277
+ )}
276
278
 
277
279
  {dataset.snapshots.length && (
278
280
  <MetaDataBlock
@@ -3,6 +3,7 @@ import PropTypes from "prop-types"
3
3
  import { useCookies } from "react-cookie"
4
4
  import { getProfile } from "../authentication/profile"
5
5
  import { config } from "../config"
6
+ import { useUser } from "../queries/user"
6
7
 
7
8
  const buildCustomQuery = (customText, prepopulatedFields) => {
8
9
  const customizerQueries = [
@@ -31,8 +32,11 @@ function FreshdeskWidget({ subject, error, sentryId, description }) {
31
32
  screenshot: "No",
32
33
  captcha: "yes",
33
34
  }
35
+
36
+ const { user } = useUser()
37
+
34
38
  const prepopulatedFields = {
35
- requester: profile && profile.email,
39
+ requester: profile && user?.email,
36
40
  subject,
37
41
  description: joinedDescription,
38
42
  }
@@ -1,4 +1,4 @@
1
- import React from "react"
1
+ import React, { useEffect } from "react"
2
2
  import Uploader from "./uploader/uploader.jsx"
3
3
  import AppRoutes from "./routes"
4
4
  import HeaderContainer from "./common/containers/header"
@@ -6,11 +6,24 @@ import FooterContainer from "./common/containers/footer"
6
6
  import { SearchParamsProvider } from "./search/search-params-ctx"
7
7
  import { UserModalOpenProvider } from "./utils/user-login-modal-ctx"
8
8
  import { useAnalytics } from "./utils/analytics"
9
-
9
+ import { useLocation, useNavigate } from "react-router-dom"
10
10
  import "../assets/email-header.png"
11
+ import { useUser } from "./queries/user.js"
11
12
 
12
13
  const Index = (): React.ReactElement => {
13
14
  useAnalytics()
15
+ // Redirect authenticated Google users to the migration step if they are in any other route
16
+ const navigate = useNavigate()
17
+ const location = useLocation()
18
+ const { user, loading, error } = useUser()
19
+ useEffect(() => {
20
+ if (
21
+ !loading && !error && location.pathname !== "/orcid-link" &&
22
+ user?.provider === "google"
23
+ ) {
24
+ navigate("/orcid-link")
25
+ }
26
+ }, [location.pathname, user])
14
27
  return (
15
28
  <Uploader>
16
29
  <SearchParamsProvider>
@@ -0,0 +1,13 @@
1
+ import React from "react"
2
+ import { render, screen } from "@testing-library/react"
3
+ import { OrcidLinkPage } from "../orcid-link"
4
+ import { vitest } from "vitest"
5
+
6
+ vitest.mock("../../config")
7
+
8
+ describe("OrcidLinkPage", () => {
9
+ it("renders orcid link button", () => {
10
+ render(<OrcidLinkPage />)
11
+ expect(screen.getByRole("button")).toHaveTextContent("Link ORCID")
12
+ })
13
+ })
@@ -0,0 +1,60 @@
1
+ import React from "react"
2
+ import Helmet from "react-helmet"
3
+ import { frontPage } from "./front-page/front-page-content"
4
+ import loginUrls from "../authentication/loginUrls"
5
+ import { Button } from "../components/button/Button"
6
+ import orcidIcon from "../../assets/orcid_24x24.png"
7
+ import styled from "@emotion/styled"
8
+
9
+ const OrcidLinkPageStyle = styled.div`
10
+ background: white;
11
+
12
+ .container {
13
+ max-width: 60em;
14
+ min-height: calc(100vh - 152px);
15
+ }
16
+ `
17
+
18
+ export function OrcidLinkPage() {
19
+ return (
20
+ <OrcidLinkPageStyle>
21
+ <Helmet>
22
+ <title>
23
+ Link ORCID to your existing account - {frontPage.pageTitle}
24
+ </title>
25
+ <meta
26
+ name="description"
27
+ content="How to link your ORCID account to your Google based OpenNeuro account"
28
+ />
29
+ </Helmet>
30
+ <div className="container">
31
+ <h2>ORCID account migration</h2>
32
+ <p>
33
+ OpenNeuro is moving to ORCID for all accounts. Please link an ORCID to
34
+ your account to continue and use ORCID for future logins. If you have
35
+ used Google login before, any datasets, comments, and permissions you
36
+ have will be merged into the combined OpenNeuro account linked to your
37
+ ORCID iD.
38
+ </p>
39
+ <h3>Why are we making this change?</h3>
40
+ <p>
41
+ ORCID allows richer researcher metadata for contributions and
42
+ optionally sharing contributions to datasets as works on your ORCID
43
+ profile.
44
+ </p>
45
+ <h3>Will Google accounts continue to work?</h3>
46
+ <p>
47
+ To make new contributions you will need link an ORCID but any existing
48
+ contributions will remain available.
49
+ </p>
50
+ <a href={loginUrls.orcid + `?migrate`}>
51
+ <Button
52
+ className="login-button"
53
+ label="Link ORCID"
54
+ imgSrc={orcidIcon}
55
+ />
56
+ </a>
57
+ </div>
58
+ </OrcidLinkPageStyle>
59
+ )
60
+ }
@@ -0,0 +1,71 @@
1
+ import { gql, useQuery } from "@apollo/client"
2
+ import { useCookies } from "react-cookie"
3
+ import { getProfile } from "../authentication/profile"
4
+ import * as Sentry from "@sentry/react"
5
+
6
+ // GraphQL query to fetch user data
7
+ export const GET_USER = gql`
8
+ query User($userId: ID!) {
9
+ user(id: $userId) {
10
+ id
11
+ name
12
+ orcid
13
+ email
14
+ avatar
15
+ location
16
+ institution
17
+ links
18
+ provider
19
+ admin
20
+ created
21
+ lastSeen
22
+ blocked
23
+ }
24
+ }
25
+ `
26
+
27
+ export const UPDATE_USER = gql`
28
+ mutation updateUser(
29
+ $id: ID!
30
+ $location: String
31
+ $links: [String]
32
+ $institution: String
33
+ ) {
34
+ updateUser(
35
+ id: $id
36
+ location: $location
37
+ links: $links
38
+ institution: $institution
39
+ ) {
40
+ id
41
+ location
42
+ links
43
+ institution
44
+ }
45
+ }
46
+ `
47
+
48
+ // Reusable hook to fetch user data
49
+ export const useUser = () => {
50
+ const [cookies] = useCookies()
51
+ const profile = getProfile(cookies)
52
+ const profileSub = profile?.sub
53
+
54
+ const { data: userData, loading: userLoading, error: userError } = useQuery(
55
+ GET_USER,
56
+ {
57
+ variables: { userId: profileSub },
58
+ skip: !profileSub,
59
+ },
60
+ )
61
+
62
+ if (userError) {
63
+ Sentry.captureException(userError)
64
+ }
65
+
66
+ return {
67
+ user: userData?.user,
68
+ loading: userLoading,
69
+ error: userError,
70
+ }
71
+ }
@@ -22,6 +22,7 @@ import { UserQuery } from "./users/user-query"
22
22
  import LoggedIn from "../scripts/authentication/logged-in"
23
23
  import LoggedOut from "../scripts/authentication/logged-out"
24
24
  import FourOThreePage from "./errors/403page"
25
+ import { OrcidLinkPage } from "./pages/orcid-link"
25
26
 
26
27
  const AppRoutes: React.VoidFunctionComponent = () => (
27
28
  <Routes>
@@ -39,6 +40,7 @@ const AppRoutes: React.VoidFunctionComponent = () => (
39
40
  <Route path="/import" element={<ImportDataset />} />
40
41
  <Route path="/metadata" element={<DatasetMetadata />} />
41
42
  <Route path="/public" element={<Navigate to="/search" replace />} />
43
+ <Route path="/orcid-link" element={<OrcidLinkPage />} />
42
44
  <Route
43
45
  path="/user/:orcid/*"
44
46
  element={