@openneuro/app 4.36.0-alpha.0 → 4.36.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 +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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openneuro/app",
|
|
3
|
-
"version": "4.36.0
|
|
3
|
+
"version": "4.36.0",
|
|
4
4
|
"description": "React JS web frontend for the OpenNeuro platform.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"main": "public/client.js",
|
|
@@ -75,5 +75,5 @@
|
|
|
75
75
|
"publishConfig": {
|
|
76
76
|
"access": "public"
|
|
77
77
|
},
|
|
78
|
-
"gitHead": "
|
|
78
|
+
"gitHead": "ac7fa1782779cfdf1f46c9a5add8865155177b99"
|
|
79
79
|
}
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
// admin page
|
|
28
28
|
.admin.route-wrapper {
|
|
29
29
|
margin: 20px auto;
|
|
30
|
-
max-width:
|
|
30
|
+
max-width: 1500px;
|
|
31
31
|
width: 90%;
|
|
32
32
|
.nav-pills.tabs {
|
|
33
33
|
display: flex;
|
|
@@ -39,80 +39,12 @@
|
|
|
39
39
|
display: block;
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
|
-
|
|
43
|
-
display: inline-block;
|
|
44
|
-
appearance: none;
|
|
45
|
-
box-sizing: border-box;
|
|
46
|
-
border-radius: 0;
|
|
47
|
-
border: 0.1rem solid #dfdfdf;
|
|
48
|
-
padding: 10px 12px;
|
|
49
|
-
max-width: 100%;
|
|
50
|
-
line-height: 12px;
|
|
51
|
-
}
|
|
42
|
+
|
|
52
43
|
.header-wrap {
|
|
53
44
|
display: flex;
|
|
54
45
|
justify-content: space-between;
|
|
55
46
|
align-items: center;
|
|
56
47
|
}
|
|
57
|
-
|
|
58
|
-
.filters {
|
|
59
|
-
button {
|
|
60
|
-
margin: 0 10px;
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
.users-panel-wrap {
|
|
64
|
-
.user-panel {
|
|
65
|
-
display: flex;
|
|
66
|
-
justify-content: space-between;
|
|
67
|
-
margin: 20px 0;
|
|
68
|
-
border: 1px solid #ddd;
|
|
69
|
-
border-radius: 4px;
|
|
70
|
-
padding: 10px;
|
|
71
|
-
> .user-col {
|
|
72
|
-
flex: auto;
|
|
73
|
-
label {
|
|
74
|
-
display: block;
|
|
75
|
-
font-weight: 600;
|
|
76
|
-
font-size: 14px;
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
.user-panel-inner {
|
|
80
|
-
display: flex;
|
|
81
|
-
justify-content: space-between;
|
|
82
|
-
> .user-col {
|
|
83
|
-
flex: 1;
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
.uc-summary .summary-data,
|
|
87
|
-
.uc-provider {
|
|
88
|
-
font-size: 12px;
|
|
89
|
-
margin: 0 0 10px;
|
|
90
|
-
}
|
|
91
|
-
.uc-name {
|
|
92
|
-
font-size: 20px;
|
|
93
|
-
display: flex;
|
|
94
|
-
align-items: flex-start;
|
|
95
|
-
.badge {
|
|
96
|
-
font-size: 12px;
|
|
97
|
-
color: #fff;
|
|
98
|
-
background: red;
|
|
99
|
-
display: inline-block;
|
|
100
|
-
padding: 5px;
|
|
101
|
-
border-radius: 4px;
|
|
102
|
-
font-weight: bold;
|
|
103
|
-
}
|
|
104
|
-
.dataset-tools-wrap-admin .tools {
|
|
105
|
-
display: flex;
|
|
106
|
-
button {
|
|
107
|
-
background-color: transparent;
|
|
108
|
-
border: 0;
|
|
109
|
-
outline: 0;
|
|
110
|
-
font-size: 12px;
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
48
|
}
|
|
117
49
|
|
|
118
50
|
//api page
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import React from "react"
|
|
2
2
|
import { fireEvent, render, screen } from "@testing-library/react"
|
|
3
3
|
import { MockedProvider } from "@apollo/client/testing"
|
|
4
|
-
import { GET_USERS, UsersQuery } from "../users"
|
|
5
4
|
import { vi } from "vitest"
|
|
5
|
+
import { UserQuery } from "../../../users/user-query"
|
|
6
|
+
import { GET_USERS } from "../../../queries/users"
|
|
7
|
+
import { MemoryRouter, Route, Routes } from "react-router-dom"
|
|
6
8
|
|
|
7
9
|
// Mock admin login
|
|
8
10
|
vi.mock("../../../authentication/profile", (_importOriginal) => {
|
|
@@ -16,6 +18,21 @@ vi.mock("../../../authentication/profile", (_importOriginal) => {
|
|
|
16
18
|
}
|
|
17
19
|
})
|
|
18
20
|
|
|
21
|
+
// MOCK THE USERQUERY COMPONENT
|
|
22
|
+
|
|
23
|
+
vi.mock("../../../users/user-query", () => {
|
|
24
|
+
return {
|
|
25
|
+
UserQuery: vi.fn(() => (
|
|
26
|
+
<div data-testid="mock-user-query-admin-view">
|
|
27
|
+
<h3>Current Users (Mocked UserQuery)</h3>
|
|
28
|
+
<input type="text" placeholder="Search Name or Email" />
|
|
29
|
+
<div data-testid="user-list-container">
|
|
30
|
+
</div>
|
|
31
|
+
</div>
|
|
32
|
+
)),
|
|
33
|
+
}
|
|
34
|
+
})
|
|
35
|
+
|
|
19
36
|
const users = [
|
|
20
37
|
{
|
|
21
38
|
__typename: "User",
|
|
@@ -31,6 +48,10 @@ const users = [
|
|
|
31
48
|
]
|
|
32
49
|
|
|
33
50
|
describe("Users", () => {
|
|
51
|
+
beforeEach(() => {
|
|
52
|
+
vi.clearAllMocks()
|
|
53
|
+
})
|
|
54
|
+
|
|
34
55
|
it("renders users", async () => {
|
|
35
56
|
const mocks = [
|
|
36
57
|
{
|
|
@@ -44,25 +65,33 @@ describe("Users", () => {
|
|
|
44
65
|
|
|
45
66
|
render(
|
|
46
67
|
<MockedProvider mocks={mocks}>
|
|
47
|
-
<
|
|
68
|
+
<MemoryRouter initialEntries={["/admin/users"]}>
|
|
69
|
+
<Routes>
|
|
70
|
+
<Route path="/admin/users" element={<UserQuery />} />
|
|
71
|
+
</Routes>
|
|
72
|
+
</MemoryRouter>
|
|
48
73
|
</MockedProvider>,
|
|
49
74
|
)
|
|
50
75
|
|
|
51
|
-
expect(await screen.findByText("Current Users"))
|
|
52
|
-
|
|
76
|
+
expect(await screen.findByText("Current Users (Mocked UserQuery)"))
|
|
77
|
+
.toBeInTheDocument()
|
|
53
78
|
})
|
|
79
|
+
|
|
54
80
|
it("handles filtering users with no email", async () => {
|
|
55
|
-
const emailLessUsers = [
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
81
|
+
const emailLessUsers = [
|
|
82
|
+
...users,
|
|
83
|
+
{
|
|
84
|
+
__typename: "User",
|
|
85
|
+
id: "db3e7a0b-950b-4951-9059-c003ca3c1669",
|
|
86
|
+
name: "New User",
|
|
87
|
+
admin: false,
|
|
88
|
+
email: null,
|
|
89
|
+
blocked: false,
|
|
90
|
+
provider: "orcid",
|
|
91
|
+
lastSeen: "2019-09-24T19:26:07.704Z",
|
|
92
|
+
created: "2013-09-24T19:26:07.704Z",
|
|
93
|
+
},
|
|
94
|
+
]
|
|
66
95
|
const mocks = [
|
|
67
96
|
{
|
|
68
97
|
delay: 30,
|
|
@@ -75,7 +104,11 @@ describe("Users", () => {
|
|
|
75
104
|
|
|
76
105
|
render(
|
|
77
106
|
<MockedProvider mocks={mocks}>
|
|
78
|
-
<
|
|
107
|
+
<MemoryRouter initialEntries={["/admin/users"]}>
|
|
108
|
+
<Routes>
|
|
109
|
+
<Route path="/admin/users" element={<UserQuery />} />
|
|
110
|
+
</Routes>
|
|
111
|
+
</MemoryRouter>
|
|
79
112
|
</MockedProvider>,
|
|
80
113
|
)
|
|
81
114
|
|
|
@@ -83,7 +116,7 @@ describe("Users", () => {
|
|
|
83
116
|
fireEvent.change(input, { target: { value: "test" } })
|
|
84
117
|
fireEvent.keyDown(input, { key: "a" })
|
|
85
118
|
|
|
86
|
-
expect(await screen.findByText("Current Users"))
|
|
87
|
-
|
|
119
|
+
expect(await screen.findByText("Current Users (Mocked UserQuery)"))
|
|
120
|
+
.toBeInTheDocument()
|
|
88
121
|
})
|
|
89
122
|
})
|
|
@@ -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
|
|
5
|
+
import { UsersPage } from "./users"
|
|
6
6
|
import FlaggedFiles from "./flagged-files.jsx"
|
|
7
7
|
import AdminUser from "../../authentication/admin-user.jsx"
|
|
8
8
|
|
|
@@ -28,7 +28,7 @@ class Dashboard extends React.Component {
|
|
|
28
28
|
</li>
|
|
29
29
|
</ul>
|
|
30
30
|
<Routes>
|
|
31
|
-
<Route path="/users" element={<
|
|
31
|
+
<Route path="/users" element={<UsersPage />} />
|
|
32
32
|
<Route path="/flagged-files" element={<FlaggedFiles />} />
|
|
33
33
|
<Route
|
|
34
34
|
path="/"
|
|
@@ -4,11 +4,18 @@ export const USER_FRAGMENT = gql`
|
|
|
4
4
|
fragment userFields on User {
|
|
5
5
|
id
|
|
6
6
|
name
|
|
7
|
+
admin
|
|
8
|
+
blocked
|
|
7
9
|
email
|
|
8
10
|
provider
|
|
9
|
-
admin
|
|
10
|
-
created
|
|
11
11
|
lastSeen
|
|
12
|
-
|
|
12
|
+
created
|
|
13
|
+
avatar
|
|
14
|
+
github
|
|
15
|
+
institution
|
|
16
|
+
location
|
|
17
|
+
modified
|
|
18
|
+
orcid
|
|
19
|
+
|
|
13
20
|
}
|
|
14
21
|
`
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import React from "react"
|
|
2
|
+
import parseISO from "date-fns/parseISO"
|
|
3
|
+
import formatDistanceToNow from "date-fns/formatDistanceToNow"
|
|
4
|
+
import { formatDate } from "../../utils/date.js"
|
|
5
|
+
import type { User } from "../../types/user-types"
|
|
6
|
+
import styles from "./users.module.scss"
|
|
7
|
+
import { Tooltip } from "../../components/tooltip/Tooltip"
|
|
8
|
+
import { UserTools } from "./user-tools.js"
|
|
9
|
+
|
|
10
|
+
interface UserSummaryProps {
|
|
11
|
+
user: User
|
|
12
|
+
refetchCurrentPage: () => void
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const UserSummary = ({ user, refetchCurrentPage }: UserSummaryProps) => {
|
|
16
|
+
const adminBadge = user.admin
|
|
17
|
+
? (
|
|
18
|
+
<Tooltip tooltip="Admin">
|
|
19
|
+
<span className={`${styles.badge} ${styles.admin}`}>
|
|
20
|
+
<i className="fa fa-star"></i>
|
|
21
|
+
</span>
|
|
22
|
+
</Tooltip>
|
|
23
|
+
)
|
|
24
|
+
: null
|
|
25
|
+
const blockedBadge = user.blocked
|
|
26
|
+
? (
|
|
27
|
+
<Tooltip tooltip="Blocked">
|
|
28
|
+
<span className={`${styles.badge} ${styles.blocked}`}>
|
|
29
|
+
<i className="fa fa-lock"></i>
|
|
30
|
+
</span>
|
|
31
|
+
</Tooltip>
|
|
32
|
+
)
|
|
33
|
+
: (
|
|
34
|
+
<Tooltip tooltip="Active">
|
|
35
|
+
<span className={`${styles.badge}`}>
|
|
36
|
+
<i className="fa fa-lock-open"></i>
|
|
37
|
+
</span>
|
|
38
|
+
</Tooltip>
|
|
39
|
+
)
|
|
40
|
+
const userEmail = <a href={`mailto:${user.email}`}>{user.email}</a>
|
|
41
|
+
const userOrcid = <a href={`/user/${user.orcid}`}>{user.orcid}</a>
|
|
42
|
+
return (
|
|
43
|
+
<div className={styles.gridRow}>
|
|
44
|
+
<div className={`${styles.gtCell} ${styles.colLarge}`}>
|
|
45
|
+
<h3>
|
|
46
|
+
{user.name}
|
|
47
|
+
{adminBadge && adminBadge}
|
|
48
|
+
{blockedBadge}
|
|
49
|
+
<br />
|
|
50
|
+
</h3>
|
|
51
|
+
</div>
|
|
52
|
+
|
|
53
|
+
<div className={`${styles.gtCell} ${styles.colXLarge}`}>
|
|
54
|
+
<div>{user.email && userEmail}</div>
|
|
55
|
+
{user.orcid && userOrcid}
|
|
56
|
+
<span>
|
|
57
|
+
Provider:{" "}
|
|
58
|
+
<b style={{ textTransform: "uppercase" }}>{user.provider}</b>
|
|
59
|
+
</span>
|
|
60
|
+
</div>
|
|
61
|
+
<div className={`${styles.gtCell} ${styles.colSmall}`}>
|
|
62
|
+
<>
|
|
63
|
+
<div>
|
|
64
|
+
<b>{formatDate(user.created)}</b>
|
|
65
|
+
</div>
|
|
66
|
+
Created
|
|
67
|
+
</>
|
|
68
|
+
</div>
|
|
69
|
+
<div className={`${styles.gtCell} ${styles.colSmall}`}>
|
|
70
|
+
{user.lastSeen !== null && (
|
|
71
|
+
<>
|
|
72
|
+
<div>
|
|
73
|
+
<b>{formatDistanceToNow(parseISO(user.lastSeen))} ago</b>
|
|
74
|
+
</div>
|
|
75
|
+
Last Login
|
|
76
|
+
</>
|
|
77
|
+
)}
|
|
78
|
+
</div>
|
|
79
|
+
<div className={`${styles.gtCell} ${styles.colSmall}`}>
|
|
80
|
+
{user.modified !== null && (
|
|
81
|
+
<>
|
|
82
|
+
<div>
|
|
83
|
+
<b>{formatDistanceToNow(parseISO(user.modified))} ago</b>
|
|
84
|
+
</div>
|
|
85
|
+
Modified
|
|
86
|
+
</>
|
|
87
|
+
)}
|
|
88
|
+
</div>
|
|
89
|
+
|
|
90
|
+
<div className={`${styles.gtCell} ${styles.colFlex}`}>
|
|
91
|
+
<UserTools
|
|
92
|
+
user={user}
|
|
93
|
+
refetchCurrentPage={refetchCurrentPage}
|
|
94
|
+
/>
|
|
95
|
+
</div>
|
|
96
|
+
</div>
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export default UserSummary
|
|
@@ -1,79 +1,102 @@
|
|
|
1
1
|
import React from "react"
|
|
2
|
-
import type { FC
|
|
3
|
-
import {
|
|
4
|
-
import { Mutation } from "@apollo/client/react/components"
|
|
2
|
+
import type { FC } from "react"
|
|
3
|
+
import { useMutation } from "@apollo/client"
|
|
5
4
|
import { WarnButton } from "../../components/warn-button/WarnButton"
|
|
6
5
|
import { getProfile } from "../../authentication/profile"
|
|
7
6
|
import { useCookies } from "react-cookie"
|
|
8
|
-
import {
|
|
7
|
+
import type { User } from "../../types/user-types"
|
|
8
|
+
import styles from "./users.module.scss"
|
|
9
|
+
import * as Sentry from "@sentry/react"
|
|
10
|
+
|
|
11
|
+
import { SET_ADMIN_MUTATION, SET_BLOCKED_MUTATION } from "../../queries/users"
|
|
9
12
|
|
|
10
13
|
interface UserToolsProps {
|
|
11
|
-
user:
|
|
12
|
-
|
|
14
|
+
user: User
|
|
15
|
+
refetchCurrentPage: () => void
|
|
13
16
|
}
|
|
14
17
|
|
|
15
|
-
export const
|
|
16
|
-
mutation ($id: ID!, $admin: Boolean!) {
|
|
17
|
-
setAdmin(id: $id, admin: $admin) {
|
|
18
|
-
...userFields
|
|
19
|
-
}
|
|
20
|
-
}
|
|
21
|
-
${USER_FRAGMENT}
|
|
22
|
-
`
|
|
23
|
-
|
|
24
|
-
export const SET_BLOCKED = gql`
|
|
25
|
-
mutation ($id: ID!, $blocked: Boolean!) {
|
|
26
|
-
setBlocked(id: $id, blocked: $blocked) {
|
|
27
|
-
...userFields
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
${USER_FRAGMENT}
|
|
31
|
-
`
|
|
32
|
-
|
|
33
|
-
export const UserTools: FC<UserToolsProps> = ({ user, refetch }) => {
|
|
18
|
+
export const UserTools: FC<UserToolsProps> = ({ user, refetchCurrentPage }) => {
|
|
34
19
|
const [cookies] = useCookies()
|
|
35
20
|
const adminIcon = user.admin ? "fa-check-square-o" : "fa-square-o"
|
|
36
21
|
const blacklistIcon = user.blocked ? "fa-check-square-o" : "fa-square-o"
|
|
37
22
|
|
|
23
|
+
// --- useMutation for SET_ADMIN ---
|
|
24
|
+
const [setAdmin] = useMutation(SET_ADMIN_MUTATION, {
|
|
25
|
+
update(cache, { data: { setAdmin: updatedUser } }) {
|
|
26
|
+
cache.modify({
|
|
27
|
+
id: cache.identify(updatedUser),
|
|
28
|
+
fields: {
|
|
29
|
+
admin() {
|
|
30
|
+
return updatedUser.admin
|
|
31
|
+
},
|
|
32
|
+
blocked() {
|
|
33
|
+
return updatedUser.blocked
|
|
34
|
+
},
|
|
35
|
+
modified() {
|
|
36
|
+
return updatedUser.modified
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
})
|
|
40
|
+
},
|
|
41
|
+
onCompleted: () => {
|
|
42
|
+
refetchCurrentPage()
|
|
43
|
+
},
|
|
44
|
+
onError: (error) => {
|
|
45
|
+
Sentry.captureException(error)
|
|
46
|
+
},
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
// --- useMutation for SET_BLOCKED ---
|
|
50
|
+
const [setBlocked] = useMutation(SET_BLOCKED_MUTATION, {
|
|
51
|
+
update(cache, { data: { setBlocked: updatedUser } }) {
|
|
52
|
+
cache.modify({
|
|
53
|
+
id: cache.identify(updatedUser),
|
|
54
|
+
fields: {
|
|
55
|
+
blocked() {
|
|
56
|
+
return updatedUser.blocked
|
|
57
|
+
},
|
|
58
|
+
admin() {
|
|
59
|
+
return updatedUser.admin
|
|
60
|
+
},
|
|
61
|
+
modified() {
|
|
62
|
+
return updatedUser.modified
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
})
|
|
66
|
+
},
|
|
67
|
+
onCompleted: () => {
|
|
68
|
+
refetchCurrentPage()
|
|
69
|
+
},
|
|
70
|
+
onError: (error) => {
|
|
71
|
+
Sentry.captureException(error)
|
|
72
|
+
},
|
|
73
|
+
})
|
|
74
|
+
|
|
38
75
|
if (user.id !== getProfile(cookies).sub) {
|
|
39
76
|
return (
|
|
40
77
|
<div className="dataset-tools-wrap-admin">
|
|
41
|
-
<div className=
|
|
78
|
+
<div className={styles.tools}>
|
|
42
79
|
<div className="tool">
|
|
43
|
-
<
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
setAdmin().then(() => {
|
|
53
|
-
refetch()
|
|
54
|
-
})
|
|
55
|
-
}}
|
|
56
|
-
/>
|
|
57
|
-
)}
|
|
58
|
-
</Mutation>
|
|
80
|
+
<WarnButton
|
|
81
|
+
message="Admin"
|
|
82
|
+
icon={adminIcon}
|
|
83
|
+
onConfirmedClick={async (): Promise<void> => {
|
|
84
|
+
await setAdmin({
|
|
85
|
+
variables: { id: user.id, admin: !user.admin },
|
|
86
|
+
})
|
|
87
|
+
}}
|
|
88
|
+
/>
|
|
59
89
|
</div>
|
|
60
90
|
<div className="tool">
|
|
61
|
-
<
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
setBlocked().then(() => {
|
|
71
|
-
refetch()
|
|
72
|
-
})
|
|
73
|
-
}}
|
|
74
|
-
/>
|
|
75
|
-
)}
|
|
76
|
-
</Mutation>
|
|
91
|
+
<WarnButton
|
|
92
|
+
message="Block"
|
|
93
|
+
icon={blacklistIcon}
|
|
94
|
+
onConfirmedClick={async (): Promise<void> => {
|
|
95
|
+
await setBlocked({
|
|
96
|
+
variables: { id: user.id, blocked: !user.blocked },
|
|
97
|
+
})
|
|
98
|
+
}}
|
|
99
|
+
/>
|
|
77
100
|
</div>
|
|
78
101
|
</div>
|
|
79
102
|
</div>
|