@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openneuro/app",
3
- "version": "4.36.0-alpha.0",
3
+ "version": "4.36.1",
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": "0d754964177331113f4279c48d6c0fae0f59adc2"
78
+ "gitHead": "1b95eb2f9fcabcdca1f4fc4e1f2064ed2c4dedf7"
79
79
  }
@@ -27,7 +27,7 @@
27
27
  // admin page
28
28
  .admin.route-wrapper {
29
29
  margin: 20px auto;
30
- max-width: 1000px;
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
- input {
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
- <UsersQuery />
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")).toBeInTheDocument()
52
- expect(await screen.findByText(users[0].name)).toBeInTheDocument()
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 = [...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
- }]
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
- <UsersQuery />
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")).toBeInTheDocument()
87
- expect(await screen.findByText(users[0].name)).toBeInTheDocument()
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 Users from "./users"
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={<Users />} />
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
- blocked
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, ReactElement } from "react"
3
- import { gql } from "@apollo/client"
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 { USER_FRAGMENT } from "./user-fragment"
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: Record<string, unknown>
12
- refetch: () => Record<string, unknown>
14
+ user: User
15
+ refetchCurrentPage: () => void
13
16
  }
14
17
 
15
- export const SET_ADMIN = gql`
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="tools clearfix">
78
+ <div className={styles.tools}>
42
79
  <div className="tool">
43
- <Mutation
44
- mutation={SET_ADMIN}
45
- variables={{ id: user.id, admin: !user.admin }}
46
- >
47
- {(setAdmin): ReactElement => (
48
- <WarnButton
49
- message="Admin"
50
- icon={adminIcon}
51
- onConfirmedClick={(): void => {
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
- <Mutation
62
- mutation={SET_BLOCKED}
63
- variables={{ id: user.id, blocked: !user.blocked }}
64
- >
65
- {(setBlocked): ReactElement => (
66
- <WarnButton
67
- message="Block"
68
- icon={blacklistIcon}
69
- onConfirmedClick={(): void => {
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>