@openneuro/app 4.27.0 → 4.28.0-alpha.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 +4 -4
- package/src/scripts/datalad/mutations/delete.jsx +1 -1
- package/src/scripts/dataset/mutations/delete.jsx +1 -1
- package/src/scripts/pages/admin/__tests__/users.spec.tsx +89 -0
- package/src/scripts/pages/admin/admin.jsx +1 -1
- package/src/scripts/pages/admin/users.tsx +185 -0
- package/src/scripts/pages/admin/users.jsx +0 -202
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openneuro/app",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.28.0-alpha.1",
|
|
4
4
|
"description": "React JS web frontend for the OpenNeuro platform.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"main": "public/client.js",
|
|
@@ -19,8 +19,8 @@
|
|
|
19
19
|
"@emotion/react": "11.11.1",
|
|
20
20
|
"@emotion/styled": "11.11.0",
|
|
21
21
|
"@niivue/niivue": "0.34.0",
|
|
22
|
-
"@openneuro/client": "^4.
|
|
23
|
-
"@openneuro/components": "^4.
|
|
22
|
+
"@openneuro/client": "^4.28.0-alpha.1",
|
|
23
|
+
"@openneuro/components": "^4.28.0-alpha.1",
|
|
24
24
|
"@sentry/react": "^8.25.0",
|
|
25
25
|
"@tanstack/react-table": "^8.9.3",
|
|
26
26
|
"bids-validator": "1.14.8",
|
|
@@ -75,5 +75,5 @@
|
|
|
75
75
|
"publishConfig": {
|
|
76
76
|
"access": "public"
|
|
77
77
|
},
|
|
78
|
-
"gitHead": "
|
|
78
|
+
"gitHead": "ed13f51c2529a7827bfa8ba8de98f1dc7b68aba7"
|
|
79
79
|
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import React from "react"
|
|
2
|
+
import { fireEvent, render, screen } from "@testing-library/react"
|
|
3
|
+
import { MockedProvider } from "@apollo/client/testing"
|
|
4
|
+
import { GET_USERS, UsersQuery } from "../users"
|
|
5
|
+
import { vi } from "vitest"
|
|
6
|
+
|
|
7
|
+
// Mock admin login
|
|
8
|
+
vi.mock("../../../authentication/profile", (_importOriginal) => {
|
|
9
|
+
return {
|
|
10
|
+
getProfile: () => {
|
|
11
|
+
return {
|
|
12
|
+
sub: "1234",
|
|
13
|
+
admin: true,
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
}
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
const users = [
|
|
20
|
+
{
|
|
21
|
+
__typename: "User",
|
|
22
|
+
id: "77106418-0daf-4800-a17c-71657ede4b21",
|
|
23
|
+
name: "Test Users",
|
|
24
|
+
admin: false,
|
|
25
|
+
blocked: false,
|
|
26
|
+
email: "test@example.com",
|
|
27
|
+
provider: "test",
|
|
28
|
+
lastSeen: "2018-09-24T19:26:07.704Z",
|
|
29
|
+
created: "2016-09-24T19:26:07.704Z",
|
|
30
|
+
},
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
describe("Users", () => {
|
|
34
|
+
it("renders users", async () => {
|
|
35
|
+
const mocks = [
|
|
36
|
+
{
|
|
37
|
+
delay: 30,
|
|
38
|
+
request: { query: GET_USERS },
|
|
39
|
+
result: {
|
|
40
|
+
data: { users },
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
render(
|
|
46
|
+
<MockedProvider mocks={mocks}>
|
|
47
|
+
<UsersQuery />
|
|
48
|
+
</MockedProvider>,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
expect(await screen.findByText("Current Users")).toBeInTheDocument()
|
|
52
|
+
expect(await screen.findByText(users[0].name)).toBeInTheDocument()
|
|
53
|
+
})
|
|
54
|
+
it("handles filtering users with no email", async () => {
|
|
55
|
+
const emailLessUsers = [...users, {
|
|
56
|
+
__typename: "User",
|
|
57
|
+
id: "db3e7a0b-950b-4951-9059-c003ca3c1669",
|
|
58
|
+
name: "New User",
|
|
59
|
+
admin: false,
|
|
60
|
+
email: null,
|
|
61
|
+
blocked: false,
|
|
62
|
+
provider: "orcid",
|
|
63
|
+
lastSeen: "2019-09-24T19:26:07.704Z",
|
|
64
|
+
created: "2013-09-24T19:26:07.704Z",
|
|
65
|
+
}]
|
|
66
|
+
const mocks = [
|
|
67
|
+
{
|
|
68
|
+
delay: 30,
|
|
69
|
+
request: { query: GET_USERS },
|
|
70
|
+
result: {
|
|
71
|
+
data: { users: emailLessUsers },
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
render(
|
|
77
|
+
<MockedProvider mocks={mocks}>
|
|
78
|
+
<UsersQuery />
|
|
79
|
+
</MockedProvider>,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
const input = await screen.findByPlaceholderText("Search Name or Email")
|
|
83
|
+
fireEvent.change(input, { target: { value: "test" } })
|
|
84
|
+
fireEvent.keyDown(input, { key: "a" })
|
|
85
|
+
|
|
86
|
+
expect(await screen.findByText("Current Users")).toBeInTheDocument()
|
|
87
|
+
expect(await screen.findByText(users[0].name)).toBeInTheDocument()
|
|
88
|
+
})
|
|
89
|
+
})
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import React from "react"
|
|
4
4
|
import { Navigate, NavLink, Route, Routes } from "react-router-dom"
|
|
5
|
-
import Users from "./users
|
|
5
|
+
import Users from "./users"
|
|
6
6
|
import FlaggedFiles from "./flagged-files.jsx"
|
|
7
7
|
import AdminUser from "../../authentication/admin-user.jsx"
|
|
8
8
|
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import React, { useState } from "react"
|
|
2
|
+
import { Query } from "@apollo/client/react/components"
|
|
3
|
+
import { gql } from "@apollo/client"
|
|
4
|
+
import parseISO from "date-fns/parseISO"
|
|
5
|
+
import formatDistanceToNow from "date-fns/formatDistanceToNow"
|
|
6
|
+
import { Input } from "@openneuro/components/input"
|
|
7
|
+
import { Loading } from "@openneuro/components/loading"
|
|
8
|
+
import { formatDate } from "../../utils/date.js"
|
|
9
|
+
import Helmet from "react-helmet"
|
|
10
|
+
import { pageTitle } from "../../resources/strings.js"
|
|
11
|
+
import { UserTools } from "./user-tools.js"
|
|
12
|
+
import { USER_FRAGMENT } from "./user-fragment.js"
|
|
13
|
+
|
|
14
|
+
export const GET_USERS = gql`
|
|
15
|
+
query {
|
|
16
|
+
users {
|
|
17
|
+
...userFields
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
${USER_FRAGMENT}
|
|
21
|
+
`
|
|
22
|
+
|
|
23
|
+
// TODO - Use the GraphQL type
|
|
24
|
+
export interface User {
|
|
25
|
+
id: string
|
|
26
|
+
name: string
|
|
27
|
+
admin: boolean
|
|
28
|
+
blocked: boolean
|
|
29
|
+
email?: string
|
|
30
|
+
provider: string
|
|
31
|
+
lastSeen?: string
|
|
32
|
+
created: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface UsersQueryResultProps {
|
|
36
|
+
loading: boolean
|
|
37
|
+
data: { users: User[] }
|
|
38
|
+
refetch: () => void
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const UsersQueryResult = (
|
|
42
|
+
{ loading, data, refetch }: UsersQueryResultProps,
|
|
43
|
+
) => {
|
|
44
|
+
if (loading) {
|
|
45
|
+
return <Loading />
|
|
46
|
+
} else {
|
|
47
|
+
return (
|
|
48
|
+
<Users loading={loading} users={data.users || []} refetch={refetch} />
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export const UsersQuery = () => (
|
|
54
|
+
<Query query={GET_USERS}>{UsersQueryResult}</Query>
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
const userSummary = (user) => {
|
|
58
|
+
const lastLogin = user.lastlogin ? user.lastlogin : user.created
|
|
59
|
+
const created = user.created
|
|
60
|
+
return (
|
|
61
|
+
<>
|
|
62
|
+
<div className="summary-data">
|
|
63
|
+
<b>Signed Up:</b>{" "}
|
|
64
|
+
<div>
|
|
65
|
+
{formatDate(created)} - {formatDistanceToNow(parseISO(created))} ago
|
|
66
|
+
</div>
|
|
67
|
+
</div>
|
|
68
|
+
<div className="summary-data">
|
|
69
|
+
<b>Last Signed In:</b>{" "}
|
|
70
|
+
<div>
|
|
71
|
+
{formatDate(lastLogin)} - {formatDistanceToNow(parseISO(lastLogin))}
|
|
72
|
+
{" "}
|
|
73
|
+
ago
|
|
74
|
+
</div>
|
|
75
|
+
</div>
|
|
76
|
+
</>
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const noResults = (loading) => {
|
|
81
|
+
return loading ? <Loading /> : <h4>No Results Found</h4>
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const Users = ({ users, refetch, loading }) => {
|
|
85
|
+
const [stringFilter, setStringFilter] = useState(null)
|
|
86
|
+
const [adminFilter, setAdminFilter] = useState(false)
|
|
87
|
+
const [blacklistFilter, setBlacklistFilter] = useState(false)
|
|
88
|
+
|
|
89
|
+
const filteredUsers = users
|
|
90
|
+
.filter((user) => !adminFilter || user.admin)
|
|
91
|
+
.filter(
|
|
92
|
+
(user) =>
|
|
93
|
+
!stringFilter ||
|
|
94
|
+
user.email?.toLowerCase().includes(stringFilter.toLowerCase()) ||
|
|
95
|
+
user.name?.toLowerCase().includes(stringFilter.toLowerCase()),
|
|
96
|
+
)
|
|
97
|
+
.map((user, index) => {
|
|
98
|
+
const adminBadge = user.admin ? "Admin" : null
|
|
99
|
+
const userEmail = user.hasOwnProperty("email") ? user.email : user.id
|
|
100
|
+
return (
|
|
101
|
+
<div className="fade-in user-panel panel panel-default" key={index}>
|
|
102
|
+
<div className="user-col uc-name">
|
|
103
|
+
<div>
|
|
104
|
+
{user.name}{" "}
|
|
105
|
+
{adminBadge && <span className="badge">{adminBadge}</span>}
|
|
106
|
+
<UserTools user={user} refetch={refetch} />
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
109
|
+
<div className="user-col user-panel-inner">
|
|
110
|
+
<div className=" user-col uc-email">
|
|
111
|
+
{userEmail}
|
|
112
|
+
<div className=" uc-provider">
|
|
113
|
+
<b>Provider:</b> {user.provider}
|
|
114
|
+
</div>
|
|
115
|
+
</div>
|
|
116
|
+
|
|
117
|
+
<div className=" user-col uc-summary">{userSummary(user)}</div>
|
|
118
|
+
</div>
|
|
119
|
+
</div>
|
|
120
|
+
)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
return (
|
|
124
|
+
<>
|
|
125
|
+
<Helmet>
|
|
126
|
+
<title>Admin Dashboard - {pageTitle}</title>
|
|
127
|
+
</Helmet>
|
|
128
|
+
<div className="admin-users">
|
|
129
|
+
<div className="header-wrap ">
|
|
130
|
+
<h2>Current Users</h2>
|
|
131
|
+
|
|
132
|
+
<Input
|
|
133
|
+
name="Search Name Or Email"
|
|
134
|
+
type="text"
|
|
135
|
+
placeholder="Search Name or Email"
|
|
136
|
+
onKeyDown={(e) => setStringFilter(e.target.value)}
|
|
137
|
+
setValue={(_) => {}}
|
|
138
|
+
/>
|
|
139
|
+
</div>
|
|
140
|
+
|
|
141
|
+
<div className="filters-sort-wrap ">
|
|
142
|
+
<span>
|
|
143
|
+
<div className="filters">
|
|
144
|
+
<label>Filter By:</label>
|
|
145
|
+
<button
|
|
146
|
+
className={adminFilter ? "active" : null}
|
|
147
|
+
onClick={() => setAdminFilter(!adminFilter)}
|
|
148
|
+
>
|
|
149
|
+
<span className="filter-admin">
|
|
150
|
+
<i
|
|
151
|
+
className={adminFilter
|
|
152
|
+
? "fa fa-check-square-o"
|
|
153
|
+
: "fa fa-square-o"}
|
|
154
|
+
/>{" "}
|
|
155
|
+
Admin
|
|
156
|
+
</span>
|
|
157
|
+
</button>
|
|
158
|
+
<button
|
|
159
|
+
className={blacklistFilter ? "active" : null}
|
|
160
|
+
onClick={() => setBlacklistFilter(!blacklistFilter)}
|
|
161
|
+
>
|
|
162
|
+
<span className="filter-admin">
|
|
163
|
+
<i
|
|
164
|
+
className={blacklistFilter
|
|
165
|
+
? "fa fa-check-square-o"
|
|
166
|
+
: "fa fa-square-o"}
|
|
167
|
+
/>{" "}
|
|
168
|
+
Blocked
|
|
169
|
+
</span>
|
|
170
|
+
</button>
|
|
171
|
+
</div>
|
|
172
|
+
</span>
|
|
173
|
+
</div>
|
|
174
|
+
|
|
175
|
+
<div>
|
|
176
|
+
<div className="users-panel-wrap">
|
|
177
|
+
{filteredUsers.length ? filteredUsers : noResults(loading)}
|
|
178
|
+
</div>
|
|
179
|
+
</div>
|
|
180
|
+
</div>
|
|
181
|
+
</>
|
|
182
|
+
)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export default UsersQuery
|
|
@@ -1,202 +0,0 @@
|
|
|
1
|
-
// dependencies -------------------------------------------------------
|
|
2
|
-
|
|
3
|
-
import React from "react"
|
|
4
|
-
import PropTypes from "prop-types"
|
|
5
|
-
import { Query } from "@apollo/client/react/components"
|
|
6
|
-
import { gql } from "@apollo/client"
|
|
7
|
-
import parseISO from "date-fns/parseISO"
|
|
8
|
-
import formatDistanceToNow from "date-fns/formatDistanceToNow"
|
|
9
|
-
import { Input } from "@openneuro/components/input"
|
|
10
|
-
import { Loading } from "@openneuro/components/loading"
|
|
11
|
-
import { formatDate } from "../../utils/date.js"
|
|
12
|
-
import Helmet from "react-helmet"
|
|
13
|
-
import { pageTitle } from "../../resources/strings.js"
|
|
14
|
-
import { UserTools } from "./user-tools"
|
|
15
|
-
import { USER_FRAGMENT } from "./user-fragment"
|
|
16
|
-
|
|
17
|
-
export const GET_USERS = gql`
|
|
18
|
-
query {
|
|
19
|
-
users {
|
|
20
|
-
...userFields
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
${USER_FRAGMENT}
|
|
24
|
-
`
|
|
25
|
-
|
|
26
|
-
export const UsersQueryResult = ({ loading, data, refetch }) => {
|
|
27
|
-
if (loading) {
|
|
28
|
-
return <Loading />
|
|
29
|
-
} else {
|
|
30
|
-
return (
|
|
31
|
-
<Users loading={loading} users={data.users || []} refetch={refetch} />
|
|
32
|
-
)
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export const UsersQuery = () => (
|
|
37
|
-
<Query query={GET_USERS}>{UsersQueryResult}</Query>
|
|
38
|
-
)
|
|
39
|
-
|
|
40
|
-
UsersQueryResult.propTypes = {
|
|
41
|
-
loading: PropTypes.bool,
|
|
42
|
-
data: PropTypes.object,
|
|
43
|
-
refetch: PropTypes.func,
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
class Users extends React.Component {
|
|
47
|
-
constructor(props) {
|
|
48
|
-
super(props)
|
|
49
|
-
this.state = {
|
|
50
|
-
stringFilter: null,
|
|
51
|
-
adminFilter: false,
|
|
52
|
-
blacklistFilter: false,
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
// life cycle events --------------------------------------------------
|
|
57
|
-
render() {
|
|
58
|
-
const users = this.props.users.map((user, index) => {
|
|
59
|
-
const adminBadge = user.admin ? "Admin" : null
|
|
60
|
-
const userEmail = user.hasOwnProperty("email") ? user.email : user.id
|
|
61
|
-
if (this.state.adminFilter && !user.admin) {
|
|
62
|
-
return null
|
|
63
|
-
}
|
|
64
|
-
if (this.state.stringFilter) {
|
|
65
|
-
const stringFilter = this.state.stringFilter.toLowerCase()
|
|
66
|
-
if (
|
|
67
|
-
!(
|
|
68
|
-
user.email.toLowerCase().includes(stringFilter) ||
|
|
69
|
-
user.name.toLowerCase().includes(stringFilter)
|
|
70
|
-
)
|
|
71
|
-
) {
|
|
72
|
-
return null
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
return (
|
|
76
|
-
<div className="fade-in user-panel panel panel-default" key={index}>
|
|
77
|
-
<div className="user-col uc-name">
|
|
78
|
-
<div>
|
|
79
|
-
{user.name}{" "}
|
|
80
|
-
{adminBadge && <span className="badge">{adminBadge}</span>}
|
|
81
|
-
<UserTools user={user} refetch={this.props.refetch} />
|
|
82
|
-
</div>
|
|
83
|
-
</div>
|
|
84
|
-
<div className="user-col user-panel-inner">
|
|
85
|
-
<div className=" user-col uc-email">
|
|
86
|
-
{userEmail}
|
|
87
|
-
<div className=" uc-provider">
|
|
88
|
-
<b>Provider:</b> {user.provider}
|
|
89
|
-
</div>
|
|
90
|
-
</div>
|
|
91
|
-
|
|
92
|
-
<div className=" user-col uc-summary">
|
|
93
|
-
{this._userSummary(user)}
|
|
94
|
-
</div>
|
|
95
|
-
</div>
|
|
96
|
-
</div>
|
|
97
|
-
)
|
|
98
|
-
})
|
|
99
|
-
return (
|
|
100
|
-
<>
|
|
101
|
-
<Helmet>
|
|
102
|
-
<title>Admin Dashboard - {pageTitle}</title>
|
|
103
|
-
</Helmet>
|
|
104
|
-
<div className="admin-users">
|
|
105
|
-
<div className="header-wrap ">
|
|
106
|
-
<h2>Current Users</h2>
|
|
107
|
-
|
|
108
|
-
<Input
|
|
109
|
-
name="Search Name Or Email"
|
|
110
|
-
type="text"
|
|
111
|
-
placeholder="Search Name or Email"
|
|
112
|
-
onKeyDown={(e) => this.setState({ stringFilter: e.target.value })}
|
|
113
|
-
/>
|
|
114
|
-
</div>
|
|
115
|
-
|
|
116
|
-
<div className="filters-sort-wrap ">
|
|
117
|
-
<span>
|
|
118
|
-
<div className="filters">
|
|
119
|
-
<label>Filter By:</label>
|
|
120
|
-
<button
|
|
121
|
-
className={this.state.adminFilter ? "active" : null}
|
|
122
|
-
onClick={() =>
|
|
123
|
-
this.setState({ adminFilter: !this.state.adminFilter })}
|
|
124
|
-
>
|
|
125
|
-
<span className="filter-admin">
|
|
126
|
-
<i
|
|
127
|
-
className={this.state.adminFilter
|
|
128
|
-
? "fa fa-check-square-o"
|
|
129
|
-
: "fa fa-square-o"}
|
|
130
|
-
/>{" "}
|
|
131
|
-
Admin
|
|
132
|
-
</span>
|
|
133
|
-
</button>
|
|
134
|
-
<button
|
|
135
|
-
className={this.state.blacklistFilter ? "active" : null}
|
|
136
|
-
onClick={() =>
|
|
137
|
-
this.setState({
|
|
138
|
-
blacklistFilter: !this.state.blacklistFilter,
|
|
139
|
-
})}
|
|
140
|
-
>
|
|
141
|
-
<span className="filter-admin">
|
|
142
|
-
<i
|
|
143
|
-
className={this.state.blacklistFilter
|
|
144
|
-
? "fa fa-check-square-o"
|
|
145
|
-
: "fa fa-square-o"}
|
|
146
|
-
/>{" "}
|
|
147
|
-
Blocked
|
|
148
|
-
</span>
|
|
149
|
-
</button>
|
|
150
|
-
</div>
|
|
151
|
-
</span>
|
|
152
|
-
</div>
|
|
153
|
-
|
|
154
|
-
<div>
|
|
155
|
-
<div className="users-panel-wrap">
|
|
156
|
-
{users.filter((u) => u !== null).length
|
|
157
|
-
? users
|
|
158
|
-
: this._noResults()}
|
|
159
|
-
</div>
|
|
160
|
-
</div>
|
|
161
|
-
</div>
|
|
162
|
-
</>
|
|
163
|
-
)
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
// custom methods -----------------------------------------------------
|
|
167
|
-
|
|
168
|
-
_noResults() {
|
|
169
|
-
return this.state.loading ? <Loading /> : <h4>No Results Found</h4>
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
_userSummary(user) {
|
|
173
|
-
const lastLogin = user.lastlogin ? user.lastlogin : user.created
|
|
174
|
-
const created = user.created
|
|
175
|
-
return (
|
|
176
|
-
<>
|
|
177
|
-
<div className="summary-data">
|
|
178
|
-
<b>Signed Up:</b>{" "}
|
|
179
|
-
<div>
|
|
180
|
-
{formatDate(created)} - {formatDistanceToNow(parseISO(created))} ago
|
|
181
|
-
</div>
|
|
182
|
-
</div>
|
|
183
|
-
<div className="summary-data">
|
|
184
|
-
<b>Last Signed In:</b>{" "}
|
|
185
|
-
<div>
|
|
186
|
-
{formatDate(lastLogin)} - {formatDistanceToNow(parseISO(lastLogin))}
|
|
187
|
-
{" "}
|
|
188
|
-
ago
|
|
189
|
-
</div>
|
|
190
|
-
</div>
|
|
191
|
-
</>
|
|
192
|
-
)
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
Users.propTypes = {
|
|
197
|
-
users: PropTypes.array,
|
|
198
|
-
loading: PropTypes.bool,
|
|
199
|
-
refetch: PropTypes.func,
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
export default UsersQuery
|