@openneuro/app 4.46.0 → 4.47.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openneuro/app",
3
- "version": "4.46.0",
3
+ "version": "4.47.0",
4
4
  "description": "React JS web frontend for the OpenNeuro platform.",
5
5
  "license": "MIT",
6
6
  "main": "public/client.js",
@@ -79,5 +79,5 @@
79
79
  "publishConfig": {
80
80
  "access": "public"
81
81
  },
82
- "gitHead": "b40f6f5b597cbb722d09497182059a5157abf110"
82
+ "gitHead": "ce9d1b3598748ab7a9510e6dc0f50422088723a1"
83
83
  }
package/src/client.jsx CHANGED
@@ -14,6 +14,14 @@ import * as gtag from "./scripts/utils/gtag"
14
14
  import { relayStylePagination } from "@apollo/client/utilities"
15
15
  // TODO - This should be a global SCSS?
16
16
  import "./scripts/components/page/page.scss"
17
+ import cookies from "./scripts/utils/cookies.js"
18
+ import { getProfile, guardExpired } from "./scripts/authentication/profile"
19
+
20
+ // Clear expired JWT before the app mounts to avoid stale auth state
21
+ const profile = getProfile(cookies.getAll())
22
+ if (profile && !guardExpired(profile)) {
23
+ cookies.remove("accessToken", { path: "/" })
24
+ }
17
25
 
18
26
  gtag.initialize(config.analytics.trackingIds)
19
27
 
@@ -0,0 +1,26 @@
1
+ import { useEffect } from "react"
2
+ import { useLocation, useNavigate } from "react-router-dom"
3
+ import { useUser } from "../queries/user"
4
+
5
+ /**
6
+ * Redirects Google-authenticated users to the ORCID linking page.
7
+ * Renders nothing — just runs the redirect effect when the user query resolves.
8
+ */
9
+ export function GoogleOrcidRedirect() {
10
+ const navigate = useNavigate()
11
+ const location = useLocation()
12
+ const { user, loading, error } = useUser()
13
+
14
+ useEffect(() => {
15
+ if (
16
+ !loading &&
17
+ !error &&
18
+ location.pathname !== "/orcid-link" &&
19
+ user?.provider === "google"
20
+ ) {
21
+ navigate("/orcid-link")
22
+ }
23
+ }, [location.pathname, user, loading, error, navigate])
24
+
25
+ return null
26
+ }
@@ -21,7 +21,11 @@ export const parseJwt = jwtDecode
21
21
  export function getProfile(cookies): OpenNeuroTokenProfile {
22
22
  const accessToken = cookies["accessToken"]
23
23
  if (!accessToken) return null
24
- return parseJwt(accessToken) as OpenNeuroTokenProfile
24
+ try {
25
+ return parseJwt(accessToken) as OpenNeuroTokenProfile
26
+ } catch {
27
+ return null
28
+ }
25
29
  }
26
30
  /**
27
31
  * Return profile if token is not expired.
@@ -0,0 +1,82 @@
1
+ import { vi, describe, it, expect } from "vitest"
2
+ import React from "react"
3
+ import { render, screen } from "@testing-library/react"
4
+ import { MemoryRouter } from "react-router-dom"
5
+ import { MockedProvider } from "@apollo/client/testing"
6
+
7
+ // Mock the dependencies
8
+ vi.mock("react-cookie", () => ({
9
+ useCookies: () => [{}],
10
+ }))
11
+
12
+ vi.mock("../../authentication/profile", () => ({
13
+ getProfile: () => ({ email: "test@example.com" }),
14
+ }))
15
+
16
+ vi.mock("../../queries/user", () => ({
17
+ useUser: () => ({
18
+ user: { email: "test@example.com" },
19
+ }),
20
+ }))
21
+
22
+ import FreshdeskWidget from "../freshdesk-widget"
23
+
24
+ describe("FreshdeskWidget component", () => {
25
+ it("includes meta[referrer] with current page URL in iframe src", () => {
26
+ const testRoute = "/datasets/ds000001"
27
+
28
+ render(
29
+ <MemoryRouter initialEntries={[testRoute]}>
30
+ <MockedProvider>
31
+ <FreshdeskWidget subject="Test" description="Test description" />
32
+ </MockedProvider>
33
+ </MemoryRouter>,
34
+ )
35
+
36
+ const iframe = screen.getByTitle("Feedback Form") as HTMLIFrameElement
37
+
38
+ // The iframe src should contain the current page URL as meta[referrer]
39
+ expect(iframe.src).toContain("meta[referrer]=")
40
+ // Verify it includes the test route pathname
41
+ expect(iframe.src).toContain("/datasets/ds000001")
42
+ })
43
+
44
+ it("includes prepopulated subject and description fields", () => {
45
+ const testRoute = "/datasets/ds000001"
46
+
47
+ render(
48
+ <MemoryRouter initialEntries={[testRoute]}>
49
+ <MockedProvider>
50
+ <FreshdeskWidget
51
+ subject="Test Issue"
52
+ description="This is a test"
53
+ />
54
+ </MockedProvider>
55
+ </MemoryRouter>,
56
+ )
57
+
58
+ const iframe = screen.getByTitle("Feedback Form") as HTMLIFrameElement
59
+
60
+ // Verify subject and description are in the iframe src
61
+ expect(iframe.src).toContain("helpdesk_ticket[subject]=Test%20Issue")
62
+ expect(iframe.src).toContain("helpdesk_ticket[description]=This%20is%20a%20test")
63
+ })
64
+
65
+ it("encodes special characters in the referrer URL correctly", () => {
66
+ const testRoute = "/search?query=test"
67
+
68
+ render(
69
+ <MemoryRouter initialEntries={[testRoute]}>
70
+ <MockedProvider>
71
+ <FreshdeskWidget subject="Test" />
72
+ </MockedProvider>
73
+ </MemoryRouter>,
74
+ )
75
+
76
+ const iframe = screen.getByTitle("Feedback Form") as HTMLIFrameElement
77
+
78
+ // Should properly encode the URL with query parameters
79
+ expect(iframe.src).toContain("meta[referrer]=")
80
+ expect(iframe.src).toContain("/search")
81
+ })
82
+ })
@@ -1,6 +1,7 @@
1
1
  import React from "react"
2
2
  import PropTypes from "prop-types"
3
3
  import { useCookies } from "react-cookie"
4
+ import { useLocation } from "react-router-dom"
4
5
  import { getProfile } from "../../authentication/profile"
5
6
  import { config } from "../../config"
6
7
  import { useUser } from "../../queries/user"
@@ -10,13 +11,21 @@ const buildCustomQuery = (customText, prepopulatedFields) => {
10
11
  ...Object.entries(customText).map(([key, value]) => `${key}=${value}`),
11
12
  ...Object.entries(prepopulatedFields)
12
13
  .filter(([, value]) => value)
13
- .map(([key, value]) => `helpdesk_ticket[${key}]=${value}`),
14
+ .map(([key, value]) => {
15
+ // Handle meta_ prefixed fields separately (e.g., meta_referrer -> meta[referrer])
16
+ if (key.startsWith("meta_")) {
17
+ const fieldName = key.replace(/^meta_/, "")
18
+ return `meta[${fieldName}]=${value}`
19
+ }
20
+ return `helpdesk_ticket[${key}]=${value}`
21
+ }),
14
22
  ]
15
23
  return customizerQueries.length ? `&${customizerQueries.join(";")}` : ""
16
24
  }
17
25
 
18
26
  function FreshdeskWidget({ subject, error, sentryId, description }) {
19
27
  const [cookies] = useCookies()
28
+ const { pathname } = useLocation()
20
29
  const profile = getProfile(cookies)
21
30
  const sentry = sentryId && `Sentry ID: ${sentryId}`
22
31
  const joinedDescription = [sentry, description, error]
@@ -38,6 +47,7 @@ function FreshdeskWidget({ subject, error, sentryId, description }) {
38
47
  requester: profile && user?.email,
39
48
  subject,
40
49
  description: joinedDescription,
50
+ meta_referrer: pathname,
41
51
  }
42
52
 
43
53
  return (
@@ -348,6 +348,22 @@ exports[`SnapshotContainer component > renders successfully 1`] = `
348
348
  <p>
349
349
  CBRAIN is a web-based distributed computing platform for collaborative neuroimaging research.
350
350
  </p>
351
+ <hr />
352
+ <img
353
+ height="16"
354
+ src="/packages/openneuro-app/src/assets/external/neurodesk.png"
355
+ width="16"
356
+ />
357
+
358
+ <a
359
+ href="https://play-america.neurodesk.org/hub/user-redirect/lab/tree/data/openneuro/ds001032"
360
+ target="_blank"
361
+ >
362
+ View on Neurodesk
363
+ </a>
364
+ <p>
365
+ Neurodesk provides browser-based access to a neuroimaging analysis environment with this dataset mounted.
366
+ </p>
351
367
  </div>
352
368
  </div>
353
369
  </div>
@@ -5,6 +5,7 @@ import styled from "@emotion/styled"
5
5
  import BrainlifeIcon from "../../../assets/external/brainlife.png"
6
6
  import NemarIcon from "../../../assets/external/nemar.png"
7
7
  import CbrainIcon from "../../../assets/external/cbrain.png"
8
+ import NeurodeskIcon from "../../../assets/external/neurodesk.png"
8
9
 
9
10
  export interface CloneDropdownProps {
10
11
  datasetId: string
@@ -24,6 +25,8 @@ export const AnalyzeDropdown: React.FC<CloneDropdownProps> = (
24
25
  `https://nemar.org/dataexplorer/detail?dataset_id=${datasetId}`
25
26
  const cbrainUrl =
26
27
  `https://portal.cbrain.mcgill.ca/openneuro/${datasetId}/versions/${snapshotVersion}`
28
+ const neurodeskUrl =
29
+ `https://play-america.neurodesk.org/hub/user-redirect/lab/tree/data/openneuro/${datasetId}`
27
30
  return (
28
31
  <AnalyzeDiv className="clone-dropdown">
29
32
  <Dropdown
@@ -91,6 +94,19 @@ export const AnalyzeDropdown: React.FC<CloneDropdownProps> = (
91
94
  CBRAIN is a web-based distributed computing platform for
92
95
  collaborative neuroimaging research.
93
96
  </p>
97
+ <hr />
98
+ <img
99
+ src={NeurodeskIcon}
100
+ height="16"
101
+ width="16"
102
+ />{" "}
103
+ <a href={neurodeskUrl} target="_blank">
104
+ View on Neurodesk
105
+ </a>
106
+ <p>
107
+ Neurodesk provides browser-based access to a neuroimaging analysis
108
+ environment with this dataset mounted.
109
+ </p>
94
110
  </div>
95
111
  </Dropdown>
96
112
  </AnalyzeDiv>
@@ -15,5 +15,11 @@ describe("AnalyzeDropdown component", () => {
15
15
  expect(menu).toHaveClass("collapsed")
16
16
  fireEvent.click(button)
17
17
  expect(menu).toHaveClass("expanded")
18
+ expect(
19
+ screen.getByRole("link", { name: "View on Neurodesk" }),
20
+ ).toHaveAttribute(
21
+ "href",
22
+ "https://play-america.neurodesk.org/hub/user-redirect/lab/tree/data/openneuro/ds000031",
23
+ )
18
24
  })
19
25
  })
@@ -21,6 +21,7 @@ const fields = (hasEdit) => {
21
21
  { value: "subject privacy" },
22
22
  { value: "duplicate dataset" },
23
23
  { value: "abuse of service", admin: true },
24
+ { value: "data retention policy", admin: true },
24
25
  ],
25
26
  showOptionOther: true,
26
27
  required: false,
@@ -36,6 +36,14 @@ const FourOFourPage: FC<FourOFourPageProps> = ({
36
36
  <Container styleContext={theme} data-testid="404-page">
37
37
  <h3>404: The page you are looking for does not exist.</h3>
38
38
  {message && <p>{message}</p>}
39
+ {message.includes("data retention policy") && (
40
+ <p>
41
+ See our documentation for the{" "}
42
+ <a href="https://docs.openneuro.org/policy/data_retention.html">
43
+ full data retention policy
44
+ </a>.
45
+ </p>
46
+ )}
39
47
  <p>
40
48
  Click <Link to={redirectRoute}>here</Link> to go to
41
49
  {" " + redirectRouteName}.
@@ -1,6 +1,7 @@
1
1
  import React from "react"
2
2
  import PropTypes from "prop-types"
3
3
  import { useCookies } from "react-cookie"
4
+ import { useLocation } from "react-router-dom"
4
5
  import { getProfile } from "../authentication/profile"
5
6
  import { config } from "../config"
6
7
  import { useUser } from "../queries/user"
@@ -10,13 +11,21 @@ const buildCustomQuery = (customText, prepopulatedFields) => {
10
11
  ...Object.entries(customText).map(([key, value]) => `${key}=${value}`),
11
12
  ...Object.entries(prepopulatedFields)
12
13
  .filter(([, value]) => value)
13
- .map(([key, value]) => `helpdesk_ticket[${key}]=${value}`),
14
+ .map(([key, value]) => {
15
+ // Handle meta_ prefixed fields separately (e.g., meta_referrer -> meta[referrer])
16
+ if (key.startsWith("meta_")) {
17
+ const fieldName = key.replace(/^meta_/, "")
18
+ return `meta[${fieldName}]=${value}`
19
+ }
20
+ return `helpdesk_ticket[${key}]=${value}`
21
+ }),
14
22
  ]
15
23
  return customizerQueries.length ? `&${customizerQueries.join(";")}` : ""
16
24
  }
17
25
 
18
26
  function FreshdeskWidget({ subject, error, sentryId, description }) {
19
27
  const [cookies] = useCookies()
28
+ const { pathname } = useLocation()
20
29
  const profile = getProfile(cookies)
21
30
  const sentry = sentryId && `Sentry ID: ${sentryId}`
22
31
  const joinedDescription = [sentry, description, error]
@@ -39,6 +48,7 @@ function FreshdeskWidget({ subject, error, sentryId, description }) {
39
48
  requester: profile && user?.email,
40
49
  subject,
41
50
  description: joinedDescription,
51
+ meta_referrer: pathname,
42
52
  }
43
53
  return (
44
54
  <>
@@ -1,5 +1,4 @@
1
- import React, { useEffect } from "react"
2
- import { useLocation, useNavigate } from "react-router-dom"
1
+ import React from "react"
3
2
  import Uploader from "./uploader/uploader"
4
3
  import AppRoutes from "./routes"
5
4
  import HeaderContainer from "./common/containers/header"
@@ -7,47 +6,23 @@ import FooterContainer from "./common/containers/footer"
7
6
  import { SearchParamsProvider } from "./search/search-params-ctx"
8
7
  import { UserModalOpenProvider } from "./utils/user-login-modal-ctx"
9
8
  import { useAnalytics } from "./utils/analytics"
10
- import { useUser } from "./queries/user"
11
9
  import { NotificationsProvider } from "./users/notifications/user-notifications-context"
10
+ import { GoogleOrcidRedirect } from "./authentication/google-redirect"
12
11
  import "../assets/email-header.png"
13
12
 
14
13
  const Index: React.FC = () => {
15
14
  useAnalytics()
16
15
 
17
- const navigate = useNavigate()
18
- const location = useLocation()
19
- const { user, loading, error } = useUser()
20
-
21
- useEffect(() => {
22
- if (
23
- !loading &&
24
- !error &&
25
- location.pathname !== "/orcid-link" &&
26
- user?.provider === "google"
27
- ) {
28
- navigate("/orcid-link")
29
- }
30
- }, [location.pathname, user, loading, error, navigate])
31
-
32
- if (loading || error) return null
33
-
34
- const initialNotifications = user?.notifications?.map((n) => ({
35
- id: n.id,
36
- status: Array.isArray(n.notificationStatus)
37
- ? n.notificationStatus.map((ns) => ns.status.toLowerCase())[0] || "unread"
38
- : n.notificationStatus?.status?.toLowerCase() || "unread",
39
- ...n,
40
- })) || []
41
-
42
16
  return (
43
17
  <Uploader>
44
18
  <SearchParamsProvider>
45
19
  <UserModalOpenProvider>
46
- <NotificationsProvider initialNotifications={initialNotifications}>
20
+ <NotificationsProvider>
47
21
  <div className="sticky-content">
48
22
  <HeaderContainer />
49
23
  <AppRoutes />
50
24
  </div>
25
+ <GoogleOrcidRedirect />
51
26
  </NotificationsProvider>
52
27
  <div className="sticky-footer">
53
28
  <FooterContainer />
@@ -1,3 +1,4 @@
1
+ import { useEffect } from "react"
1
2
  import { gql, useQuery } from "@apollo/client"
2
3
  import { useCookies } from "react-cookie"
3
4
  import { getProfile } from "../authentication/profile"
@@ -211,7 +212,7 @@ export const ADVANCED_SEARCH_DATASETS_QUERY = gql`
211
212
 
212
213
  // Reusable hook to fetch user data
213
214
  export const useUser = (userId?: string) => {
214
- const [cookies] = useCookies()
215
+ const [cookies, , removeCookie] = useCookies()
215
216
  const profile = getProfile(cookies)
216
217
  const profileSub = profile?.sub
217
218
 
@@ -230,6 +231,13 @@ export const useUser = (userId?: string) => {
230
231
  Sentry.captureException(userError)
231
232
  }
232
233
 
234
+ // Clear invalid session: cookie exists but query failed or returned no user
235
+ useEffect(() => {
236
+ if (!userLoading && profile && (userError || !userData?.user)) {
237
+ removeCookie("accessToken", { path: "/" })
238
+ }
239
+ }, [userLoading, profile, userError, userData?.user, removeCookie])
240
+
233
241
  return {
234
242
  user: userData?.user,
235
243
  loading: userLoading,
@@ -33,7 +33,7 @@ const baseUser: User = {
33
33
  const userMock = {
34
34
  request: {
35
35
  query: userQueries.GET_USER,
36
- variables: { id: baseUser.orcid },
36
+ variables: { userId: baseUser.id },
37
37
  },
38
38
  result: {
39
39
  data: {
@@ -50,13 +50,13 @@ describe("<UserAccountView />", () => {
50
50
  request: {
51
51
  query: userQueries.UPDATE_USER,
52
52
  variables: {
53
- id: baseUser.orcid,
53
+ id: baseUser.id,
54
54
  },
55
55
  },
56
56
  result: {
57
57
  data: {
58
58
  updateUser: {
59
- id: baseUser.orcid,
59
+ id: baseUser.id,
60
60
  location: "Marin, CA",
61
61
  links: ["https://newlink.com"],
62
62
  institution: "New University",
@@ -87,14 +87,14 @@ describe("<UserAccountView />", () => {
87
87
  request: {
88
88
  query: userQueries.UPDATE_USER,
89
89
  variables: {
90
- id: baseUser.orcid,
90
+ id: baseUser.id,
91
91
  location: "Marin, CA",
92
92
  },
93
93
  },
94
94
  result: {
95
95
  data: {
96
96
  updateUser: {
97
- id: baseUser.orcid,
97
+ id: baseUser.id,
98
98
  location: "Marin, CA",
99
99
  links: ["https://newlink.com"],
100
100
  institution: "New University",
@@ -128,14 +128,14 @@ describe("<UserAccountView />", () => {
128
128
  request: {
129
129
  query: userQueries.UPDATE_USER,
130
130
  variables: {
131
- id: baseUser.orcid,
131
+ id: baseUser.id,
132
132
  institution: "New University",
133
133
  },
134
134
  },
135
135
  result: {
136
136
  data: {
137
137
  updateUser: {
138
- id: baseUser.orcid,
138
+ id: baseUser.id,
139
139
  location: "Marin, CA",
140
140
  links: ["https://newlink.com"],
141
141
  institution: "New University",
@@ -168,14 +168,14 @@ describe("<UserAccountView />", () => {
168
168
  request: {
169
169
  query: userQueries.UPDATE_USER,
170
170
  variables: {
171
- id: baseUser.orcid,
171
+ id: baseUser.id,
172
172
  links: ["https://newlink.com"],
173
173
  },
174
174
  },
175
175
  result: {
176
176
  data: {
177
177
  updateUser: {
178
- id: baseUser.orcid,
178
+ id: baseUser.id,
179
179
  location: "Marin, CA",
180
180
  links: ["https://newlink.com"],
181
181
  institution: "New University",
@@ -208,14 +208,14 @@ describe("<UserAccountView />", () => {
208
208
  request: {
209
209
  query: userQueries.UPDATE_USER,
210
210
  variables: {
211
- id: baseUser.orcid,
211
+ id: baseUser.id,
212
212
  links: ["https://newlink.com"],
213
213
  },
214
214
  },
215
215
  result: {
216
216
  data: {
217
217
  updateUser: {
218
- id: baseUser.orcid,
218
+ id: baseUser.id,
219
219
  location: "Marin, CA",
220
220
  links: ["https://newlink.com"],
221
221
  institution: "New University",
@@ -1,8 +1,18 @@
1
- import React, { createContext, useCallback, useContext, useState } from "react"
1
+ import React, {
2
+ createContext,
3
+ useCallback,
4
+ useContext,
5
+ useEffect,
6
+ useState,
7
+ } from "react"
2
8
  import * as Sentry from "@sentry/react"
3
9
  import { useMutation } from "@apollo/client"
4
10
  import { UPDATE_NOTIFICATION_STATUS_MUTATION } from "../../queries/datasetEvents"
5
- import type { MappedNotification } from "../../types/event-types"
11
+ import {
12
+ type MappedNotification,
13
+ mapRawEventToMappedNotification,
14
+ } from "../../types/event-types"
15
+ import { useUser } from "../../queries/user"
6
16
 
7
17
  interface NotificationsContextValue {
8
18
  notifications: MappedNotification[]
@@ -15,7 +25,7 @@ interface NotificationsContextValue {
15
25
 
16
26
  interface NotificationsProviderProps {
17
27
  children: React.ReactNode
18
- initialNotifications?: MappedNotification[]
28
+ userId?: string
19
29
  }
20
30
 
21
31
  const NotificationsContext = createContext<
@@ -24,11 +34,16 @@ const NotificationsContext = createContext<
24
34
 
25
35
  export const NotificationsProvider: React.FC<NotificationsProviderProps> = ({
26
36
  children,
27
- initialNotifications = [],
37
+ userId,
28
38
  }) => {
29
- const [notifications, setNotifications] = useState<MappedNotification[]>(
30
- initialNotifications,
31
- )
39
+ const { user } = useUser(userId)
40
+ const [notifications, setNotifications] = useState<MappedNotification[]>([])
41
+
42
+ useEffect(() => {
43
+ const mapped = user?.notifications?.map(mapRawEventToMappedNotification) ??
44
+ []
45
+ setNotifications(mapped)
46
+ }, [user?.notifications])
32
47
  const [updateEventStatus] = useMutation(UPDATE_NOTIFICATION_STATUS_MUTATION)
33
48
 
34
49
  const handleUpdateNotification = useCallback(
@@ -10,10 +10,6 @@ import styles from "./scss/usernotifications.module.scss"
10
10
  import iconUnread from "../../../assets/icon-unread.png"
11
11
  import iconSaved from "../../../assets/icon-saved.png"
12
12
  import iconArchived from "../../../assets/icon-archived.png"
13
- import { useUser } from "../../queries/user"
14
- import { Loading } from "../../components/loading/Loading"
15
- import * as Sentry from "@sentry/react"
16
- import { mapRawEventToMappedNotification } from "../../types/event-types"
17
13
  import {
18
14
  NotificationsProvider,
19
15
  useNotifications,
@@ -27,8 +23,6 @@ export const UserNotificationsView: React.FC<UserNotificationsViewProps> = (
27
23
  const { tab = "unread" } = useParams()
28
24
  const navigate = useNavigate()
29
25
  const location = useLocation()
30
- const { user, loading, error } = useUser(orcidUser.id)
31
-
32
26
  const [indicatorStyle, setIndicatorStyle] = useState<React.CSSProperties>({
33
27
  width: "0px",
34
28
  transform: "translateX(0px)",
@@ -63,19 +57,8 @@ export const UserNotificationsView: React.FC<UserNotificationsViewProps> = (
63
57
  }
64
58
  }, [tab, orcidUser.orcid, navigate])
65
59
 
66
- if (loading) return <Loading />
67
- if (error) {
68
- Sentry.captureException(error)
69
- return (
70
- <div>Error loading notifications: {error.message}. Please try again.</div>
71
- )
72
- }
73
-
74
- const initialNotifications =
75
- user?.notifications.map(mapRawEventToMappedNotification) || []
76
-
77
60
  return (
78
- <NotificationsProvider initialNotifications={initialNotifications}>
61
+ <NotificationsProvider userId={orcidUser.id}>
79
62
  <div data-testid="user-notifications-view">
80
63
  <h3>Notifications for {orcidUser.name}</h3>
81
64
  <div className={styles.tabContainer}>
@@ -14,12 +14,12 @@ import { pageTitle } from "../resources/strings.js"
14
14
  export const UserAccountView: React.FC<UserAccountViewProps> = ({
15
15
  orcidUser,
16
16
  }) => {
17
- const [userLinks, setLinks] = useState<string[]>(orcidUser?.links || [])
17
+ const [userLinks, setLinks] = useState<string[]>(orcidUser.links ?? [])
18
18
  const [userLocation, setLocation] = useState<string>(
19
- orcidUser?.location || "",
19
+ orcidUser.location ?? "",
20
20
  )
21
21
  const [userInstitution, setInstitution] = useState<string>(
22
- orcidUser?.institution || "",
22
+ orcidUser.institution ?? "",
23
23
  )
24
24
  const [updateUser] = useMutation(UPDATE_USER)
25
25
 
@@ -28,13 +28,13 @@ export const UserAccountView: React.FC<UserAccountViewProps> = ({
28
28
  try {
29
29
  await updateUser({
30
30
  variables: {
31
- id: orcidUser?.orcid,
31
+ id: orcidUser.id,
32
32
  links: newLinks,
33
33
  },
34
34
  refetchQueries: [
35
35
  {
36
36
  query: GET_USER,
37
- variables: { id: orcidUser?.orcid },
37
+ variables: { userId: orcidUser.id },
38
38
  },
39
39
  ],
40
40
  })
@@ -49,13 +49,13 @@ export const UserAccountView: React.FC<UserAccountViewProps> = ({
49
49
  try {
50
50
  await updateUser({
51
51
  variables: {
52
- id: orcidUser?.orcid,
52
+ id: orcidUser.id,
53
53
  location: newLocation,
54
54
  },
55
55
  refetchQueries: [
56
56
  {
57
57
  query: GET_USER,
58
- variables: { id: orcidUser?.orcid },
58
+ variables: { userId: orcidUser.id },
59
59
  },
60
60
  ],
61
61
  })
@@ -70,13 +70,13 @@ export const UserAccountView: React.FC<UserAccountViewProps> = ({
70
70
  try {
71
71
  await updateUser({
72
72
  variables: {
73
- id: orcidUser?.orcid,
73
+ id: orcidUser.id,
74
74
  institution: newInstitution,
75
75
  },
76
76
  refetchQueries: [
77
77
  {
78
78
  query: GET_USER,
79
- variables: { id: orcidUser?.orcid },
79
+ variables: { userId: orcidUser.id },
80
80
  },
81
81
  ],
82
82
  })
@@ -107,7 +107,7 @@ export const UserAccountView: React.FC<UserAccountViewProps> = ({
107
107
  <span>ORCID:</span>
108
108
  {orcidUser.orcid}
109
109
  </li>
110
- {orcidUser?.github &&
110
+ {orcidUser.github &&
111
111
  (
112
112
  <li>
113
113
  <span>GitHub:</span>
@@ -128,7 +128,7 @@ export const UserAccountView: React.FC<UserAccountViewProps> = ({
128
128
  data-testid="links-section"
129
129
  />
130
130
 
131
- {orcidUser?.id && orcidUser?.orcid !== undefined && (
131
+ {orcidUser.orcid !== undefined && (
132
132
  <div className={styles.umbOrcidConsent}>
133
133
  <div className={styles.umbOrcidHeading}>
134
134
  <h4>ORCID Integration</h4>
@@ -10,15 +10,17 @@ export interface UserMenuProps {
10
10
  }
11
11
 
12
12
  export const UserMenu: React.FC<UserMenuProps> = ({ signOutAndRedirect }) => {
13
- const { user } = useUser()
13
+ const { user, loading } = useUser()
14
14
  const { notifications } = useNotifications()
15
15
 
16
+ if (loading || !user) return null
17
+
16
18
  const inboxCount =
17
19
  notifications?.filter((n) => n.status === "unread").length || 0
18
20
 
19
21
  return (
20
22
  <span className="user-menu-wrap">
21
- {user?.orcid && (
23
+ {user.orcid && (
22
24
  <span className="notifications-link">
23
25
  <Link to={`/user/${user.orcid}/notifications/unread`}>
24
26
  <i className="fa fa-inbox">
@@ -41,7 +43,7 @@ export const UserMenu: React.FC<UserMenuProps> = ({ signOutAndRedirect }) => {
41
43
 
42
44
  <Dropdown
43
45
  className="user-menu-dropdown"
44
- label={user?.avatar
46
+ label={user.avatar
45
47
  ? (
46
48
  <img
47
49
  className="user-menu-label avatar"
@@ -56,23 +58,23 @@ export const UserMenu: React.FC<UserMenuProps> = ({ signOutAndRedirect }) => {
56
58
  <li className="dropdown-header">
57
59
  <p>
58
60
  <span>Hello</span> <br />
59
- {user?.name} <br />
60
- {user?.email}
61
+ {user.name} <br />
62
+ {user.email}
61
63
  </p>
62
64
  <p>
63
- <span>signed in via {user?.provider}</span>
65
+ <span>signed in via {user.provider}</span>
64
66
  </p>
65
67
  </li>
66
68
 
67
69
  <li>
68
70
  <Link
69
- to={user?.orcid ? `/user/${user.orcid}` : "/search?mydatasets"}
71
+ to={user.orcid ? `/user/${user.orcid}` : "/search?mydatasets"}
70
72
  >
71
73
  My Datasets
72
74
  </Link>
73
75
  </li>
74
76
 
75
- {user?.orcid && (
77
+ {user.orcid && (
76
78
  <li>
77
79
  <Link to={`/user/${user.orcid}/account`}>Account Info</Link>
78
80
  </li>
@@ -82,13 +84,13 @@ export const UserMenu: React.FC<UserMenuProps> = ({ signOutAndRedirect }) => {
82
84
  <Link to="/keygen">Obtain an API Key</Link>
83
85
  </li>
84
86
 
85
- {user?.provider !== "orcid" && (
87
+ {user.provider !== "orcid" && (
86
88
  <li className="user-menu-link">
87
89
  <a href="/crn/auth/orcid?link=true">Link ORCID to my account</a>
88
90
  </li>
89
91
  )}
90
92
 
91
- {user?.admin && (
93
+ {user.admin && (
92
94
  <li className="user-menu-link">
93
95
  <Link to="/admin">Admin</Link>
94
96
  </li>