@openneuro/app 4.36.0-alpha.0 → 4.36.1

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.
@@ -0,0 +1,247 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react"
2
+ import { gql, useQuery } from "@apollo/client"
3
+ import * as Sentry from "@sentry/react"
4
+ import type { User } from "../types/user-types"
5
+
6
+ // --- Fragments ---
7
+ export const USER_FRAGMENT = gql`
8
+ fragment userFields on User {
9
+ id
10
+ name
11
+ admin
12
+ blocked
13
+ email
14
+ provider
15
+ lastSeen
16
+ created
17
+ avatar
18
+ github
19
+ institution
20
+ location
21
+ modified
22
+ orcid
23
+ }
24
+ `
25
+
26
+ // --- GET_USERS QUERY ---
27
+ export const GET_USERS = gql`
28
+ query GetUsers(
29
+ $orderBy: [UserSortInput!]
30
+ $isAdmin: Boolean
31
+ $isBlocked: Boolean
32
+ $search: String
33
+ $limit: Int
34
+ $offset: Int
35
+ ) {
36
+ users(
37
+ orderBy: $orderBy
38
+ isAdmin: $isAdmin
39
+ isBlocked: $isBlocked
40
+ search: $search
41
+ limit: $limit
42
+ offset: $offset
43
+ ) {
44
+ users {
45
+ ...userFields
46
+ }
47
+ totalCount
48
+ }
49
+ }
50
+ ${USER_FRAGMENT}
51
+ `
52
+
53
+ export const SET_ADMIN_MUTATION = gql`
54
+ mutation SetAdmin($id: ID!, $admin: Boolean!) {
55
+ setAdmin(id: $id, admin: $admin) {
56
+ ...userFields
57
+ }
58
+ }
59
+ ${USER_FRAGMENT}
60
+ `
61
+
62
+ export const SET_BLOCKED_MUTATION = gql`
63
+ mutation SetBlocked($id: ID!, $blocked: Boolean!) {
64
+ setBlocked(id: $id, blocked: $blocked) {
65
+ ...userFields
66
+ }
67
+ }
68
+ ${USER_FRAGMENT}
69
+ `
70
+
71
+ // --- INTERFACES ---
72
+ export interface GetUsersQueryResult {
73
+ users?: {
74
+ users: User[]
75
+ totalCount: number
76
+ }
77
+ }
78
+
79
+ export interface GetUsersQueryVariables {
80
+ orderBy?: { field: string; order: "ascending" | "descending" }[]
81
+ isAdmin?: boolean
82
+ isBlocked?: boolean
83
+ search?: string
84
+ limit?: number
85
+ offset?: number
86
+ }
87
+
88
+ export interface UseUsersOptions {
89
+ orderBy?: { field: string | null; order: "ascending" | "descending" }
90
+ isAdmin?: boolean | null
91
+ isBlocked?: boolean | null
92
+ search?: string | undefined
93
+ initialLimit?: number
94
+ }
95
+
96
+ // --- useUsers HOOK ---
97
+ export const useUsers = (options: UseUsersOptions = {}) => {
98
+ const [currentLimit] = useState(options.initialLimit || 100)
99
+ const [offset, setOffset] = useState(0)
100
+
101
+ const filterSortSearchVariables = useRef({
102
+ orderBy: options.orderBy?.field
103
+ ? [{ field: options.orderBy.field, order: options.orderBy.order }]
104
+ : undefined,
105
+ search: options.search,
106
+ isAdmin: typeof options.isAdmin === "boolean" ? options.isAdmin : undefined,
107
+ isBlocked: typeof options.isBlocked === "boolean"
108
+ ? options.isBlocked
109
+ : undefined,
110
+ })
111
+
112
+ // Effect to update the ref when options change and reset offset
113
+ useEffect(() => {
114
+ filterSortSearchVariables.current = {
115
+ orderBy: options.orderBy?.field
116
+ ? [{ field: options.orderBy.field, order: options.orderBy.order }]
117
+ : undefined,
118
+ search: options.search,
119
+ isAdmin: typeof options.isAdmin === "boolean"
120
+ ? options.isAdmin
121
+ : undefined,
122
+ isBlocked: typeof options.isBlocked === "boolean"
123
+ ? options.isBlocked
124
+ : undefined,
125
+ }
126
+ // When filters/sort/search change, reset the offset to 0 to start a new list
127
+ setOffset(0)
128
+ }, [options.orderBy, options.search, options.isAdmin, options.isBlocked])
129
+
130
+ const { data, loading, error, fetchMore, refetch } = useQuery<
131
+ GetUsersQueryResult
132
+ >(GET_USERS, {
133
+ variables: {
134
+ ...filterSortSearchVariables.current,
135
+ limit: currentLimit,
136
+ offset: offset,
137
+ },
138
+ notifyOnNetworkStatusChange: true,
139
+ fetchPolicy: "cache-and-network",
140
+ })
141
+
142
+ const [allUsers, setAllUsers] = useState<User[]>([])
143
+
144
+ useEffect(() => {
145
+ if (data?.users?.users) {
146
+ if (offset === 0) {
147
+ setAllUsers(data.users.users)
148
+ } else {
149
+ setAllUsers((prevUsers) => {
150
+ const newUsers = data.users!.users
151
+ const uniqueNewUsers = newUsers.filter(
152
+ (newUser) =>
153
+ !prevUsers.some((existingUser) => existingUser.id === newUser.id),
154
+ )
155
+ return [...prevUsers, ...uniqueNewUsers]
156
+ })
157
+ }
158
+ }
159
+ }, [data?.users?.users, offset])
160
+
161
+ const totalCount = data?.users?.totalCount || 0
162
+ const hasMore = totalCount > allUsers.length || loading
163
+
164
+ const loadMore = useCallback(() => {
165
+ if (hasMore && !loading) {
166
+ const newOffset = allUsers.length
167
+ fetchMore({
168
+ variables: {
169
+ ...filterSortSearchVariables.current,
170
+ offset: newOffset,
171
+ limit: currentLimit,
172
+ },
173
+ updateQuery: (prev, { fetchMoreResult }) => {
174
+ if (!fetchMoreResult) return prev
175
+
176
+ const incomingUsers = fetchMoreResult.users.users
177
+ const currentUsers = prev.users?.users || []
178
+
179
+ const combinedUsers = currentUsers.concat(
180
+ incomingUsers.filter(
181
+ (newUser) =>
182
+ !currentUsers.some(
183
+ (existingUser) => existingUser.id === newUser.id,
184
+ ),
185
+ ),
186
+ )
187
+
188
+ return {
189
+ users: {
190
+ ...prev.users,
191
+ users: combinedUsers,
192
+ totalCount: fetchMoreResult.users.totalCount,
193
+ },
194
+ }
195
+ },
196
+ })
197
+ }
198
+ }, [
199
+ allUsers.length,
200
+ hasMore,
201
+ loading,
202
+ fetchMore,
203
+ currentLimit,
204
+ filterSortSearchVariables,
205
+ ])
206
+
207
+ const refetchFullList = useCallback(
208
+ async (variablesToRefetch?: GetUsersQueryVariables) => {
209
+ setOffset(0)
210
+ await refetch({
211
+ ...filterSortSearchVariables.current,
212
+ ...variablesToRefetch,
213
+ offset: 0,
214
+ limit: currentLimit,
215
+ })
216
+ },
217
+ [refetch, filterSortSearchVariables, currentLimit],
218
+ )
219
+
220
+ const refetchCurrentPage = useCallback(async () => {
221
+ await refetch({
222
+ ...filterSortSearchVariables.current,
223
+ offset: 0,
224
+ limit: allUsers.length > 0 ? allUsers.length : currentLimit,
225
+ })
226
+ }, [refetch, filterSortSearchVariables, allUsers.length, currentLimit])
227
+
228
+ useEffect(() => {
229
+ if (error) {
230
+ Sentry.captureException(error)
231
+ }
232
+ }, [error])
233
+
234
+ return {
235
+ users: allUsers,
236
+ loading,
237
+ error,
238
+ refetchFullList,
239
+ refetchCurrentPage,
240
+ loadMore,
241
+ hasMore,
242
+ totalCount,
243
+ currentLimit,
244
+ currentOffset: allUsers.length,
245
+ filterSortSearchVariables: filterSortSearchVariables.current,
246
+ }
247
+ }
@@ -4,7 +4,7 @@ import { Navigate, Route, Routes } from "react-router-dom"
4
4
  // TODO - Re-enable code splitting these when we can
5
5
  import DatasetQuery from "./dataset/dataset-query"
6
6
  //import PreRefactorDatasetProps from './dataset/dataset-pre-refactor-container'
7
-
7
+ import { isAdmin } from "./authentication/admin-user.jsx"
8
8
  import FaqPage from "./pages/faq/faq"
9
9
  import FrontPageContainer from "./pages/front-page/front-page"
10
10
  import Admin from "./pages/admin/admin"
@@ -19,10 +19,8 @@ import { DatasetMetadata } from "./pages/metadata/dataset-metadata"
19
19
  import { TermsPage } from "./pages/terms"
20
20
  import { ImageAttribution } from "./pages/image-attribution"
21
21
  import { UserQuery } from "./users/user-query"
22
- import LoggedIn from "../scripts/authentication/logged-in"
23
- import LoggedOut from "../scripts/authentication/logged-out"
24
- import FourOThreePage from "./errors/403page"
25
22
  import { OrcidLinkPage } from "./pages/orcid-link"
23
+ import FourOThreePage from "./errors/403page"
26
24
 
27
25
  const AppRoutes: React.VoidFunctionComponent = () => (
28
26
  <Routes>
@@ -31,7 +29,10 @@ const AppRoutes: React.VoidFunctionComponent = () => (
31
29
  <Route path="/keygen" element={<APIKey />} />
32
30
  <Route path="/datasets/:datasetId/*" element={<DatasetQuery />} />
33
31
  <Route path="/search/*" element={<SearchRoutes />} />
34
- <Route path="/admin/*" element={<Admin />} />
32
+ <Route
33
+ path="/admin/*"
34
+ element={isAdmin() ? <Admin /> : <FourOThreePage />}
35
+ />
35
36
  <Route path="/error/*" element={<ErrorRoute />} />
36
37
  <Route path="/pet" element={<PETRedirect />} />
37
38
  <Route path="/cite" element={<Citation />} />
@@ -43,16 +44,7 @@ const AppRoutes: React.VoidFunctionComponent = () => (
43
44
  <Route path="/orcid-link" element={<OrcidLinkPage />} />
44
45
  <Route
45
46
  path="/user/:orcid/*"
46
- element={
47
- <>
48
- <LoggedIn>
49
- <UserQuery />
50
- </LoggedIn>
51
- <LoggedOut>
52
- <FourOThreePage />
53
- </LoggedOut>
54
- </>
55
- }
47
+ element={<UserQuery />}
56
48
  />
57
49
  <Route
58
50
  path="/saved"
@@ -8,12 +8,13 @@ export interface User {
8
8
  avatar?: string
9
9
  orcid?: string
10
10
  links?: string[]
11
- githubSynced?: Date
12
11
  admin?: boolean
13
- provider?: string
14
- created?: Date
15
- lastSeen?: Date
16
12
  blocked?: boolean
13
+ lastSeen?: string
14
+ created?: string
15
+ provider?: string
16
+ modified?: string
17
+ githubSynced?: Date
17
18
  }
18
19
 
19
20
  export interface UserRoutesProps {
@@ -24,8 +24,8 @@ const baseUser: User = {
24
24
  links: [],
25
25
  admin: false,
26
26
  provider: "orcid",
27
- created: new Date("2025-05-20T14:50:32.424Z"),
28
- lastSeen: new Date("2025-05-20T14:50:32.424Z"),
27
+ created: new Date("2025-05-20T14:50:32.424Z").toISOString(),
28
+ lastSeen: new Date("2025-05-20T14:50:32.424Z").toISOString(),
29
29
  blocked: false,
30
30
  githubSynced: null,
31
31
  }
@@ -21,12 +21,18 @@ export const UserCard: React.FC<UserCardProps> = ({ orcidUser }) => {
21
21
  {location}
22
22
  </li>
23
23
  )}
24
- <li>
25
- <i className="fas fa-envelope"></i>
26
- <a href={"mailto:" + email} target="_blank" rel="noopener noreferrer">
27
- {email}
28
- </a>
29
- </li>
24
+ {email && (
25
+ <li>
26
+ <i className="fas fa-envelope"></i>
27
+ <a
28
+ href={"mailto:" + email}
29
+ target="_blank"
30
+ rel="noopener noreferrer"
31
+ >
32
+ {email}
33
+ </a>
34
+ </li>
35
+ )}
30
36
  {orcid && (
31
37
  <li className={styles.orcid}>
32
38
  <i className="fab fa-orcid" aria-hidden="true"></i>
@@ -27,9 +27,6 @@ export const UserQuery: React.FC = () => {
27
27
  return <FourOFourPage />
28
28
  }
29
29
 
30
- if (!profile || !profile.sub) {
31
- return <FourOFourPage />
32
- }
33
30
  // is admin or profile matches id from the user data being returned
34
31
  const isUser = (user?.id === profile?.sub) ? true : false
35
32
  const hasEdit = isAdminUser || (user?.id === profile?.sub) ? true : false