@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.
- package/package.json +2 -2
- package/src/scripts/components/page/page.scss +2 -70
- package/src/scripts/pages/admin/__tests__/users.spec.tsx +51 -18
- package/src/scripts/pages/admin/admin.jsx +2 -2
- package/src/scripts/pages/admin/user-fragment.ts +10 -3
- package/src/scripts/pages/admin/user-summary.tsx +100 -0
- package/src/scripts/pages/admin/user-tools.tsx +81 -58
- package/src/scripts/pages/admin/users.module.scss +277 -0
- package/src/scripts/pages/admin/users.tsx +351 -152
- package/src/scripts/queries/users.ts +247 -0
- package/src/scripts/routes.tsx +7 -15
- package/src/scripts/types/user-types.ts +5 -4
- package/src/scripts/users/__tests__/user-account-view.spec.tsx +2 -2
- package/src/scripts/users/user-card.tsx +12 -6
- package/src/scripts/users/user-query.tsx +0 -3
|
@@ -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
|
+
}
|
package/src/scripts/routes.tsx
CHANGED
|
@@ -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
|
|
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
|
-
|
|
25
|
-
<
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|