@openneuro/app 4.35.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.
Files changed (51) hide show
  1. package/package.json +3 -3
  2. package/src/client.jsx +1 -0
  3. package/src/scripts/components/button/button.scss +14 -0
  4. package/src/scripts/components/page/page.scss +2 -70
  5. package/src/scripts/components/search-page/SearchResultItem.tsx +3 -1
  6. package/src/scripts/components/search-page/search-page.scss +1 -13
  7. package/src/scripts/config.ts +6 -0
  8. package/src/scripts/dataset/files/__tests__/__snapshots__/file.spec.jsx.snap +2 -2
  9. package/src/scripts/dataset/files/file.tsx +2 -2
  10. package/src/scripts/dataset/mutations/__tests__/update-file.spec.tsx +126 -0
  11. package/src/scripts/dataset/mutations/update-file.jsx +20 -5
  12. package/src/scripts/dataset/routes/snapshot.tsx +1 -1
  13. package/src/scripts/errors/errorRoute.tsx +2 -0
  14. package/src/scripts/pages/admin/__tests__/users.spec.tsx +51 -18
  15. package/src/scripts/pages/admin/admin.jsx +2 -2
  16. package/src/scripts/pages/admin/user-fragment.ts +10 -3
  17. package/src/scripts/pages/admin/user-summary.tsx +100 -0
  18. package/src/scripts/pages/admin/user-tools.tsx +81 -58
  19. package/src/scripts/pages/admin/users.module.scss +277 -0
  20. package/src/scripts/pages/admin/users.tsx +351 -152
  21. package/src/scripts/queries/user.ts +120 -3
  22. package/src/scripts/queries/users.ts +247 -0
  23. package/src/scripts/routes.tsx +7 -15
  24. package/src/scripts/types/user-types.ts +12 -13
  25. package/src/scripts/uploader/file-select.tsx +42 -57
  26. package/src/scripts/uploader/upload-select.jsx +1 -1
  27. package/src/scripts/users/__tests__/dataset-card.spec.tsx +127 -0
  28. package/src/scripts/users/__tests__/user-account-view.spec.tsx +150 -67
  29. package/src/scripts/users/__tests__/user-card.spec.tsx +6 -17
  30. package/src/scripts/users/__tests__/user-query.spec.tsx +133 -38
  31. package/src/scripts/users/__tests__/user-routes.spec.tsx +156 -27
  32. package/src/scripts/users/__tests__/user-tabs.spec.tsx +7 -7
  33. package/src/scripts/users/components/edit-list.tsx +26 -5
  34. package/src/scripts/users/components/edit-string.tsx +40 -13
  35. package/src/scripts/users/components/editable-content.tsx +10 -3
  36. package/src/scripts/users/components/user-dataset-filters.tsx +205 -121
  37. package/src/scripts/users/dataset-card.tsx +3 -2
  38. package/src/scripts/users/github-auth-button.tsx +98 -0
  39. package/src/scripts/users/scss/datasetcard.module.scss +65 -12
  40. package/src/scripts/users/scss/useraccountview.module.scss +1 -1
  41. package/src/scripts/users/user-account-view.tsx +43 -34
  42. package/src/scripts/users/user-card.tsx +23 -22
  43. package/src/scripts/users/user-container.tsx +9 -5
  44. package/src/scripts/users/user-datasets-view.tsx +350 -40
  45. package/src/scripts/users/user-menu.tsx +4 -9
  46. package/src/scripts/users/user-notifications-view.tsx +9 -7
  47. package/src/scripts/users/user-query.tsx +3 -6
  48. package/src/scripts/users/user-routes.tsx +11 -5
  49. package/src/scripts/users/user-tabs.tsx +4 -2
  50. package/src/scripts/users/__tests__/datasest-card.spec.tsx +0 -201
  51. package/src/scripts/users/fragments/query.js +0 -42
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openneuro/app",
3
- "version": "4.35.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",
@@ -14,7 +14,7 @@
14
14
  },
15
15
  "author": "Squishymedia",
16
16
  "dependencies": {
17
- "@apollo/client": "3.11.8",
17
+ "@apollo/client": "3.13.8",
18
18
  "@artsy/fresnel": "^1.3.1",
19
19
  "@bids/validator": "npm:@jsr/bids__validator@^2.0.3",
20
20
  "@emotion/react": "11.11.1",
@@ -75,5 +75,5 @@
75
75
  "publishConfig": {
76
76
  "access": "public"
77
77
  },
78
- "gitHead": "ce3f98fbbe9da0c9463dcae9fe6357c1000d0fa5"
78
+ "gitHead": "ac7fa1782779cfdf1f46c9a5add8865155177b99"
79
79
  }
package/src/client.jsx CHANGED
@@ -30,6 +30,7 @@ const client = new ApolloClient({
30
30
  },
31
31
  },
32
32
  }),
33
+ connectToDevTools: config.sentry.environment !== "production",
33
34
  })
34
35
 
35
36
  container.render(
@@ -138,3 +138,17 @@
138
138
  }
139
139
  }
140
140
  }
141
+
142
+ .load-more {
143
+ .on-button {
144
+ display: block;
145
+ border: 1px solid;
146
+ width: 100%;
147
+ background-color: #fff;
148
+ transition: background-color 0.3s;
149
+ &:hover {
150
+ text-decoration: none;
151
+ background-color: $newspaper;
152
+ }
153
+ }
154
+ }
@@ -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
@@ -127,6 +127,8 @@ export const SearchResultItem = ({
127
127
  const hasEdit = hasEditPermissions(node.permissions, profileSub) || isAdmin
128
128
 
129
129
  const heading = node.latestSnapshot.description?.Name
130
+ ? node.latestSnapshot.description?.Name
131
+ : node.id
130
132
  const summary = node.latestSnapshot?.summary
131
133
  const datasetId = node.id
132
134
  const numSessions = summary?.sessions.length > 0 ? summary.sessions.length : 1
@@ -134,7 +136,7 @@ export const SearchResultItem = ({
134
136
  const accessionNumber = (
135
137
  <span className="result-summary-meta">
136
138
  <strong>Openneuro Accession Number:</strong>
137
- <span>{node.id}</span>
139
+ <Link to={"/datasets/" + datasetId}>{node.id}</Link>
138
140
  </span>
139
141
  )
140
142
  const sessions = (
@@ -302,19 +302,7 @@
302
302
  border-bottom: 1px solid $newspaper;
303
303
  }
304
304
  }
305
- .load-more {
306
- .on-button {
307
- display: block;
308
- border: 1px solid;
309
- width: 100%;
310
- background-color: #fff;
311
- transition: background-color 0.3s;
312
- &:hover {
313
- text-decoration: none;
314
- background-color: $newspaper;
315
- }
316
- }
317
- }
305
+
318
306
  }
319
307
 
320
308
  .search-sort {
@@ -15,6 +15,9 @@ export interface OpenNeuroConfig {
15
15
  clientID: string
16
16
  ORCID_API_ENDPOINT: string
17
17
  }
18
+ github?: {
19
+ clientID: string
20
+ }
18
21
  globus?: {
19
22
  clientID: string
20
23
  }
@@ -48,6 +51,9 @@ export const config: OpenNeuroConfig = {
48
51
  clientID: globalThis.OpenNeuroConfig.ORCID_CLIENT_ID,
49
52
  ORCID_API_ENDPOINT: globalThis.OpenNeuroConfig.ORCID_API_ENDPOINT,
50
53
  },
54
+ github: {
55
+ clientID: globalThis.OpenNeuroConfig.GITHUB_CLIENT_ID,
56
+ },
51
57
  },
52
58
  analytics: {
53
59
  trackingIds: globalThis.OpenNeuroConfig.GOOGLE_TRACKING_IDS.split(",").map(
@@ -29,7 +29,7 @@ exports[`File component > renders for dataset snapshots 1`] = `
29
29
  >
30
30
  <a
31
31
  aria-label="download file"
32
- download=""
32
+ download="README"
33
33
  href="/crn/datasets/ds001/snapshots/1.0.0/files/README"
34
34
  >
35
35
  <i
@@ -90,7 +90,7 @@ exports[`File component > renders with common props 1`] = `
90
90
  >
91
91
  <a
92
92
  aria-label="download file"
93
- download=""
93
+ download="README"
94
94
  href="/crn/datasets/ds001/files/README"
95
95
  >
96
96
  <i
@@ -149,7 +149,7 @@ const File = ({
149
149
  <a
150
150
  href={urls?.[0] ||
151
151
  apiPath(datasetId, snapshotTag, filePath(path, filename))}
152
- download
152
+ download={filename}
153
153
  aria-label="download file"
154
154
  >
155
155
  <i className="fa fa-download" />
@@ -170,7 +170,7 @@ const File = ({
170
170
  {editMode && (
171
171
  <Media greaterThanOrEqual="medium">
172
172
  <Tooltip tooltip="Update">
173
- <UpdateFile datasetId={datasetId} path={path}>
173
+ <UpdateFile datasetId={datasetId} path={path} filename={filename}>
174
174
  <i className="fa fa-cloud-upload" />
175
175
  </UpdateFile>
176
176
  </Tooltip>
@@ -0,0 +1,126 @@
1
+ import React from "react"
2
+ import { fireEvent, render, screen } from "@testing-library/react"
3
+ import { vi } from "vitest"
4
+ import UpdateFile from "../update-file"
5
+ import UploaderContext from "../../../uploader/uploader-context.js"
6
+
7
+ describe("UpdateFile Component", () => {
8
+ const mockResumeDataset = vi.fn()
9
+ const mockUploader = {
10
+ resumeDataset: vi.fn(() => mockResumeDataset),
11
+ }
12
+
13
+ const datasetId = "ds000001"
14
+ const path = "sub-01/anat"
15
+
16
+ const renderComponent = (props = {}, uploader = mockUploader) => {
17
+ return render(
18
+ <UploaderContext.Provider value={uploader}>
19
+ <UpdateFile datasetId={datasetId} path={path} {...props}>
20
+ <button>Upload Button</button>
21
+ </UpdateFile>
22
+ </UploaderContext.Provider>,
23
+ )
24
+ }
25
+
26
+ it("renders children correctly", () => {
27
+ renderComponent()
28
+ expect(screen.getByText("Upload Button")).toBeInTheDocument()
29
+ })
30
+
31
+ it("sets webkitdirectory attribute when directory prop is true", () => {
32
+ renderComponent({ directory: true })
33
+ const inputElement = screen.getByRole("button", { name: "Upload Button" })
34
+ .previousSibling // The input is before the children
35
+ expect(inputElement).toHaveAttribute("webkitdirectory", "true")
36
+ })
37
+
38
+ it("does not set webkitdirectory attribute when directory prop is false", () => {
39
+ renderComponent({ directory: false })
40
+ const inputElement = screen.getByRole("button", { name: "Upload Button" })
41
+ .previousSibling
42
+ expect(inputElement).not.toHaveAttribute("webkitdirectory")
43
+ })
44
+
45
+ it("sets multiple attribute when multiple prop is true", () => {
46
+ renderComponent({ multiple: true })
47
+ const inputElement = screen.getByRole("button", { name: "Upload Button" })
48
+ .previousSibling
49
+ expect(inputElement).toHaveAttribute("multiple")
50
+ })
51
+
52
+ it("does not set multiple attribute when multiple prop is false", () => {
53
+ renderComponent({ multiple: false })
54
+ const inputElement = screen.getByRole("button", { name: "Upload Button" })
55
+ .previousSibling
56
+ expect(inputElement).not.toHaveAttribute("multiple")
57
+ })
58
+
59
+ describe("onChange event", () => {
60
+ const file1 = new File(["content1"], "original1.txt", {
61
+ type: "text/plain",
62
+ })
63
+ const file2 = new File(["content2"], "original2.txt", {
64
+ type: "text/plain",
65
+ })
66
+
67
+ it("calls uploader.resumeDataset with renamed file when filename is provided and one file is selected", () => {
68
+ const customFilename = "new_filename.txt"
69
+ renderComponent({ filename: customFilename })
70
+ const inputElement = screen.getByRole("button", { name: "Upload Button" })
71
+ .previousSibling
72
+
73
+ fireEvent.change(inputElement, {
74
+ target: { files: [file1] },
75
+ })
76
+
77
+ expect(mockUploader.resumeDataset).toHaveBeenCalledWith(
78
+ datasetId,
79
+ path,
80
+ false,
81
+ )
82
+ expect(mockResumeDataset).toHaveBeenCalledTimes(1)
83
+ const calledWithArgs = mockResumeDataset.mock.calls[0][0]
84
+ expect(calledWithArgs.files).toHaveLength(1)
85
+ expect(calledWithArgs.files[0].name).toBe(customFilename)
86
+ expect(calledWithArgs.files[0].type).toBe(file1.type)
87
+ })
88
+
89
+ it("calls uploader.resumeDataset with original files when filename is not provided", () => {
90
+ renderComponent()
91
+ const inputElement = screen.getByRole("button", { name: "Upload Button" })
92
+ .previousSibling
93
+
94
+ fireEvent.change(inputElement, {
95
+ target: { files: [file1, file2] },
96
+ })
97
+
98
+ expect(mockUploader.resumeDataset).toHaveBeenCalledWith(
99
+ datasetId,
100
+ path,
101
+ false,
102
+ )
103
+ expect(mockResumeDataset).toHaveBeenCalledTimes(1)
104
+ expect(mockResumeDataset).toHaveBeenCalledWith({ files: [file1, file2] })
105
+ })
106
+
107
+ it("calls uploader.resumeDataset with original files when multiple files are selected, even if filename is provided", () => {
108
+ const customFilename = "new_filename.txt"
109
+ renderComponent({ filename: customFilename, multiple: true })
110
+ const inputElement = screen.getByRole("button", { name: "Upload Button" })
111
+ .previousSibling
112
+
113
+ fireEvent.change(inputElement, {
114
+ target: { files: [file1, file2] },
115
+ })
116
+
117
+ expect(mockUploader.resumeDataset).toHaveBeenCalledWith(
118
+ datasetId,
119
+ path,
120
+ false,
121
+ )
122
+ expect(mockResumeDataset).toHaveBeenCalledTimes(1)
123
+ expect(mockResumeDataset).toHaveBeenCalledWith({ files: [file1, file2] })
124
+ })
125
+ })
126
+ })
@@ -7,6 +7,7 @@ const UpdateFile = ({
7
7
  directory = false,
8
8
  multiple = false,
9
9
  path = null,
10
+ filename = null,
10
11
  children,
11
12
  }) => {
12
13
  return (
@@ -18,11 +19,25 @@ const UpdateFile = ({
18
19
  className="update-file"
19
20
  onChange={(e) => {
20
21
  e.preventDefault()
21
- uploader.resumeDataset(
22
- datasetId,
23
- path,
24
- false,
25
- )({ files: e.target.files })
22
+ if (filename && e.target.files.length === 1) {
23
+ // In the case that a single file was selected,
24
+ // name that file based on the original path and not the client side name.
25
+ const target = e.target.files[0]
26
+ const files = [
27
+ new File([target], filename, { type: target.type }),
28
+ ]
29
+ uploader.resumeDataset(
30
+ datasetId,
31
+ path,
32
+ false,
33
+ )({ files })
34
+ } else {
35
+ uploader.resumeDataset(
36
+ datasetId,
37
+ path,
38
+ false,
39
+ )({ files: e.target.files })
40
+ }
26
41
  }}
27
42
  webkitdirectory={directory ? "true" : undefined}
28
43
  multiple={multiple && true}
@@ -18,7 +18,7 @@ const FormRow = styled.div`
18
18
  export const NoErrors = ({ validation, authors, children }) => {
19
19
  const noErrors = validation?.errors === 0
20
20
  // zero authors will cause DOI minting to fail
21
- const hasAuthor = authors.length > 0
21
+ const hasAuthor = authors?.length > 0
22
22
  if (noErrors && hasAuthor) {
23
23
  return children
24
24
  } else {
@@ -6,12 +6,14 @@ import { Route, Routes } from "react-router-dom"
6
6
  import OrcidGeneral from "./orcid/general.jsx"
7
7
  import { OrcidEmailWarning } from "./orcid/email-warning.js"
8
8
  import FourOFourPage from "./404page.js"
9
+ import FourOThreePage from "./403page.js"
9
10
 
10
11
  function ErrorRoute() {
11
12
  return (
12
13
  <div className="container errors">
13
14
  <div className="panel">
14
15
  <Routes>
16
+ <Route path="github" element={<FourOThreePage />} />
15
17
  <Route path="orcid" element={<OrcidGeneral />} />
16
18
  <Route path="email-warning" element={<OrcidEmailWarning />} />
17
19
  <Route path="*" element={<FourOFourPage />} />
@@ -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