Compare commits
	
		
			9 Commits
		
	
	
		
			commit-6cd
			...
			commit-335
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | 33524ce3cc | ||
|  | e037c6c577 | ||
|  | c268991495 | ||
|  | 0edfe0f73a | ||
|  | f4a6030d93 | ||
|  | 9358539e0c | ||
|  | 5bf7d5652c | ||
|  | 12f9229320 | ||
|  | df19fa811c | 
							
								
								
									
										2
									
								
								.github/workflows/end-to-end-test.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/end-to-end-test.yml
									
									
									
									
										vendored
									
									
								
							| @@ -81,7 +81,7 @@ jobs: | ||||
|     - name: Install go | ||||
|       uses: actions/setup-go@v3 | ||||
|       with: | ||||
|         go-version: 1.20.x | ||||
|         go-version: 1.21.x | ||||
|  | ||||
|     - name: Checkout zot repo | ||||
|       uses: actions/checkout@v3 | ||||
|   | ||||
							
								
								
									
										210
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										210
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							| @@ -14,6 +14,7 @@ | ||||
|         "@mui/lab": "^5.0.0-alpha.89", | ||||
|         "@mui/material": "^5.8.6", | ||||
|         "@mui/styles": "^5.8.6", | ||||
|         "@mui/x-date-pickers": "^6.18.4", | ||||
|         "@testing-library/jest-dom": "^5.16.1", | ||||
|         "@testing-library/react": "^12.1.2", | ||||
|         "@testing-library/user-event": "^13.5.0", | ||||
| @@ -21,7 +22,7 @@ | ||||
|         "downshift": "^6.1.12", | ||||
|         "export-from-json": "^1.7.3", | ||||
|         "lodash": "^4.17.21", | ||||
|         "luxon": "^2.5.2", | ||||
|         "luxon": "^3.4.4", | ||||
|         "markdown-to-jsx": "^7.1.7", | ||||
|         "react": "^17.0.2", | ||||
|         "react-dom": "^17.0.2", | ||||
| @@ -2130,16 +2131,21 @@ | ||||
|       "dev": true | ||||
|     }, | ||||
|     "node_modules/@babel/runtime": { | ||||
|       "version": "7.22.5", | ||||
|       "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.22.5.tgz", | ||||
|       "integrity": "sha512-ecjvYlnAaZ/KVneE/OdKYBYfgXV3Ptu6zQWmgEF7vwKhQnvVS6bjMD2XYgj+SNvQ1GfK/pjgokfPkC/2CO8CuA==", | ||||
|       "version": "7.23.6", | ||||
|       "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.6.tgz", | ||||
|       "integrity": "sha512-zHd0eUrf5GZoOWVCXp6koAKQTfZV07eit6bGPmJgnZdnSAvvZee6zniW2XMF7Cmc4ISOOnPy3QaSiIJGJkVEDQ==", | ||||
|       "dependencies": { | ||||
|         "regenerator-runtime": "^0.13.11" | ||||
|         "regenerator-runtime": "^0.14.0" | ||||
|       }, | ||||
|       "engines": { | ||||
|         "node": ">=6.9.0" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/@babel/runtime/node_modules/regenerator-runtime": { | ||||
|       "version": "0.14.0", | ||||
|       "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.0.tgz", | ||||
|       "integrity": "sha512-srw17NI0TUWHuGa5CFGGmhfNIeja30WMBfbslPNhf6JrqQlLN5gcrvig1oqPxiVaXb0oW0XRKtH6Nngs5lKCIA==" | ||||
|     }, | ||||
|     "node_modules/@babel/template": { | ||||
|       "version": "7.22.5", | ||||
|       "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.5.tgz", | ||||
| @@ -2678,6 +2684,40 @@ | ||||
|         "node": "^12.22.0 || ^14.17.0 || >=16.0.0" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/@floating-ui/core": { | ||||
|       "version": "1.5.2", | ||||
|       "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.5.2.tgz", | ||||
|       "integrity": "sha512-Ii3MrfY/GAIN3OhXNzpCKaLxHQfJF9qvwq/kEJYdqDxeIHa01K8sldugal6TmeeXl+WMvhv9cnVzUTaFFJF09A==", | ||||
|       "dependencies": { | ||||
|         "@floating-ui/utils": "^0.1.3" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/@floating-ui/dom": { | ||||
|       "version": "1.5.3", | ||||
|       "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.5.3.tgz", | ||||
|       "integrity": "sha512-ClAbQnEqJAKCJOEbbLo5IUlZHkNszqhuxS4fHAVxRPXPya6Ysf2G8KypnYcOTpx6I8xcgF9bbHb6g/2KpbV8qA==", | ||||
|       "dependencies": { | ||||
|         "@floating-ui/core": "^1.4.2", | ||||
|         "@floating-ui/utils": "^0.1.3" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/@floating-ui/react-dom": { | ||||
|       "version": "2.0.4", | ||||
|       "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.0.4.tgz", | ||||
|       "integrity": "sha512-CF8k2rgKeh/49UrnIBs4BdxPUV6vize/Db1d/YbCLyp9GiVZ0BEwf5AiDSxJRCr6yOkGqTFHtmrULxkEfYZ7dQ==", | ||||
|       "dependencies": { | ||||
|         "@floating-ui/dom": "^1.5.1" | ||||
|       }, | ||||
|       "peerDependencies": { | ||||
|         "react": ">=16.8.0", | ||||
|         "react-dom": ">=16.8.0" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/@floating-ui/utils": { | ||||
|       "version": "0.1.6", | ||||
|       "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.1.6.tgz", | ||||
|       "integrity": "sha512-OfX7E2oUDYxtBvsuS4e/jSn4Q9Qb6DzgeYtsAdkPZ47znpoNsMgZw0+tVijiv3uGNR6dgNlty6r9rzIzHjtd/A==" | ||||
|     }, | ||||
|     "node_modules/@humanwhocodes/config-array": { | ||||
|       "version": "0.11.10", | ||||
|       "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.10.tgz", | ||||
| @@ -3935,11 +3975,11 @@ | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/@mui/types": { | ||||
|       "version": "7.2.4", | ||||
|       "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.4.tgz", | ||||
|       "integrity": "sha512-LBcwa8rN84bKF+f5sDyku42w1NTxaPgPyYKODsh01U1fVstTClbUoSA96oyRBnSNyEiAVjKm6Gwx9vjR+xyqHA==", | ||||
|       "version": "7.2.11", | ||||
|       "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.11.tgz", | ||||
|       "integrity": "sha512-KWe/QTEsFFlFSH+qRYf3zoFEj3z67s+qAuSnMMg+gFwbxG7P96Hm6g300inQL1Wy///gSRb8juX7Wafvp93m3w==", | ||||
|       "peerDependencies": { | ||||
|         "@types/react": "*" | ||||
|         "@types/react": "^17.0.0 || ^18.0.0" | ||||
|       }, | ||||
|       "peerDependenciesMeta": { | ||||
|         "@types/react": { | ||||
| @@ -3948,25 +3988,134 @@ | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/@mui/utils": { | ||||
|       "version": "5.13.6", | ||||
|       "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.13.6.tgz", | ||||
|       "integrity": "sha512-ggNlxl5NPSbp+kNcQLmSig6WVB0Id+4gOxhx644987v4fsji+CSXc+MFYLocFB/x4oHtzCUlSzbVHlJfP/fXoQ==", | ||||
|       "version": "5.15.0", | ||||
|       "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.15.0.tgz", | ||||
|       "integrity": "sha512-XSmTKStpKYamewxyJ256+srwEnsT3/6eNo6G7+WC1tj2Iq9GfUJ/6yUoB7YXjOD2jTZ3XobToZm4pVz1LBt6GA==", | ||||
|       "dependencies": { | ||||
|         "@babel/runtime": "^7.22.5", | ||||
|         "@types/prop-types": "^15.7.5", | ||||
|         "@types/react-is": "^18.2.0", | ||||
|         "@babel/runtime": "^7.23.5", | ||||
|         "@types/prop-types": "^15.7.11", | ||||
|         "prop-types": "^15.8.1", | ||||
|         "react-is": "^18.2.0" | ||||
|       }, | ||||
|       "engines": { | ||||
|         "node": ">=12.0.0" | ||||
|       }, | ||||
|       "funding": { | ||||
|         "type": "opencollective", | ||||
|         "url": "https://opencollective.com/mui-org" | ||||
|       }, | ||||
|       "peerDependencies": { | ||||
|         "@types/react": "^17.0.0 || ^18.0.0", | ||||
|         "react": "^17.0.0 || ^18.0.0" | ||||
|       }, | ||||
|       "peerDependenciesMeta": { | ||||
|         "@types/react": { | ||||
|           "optional": true | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/@mui/x-date-pickers": { | ||||
|       "version": "6.18.4", | ||||
|       "resolved": "https://registry.npmjs.org/@mui/x-date-pickers/-/x-date-pickers-6.18.4.tgz", | ||||
|       "integrity": "sha512-YqJ6lxZHBIt344B3bvRAVbdYSQz4dcmJQXGcfvJTn26VdKjpgzjAqwhlbQhbAt55audJOWzGB99ImuQuljDROA==", | ||||
|       "dependencies": { | ||||
|         "@babel/runtime": "^7.23.2", | ||||
|         "@mui/base": "^5.0.0-beta.22", | ||||
|         "@mui/utils": "^5.14.16", | ||||
|         "@types/react-transition-group": "^4.4.8", | ||||
|         "clsx": "^2.0.0", | ||||
|         "prop-types": "^15.8.1", | ||||
|         "react-transition-group": "^4.4.5" | ||||
|       }, | ||||
|       "engines": { | ||||
|         "node": ">=14.0.0" | ||||
|       }, | ||||
|       "funding": { | ||||
|         "type": "opencollective", | ||||
|         "url": "https://opencollective.com/mui" | ||||
|       }, | ||||
|       "peerDependencies": { | ||||
|         "react": "^17.0.0 || ^18.0.0" | ||||
|         "@emotion/react": "^11.9.0", | ||||
|         "@emotion/styled": "^11.8.1", | ||||
|         "@mui/material": "^5.8.6", | ||||
|         "@mui/system": "^5.8.0", | ||||
|         "date-fns": "^2.25.0", | ||||
|         "date-fns-jalali": "^2.13.0-0", | ||||
|         "dayjs": "^1.10.7", | ||||
|         "luxon": "^3.0.2", | ||||
|         "moment": "^2.29.4", | ||||
|         "moment-hijri": "^2.1.2", | ||||
|         "moment-jalaali": "^0.7.4 || ^0.8.0 || ^0.9.0 || ^0.10.0", | ||||
|         "react": "^17.0.0 || ^18.0.0", | ||||
|         "react-dom": "^17.0.0 || ^18.0.0" | ||||
|       }, | ||||
|       "peerDependenciesMeta": { | ||||
|         "@emotion/react": { | ||||
|           "optional": true | ||||
|         }, | ||||
|         "@emotion/styled": { | ||||
|           "optional": true | ||||
|         }, | ||||
|         "date-fns": { | ||||
|           "optional": true | ||||
|         }, | ||||
|         "date-fns-jalali": { | ||||
|           "optional": true | ||||
|         }, | ||||
|         "dayjs": { | ||||
|           "optional": true | ||||
|         }, | ||||
|         "luxon": { | ||||
|           "optional": true | ||||
|         }, | ||||
|         "moment": { | ||||
|           "optional": true | ||||
|         }, | ||||
|         "moment-hijri": { | ||||
|           "optional": true | ||||
|         }, | ||||
|         "moment-jalaali": { | ||||
|           "optional": true | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/@mui/x-date-pickers/node_modules/@mui/base": { | ||||
|       "version": "5.0.0-beta.27", | ||||
|       "resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-beta.27.tgz", | ||||
|       "integrity": "sha512-duL37qxihT1N0pW/gyXVezP7SttLkF+cLAs/y6g6ubEFmVadjbnZ45SeF12/vAiKzqwf5M0uFH1cczIPXFZygA==", | ||||
|       "dependencies": { | ||||
|         "@babel/runtime": "^7.23.5", | ||||
|         "@floating-ui/react-dom": "^2.0.4", | ||||
|         "@mui/types": "^7.2.11", | ||||
|         "@mui/utils": "^5.15.0", | ||||
|         "@popperjs/core": "^2.11.8", | ||||
|         "clsx": "^2.0.0", | ||||
|         "prop-types": "^15.8.1" | ||||
|       }, | ||||
|       "engines": { | ||||
|         "node": ">=12.0.0" | ||||
|       }, | ||||
|       "funding": { | ||||
|         "type": "opencollective", | ||||
|         "url": "https://opencollective.com/mui-org" | ||||
|       }, | ||||
|       "peerDependencies": { | ||||
|         "@types/react": "^17.0.0 || ^18.0.0", | ||||
|         "react": "^17.0.0 || ^18.0.0", | ||||
|         "react-dom": "^17.0.0 || ^18.0.0" | ||||
|       }, | ||||
|       "peerDependenciesMeta": { | ||||
|         "@types/react": { | ||||
|           "optional": true | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/@mui/x-date-pickers/node_modules/clsx": { | ||||
|       "version": "2.0.0", | ||||
|       "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.0.0.tgz", | ||||
|       "integrity": "sha512-rQ1+kcj+ttHG0MKVGBUXwayCCF1oh39BF5COIpRzuCEv8Mwjv0XucrI2ExNTOn9IlLifGClWQcU9BrZORvtw6Q==", | ||||
|       "engines": { | ||||
|         "node": ">=6" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/@nicolo-ribaudo/eslint-scope-5-internals": { | ||||
| @@ -4844,9 +4993,9 @@ | ||||
|       "dev": true | ||||
|     }, | ||||
|     "node_modules/@types/prop-types": { | ||||
|       "version": "15.7.5", | ||||
|       "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz", | ||||
|       "integrity": "sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==" | ||||
|       "version": "15.7.11", | ||||
|       "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.11.tgz", | ||||
|       "integrity": "sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==" | ||||
|     }, | ||||
|     "node_modules/@types/q": { | ||||
|       "version": "1.5.5", | ||||
| @@ -4884,18 +5033,10 @@ | ||||
|         "@types/react": "^17" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/@types/react-is": { | ||||
|       "version": "18.2.1", | ||||
|       "resolved": "https://registry.npmjs.org/@types/react-is/-/react-is-18.2.1.tgz", | ||||
|       "integrity": "sha512-wyUkmaaSZEzFZivD8F2ftSyAfk6L+DfFliVj/mYdOXbVjRcS87fQJLTnhk6dRZPuJjI+9g6RZJO4PNCngUrmyw==", | ||||
|       "dependencies": { | ||||
|         "@types/react": "*" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/@types/react-transition-group": { | ||||
|       "version": "4.4.6", | ||||
|       "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.6.tgz", | ||||
|       "integrity": "sha512-VnCdSxfcm08KjsJVQcfBmhEQAPnLB8G08hAxn39azX1qYBQ/5RVQuoHuKIcfKOdncuaUvEpFKFzEvbtIMsfVew==", | ||||
|       "version": "4.4.10", | ||||
|       "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.10.tgz", | ||||
|       "integrity": "sha512-hT/+s0VQs2ojCX823m60m5f0sL5idt9SO6Tj6Dg+rdphGPIeJbJ6CxvBYkgkGKrYeDjvIpKTR38UzmtHJOGW3Q==", | ||||
|       "dependencies": { | ||||
|         "@types/react": "*" | ||||
|       } | ||||
| @@ -13250,9 +13391,9 @@ | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/luxon": { | ||||
|       "version": "2.5.2", | ||||
|       "resolved": "https://registry.npmjs.org/luxon/-/luxon-2.5.2.tgz", | ||||
|       "integrity": "sha512-Yg7/RDp4nedqmLgyH0LwgGRvMEKVzKbUdkBYyCosbHgJ+kaOUx0qzSiSatVc3DFygnirTPYnMM2P5dg2uH1WvA==", | ||||
|       "version": "3.4.4", | ||||
|       "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.4.4.tgz", | ||||
|       "integrity": "sha512-zobTr7akeGHnv7eBOXcRgMeCP6+uyYsczwmeRCauvpvaAltgNyTbLH/+VaEAPUeWBT+1GuNmz4wC/6jtQzbbVA==", | ||||
|       "engines": { | ||||
|         "node": ">=12" | ||||
|       } | ||||
| @@ -16202,7 +16343,8 @@ | ||||
|     "node_modules/regenerator-runtime": { | ||||
|       "version": "0.13.11", | ||||
|       "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", | ||||
|       "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==" | ||||
|       "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", | ||||
|       "dev": true | ||||
|     }, | ||||
|     "node_modules/regenerator-transform": { | ||||
|       "version": "0.15.1", | ||||
|   | ||||
| @@ -9,6 +9,7 @@ | ||||
|     "@mui/lab": "^5.0.0-alpha.89", | ||||
|     "@mui/material": "^5.8.6", | ||||
|     "@mui/styles": "^5.8.6", | ||||
|     "@mui/x-date-pickers": "^6.18.4", | ||||
|     "@testing-library/jest-dom": "^5.16.1", | ||||
|     "@testing-library/react": "^12.1.2", | ||||
|     "@testing-library/user-event": "^13.5.0", | ||||
| @@ -16,7 +17,7 @@ | ||||
|     "downshift": "^6.1.12", | ||||
|     "export-from-json": "^1.7.3", | ||||
|     "lodash": "^4.17.21", | ||||
|     "luxon": "^2.5.2", | ||||
|     "luxon": "^3.4.4", | ||||
|     "markdown-to-jsx": "^7.1.7", | ||||
|     "react": "^17.0.2", | ||||
|     "react-dom": "^17.0.2", | ||||
|   | ||||
| @@ -42,7 +42,8 @@ const config = { | ||||
|  | ||||
|     /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ | ||||
|     trace: 'on-first-retry', | ||||
|     ignoreHTTPSErrors: true | ||||
|     ignoreHTTPSErrors: true, | ||||
|     screenshot: 'only-on-failure' | ||||
|   }, | ||||
|  | ||||
|   /* Configure projects for major browsers */ | ||||
| @@ -101,7 +102,7 @@ const config = { | ||||
|   ], | ||||
|  | ||||
|   /* Folder for test artifacts such as screenshots, videos, traces, etc. */ | ||||
|   // outputDir: 'test-results/', | ||||
|   outputDir: 'test-results/', | ||||
|  | ||||
|   /* Run your local dev server before starting the tests */ | ||||
|   // webServer: { | ||||
|   | ||||
| @@ -1,14 +1,15 @@ | ||||
| import React, { useState } from 'react'; | ||||
| import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'; | ||||
|  | ||||
| import { isAuthenticated } from 'utilities/authUtilities'; | ||||
| import { isAuthenticated, isApiKeyEnabled } from 'utilities/authUtilities'; | ||||
| import { AuthWrapper } from 'utilities/AuthWrapper'; | ||||
|  | ||||
| import HomePage from './pages/HomePage'; | ||||
| import LoginPage from './pages/LoginPage'; | ||||
| import { AuthWrapper } from 'utilities/AuthWrapper'; | ||||
| import RepoPage from 'pages/RepoPage'; | ||||
| import TagPage from 'pages/TagPage'; | ||||
| import ExplorePage from 'pages/ExplorePage'; | ||||
| import UserManagementPage from 'pages/UserManagementPage'; | ||||
|  | ||||
| import './App.css'; | ||||
|  | ||||
| @@ -25,6 +26,7 @@ function App() { | ||||
|             <Route path="/explore" element={<ExplorePage />} /> | ||||
|             <Route path="/image/:name" element={<RepoPage />} /> | ||||
|             <Route path="/image/:reponame/tag/:tag" element={<TagPage />} /> | ||||
|             {isApiKeyEnabled() && <Route path="/user/apikey" element={<UserManagementPage />} />} | ||||
|             <Route path="*" element={<Navigate to="/home" />} /> | ||||
|           </Route> | ||||
|           <Route element={<AuthWrapper isLoggedIn={!isLoggedIn} redirect="/" />}> | ||||
|   | ||||
| @@ -7,7 +7,7 @@ import userEvent from '@testing-library/user-event'; | ||||
| const mockMgmtResponse = { | ||||
|   distSpecVersion: '1.1.0-dev', | ||||
|   binaryType: '-apikey-lint-metrics-mgmt-scrub-search-sync-ui-userprefs', | ||||
|   http: { auth: { htpasswd: {} } } | ||||
|   http: { auth: { htpasswd: {}, openid: { providers: { github: {} } } } } | ||||
| }; | ||||
|  | ||||
| // useNavigate mock | ||||
| @@ -55,6 +55,7 @@ describe('Sign in form', () => { | ||||
|     fireEvent.change(passwordInput, { target: { value: 'test' } }); | ||||
|     expect(usernameInput).toHaveValue('test'); | ||||
|     expect(passwordInput).toHaveValue('test'); | ||||
|     expect(screen.getByTestId('openid-divider')).toBeInTheDocument(); | ||||
|   }); | ||||
|  | ||||
|   it('should display error if username and password values are empty after change', async () => { | ||||
|   | ||||
| @@ -22,6 +22,14 @@ const mockCVEList = { | ||||
|   CVEListForImage: { | ||||
|     Tag: '', | ||||
|     Page: { ItemCount: 20, TotalCount: 20 }, | ||||
|     Summary: { | ||||
|       Count: 5, | ||||
|       UnknownCount: 1, | ||||
|       LowCount: 1, | ||||
|       MediumCount: 1, | ||||
|       HighCount: 1, | ||||
|       CriticalCount: 1, | ||||
|     }, | ||||
|     CVEList: [ | ||||
|       { | ||||
|         Id: 'CVE-2020-16156', | ||||
| @@ -442,10 +450,52 @@ const mockCVEListFiltered = { | ||||
|   CVEListForImage: { | ||||
|     Tag: '', | ||||
|     Page: { ItemCount: 20, TotalCount: 20 }, | ||||
|     Summary: { | ||||
|       Count: 5, | ||||
|       UnknownCount: 1, | ||||
|       LowCount: 1, | ||||
|       MediumCount: 1, | ||||
|       HighCount: 1, | ||||
|       CriticalCount: 1, | ||||
|     }, | ||||
|     CVEList: mockCVEList.CVEListForImage.CVEList.filter((e) => e.Id.includes('2022')) | ||||
|   } | ||||
| }; | ||||
|  | ||||
| const mockCVEListFilteredBySeverity = (severity) => { | ||||
|   return { | ||||
|     CVEListForImage: { | ||||
|       Tag: '', | ||||
|       Page: { ItemCount: 20, TotalCount: 20 }, | ||||
|       Summary: { | ||||
|         Count: 5, | ||||
|         UnknownCount: 1, | ||||
|         LowCount: 1, | ||||
|         MediumCount: 1, | ||||
|         HighCount: 1, | ||||
|         CriticalCount: 1, | ||||
|       }, | ||||
|       CVEList: mockCVEList.CVEListForImage.CVEList.filter((e) => e.Severity.includes(severity)) | ||||
|     } | ||||
|   }; | ||||
| }; | ||||
|  | ||||
| const mockCVEListFilteredExclude = { | ||||
|   CVEListForImage: { | ||||
|     Tag: '', | ||||
|     Page: { ItemCount: 20, TotalCount: 20 }, | ||||
|     Summary: { | ||||
|       Count: 5, | ||||
|       UnknownCount: 1, | ||||
|       LowCount: 1, | ||||
|       MediumCount: 1, | ||||
|       HighCount: 1, | ||||
|       CriticalCount: 1, | ||||
|     }, | ||||
|     CVEList: mockCVEList.CVEListForImage.CVEList.filter((e) => !e.Id.includes('2022')) | ||||
|   } | ||||
| }; | ||||
|  | ||||
| const mockCVEFixed = { | ||||
|   pageOne: { | ||||
|     ImageListWithCVEFixed: { | ||||
| @@ -499,41 +549,97 @@ describe('Vulnerabilties page', () => { | ||||
|     jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockCVEList } }); | ||||
|     render(<StateVulnerabilitiesWrapper />); | ||||
|     await waitFor(() => expect(screen.getAllByText('Vulnerabilities')).toHaveLength(1)); | ||||
|     await waitFor(() => expect(screen.getAllByText(/fixed in/i)).toHaveLength(20)); | ||||
|     await waitFor(() => expect(screen.getAllByText('Total 5')).toHaveLength(1)); | ||||
|     await waitFor(() => expect(screen.getAllByText(/CVE/)).toHaveLength(20)); | ||||
|   }); | ||||
|  | ||||
|   it('renders the vulnerabilities by severity', async () => { | ||||
|     jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockCVEList } }); | ||||
|     render(<StateVulnerabilitiesWrapper />); | ||||
|     await waitFor(() => expect(screen.getAllByText('Vulnerabilities')).toHaveLength(1)); | ||||
|     await waitFor(() => expect(screen.getAllByText('Total 5')).toHaveLength(1)); | ||||
|     await waitFor(() => expect(screen.getAllByText(/CVE-/)).toHaveLength(20)); | ||||
|     expect(screen.getByLabelText('Medium')).toBeInTheDocument(); | ||||
|     const mediumSeverity = await screen.getByLabelText('Medium'); | ||||
|     jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockCVEListFilteredBySeverity('MEDIUM') } }); | ||||
|     fireEvent.click(mediumSeverity); | ||||
|     await waitFor(() => expect(screen.getAllByText(/CVE-/)).toHaveLength(6)); | ||||
|     expect(screen.getByLabelText('High')).toBeInTheDocument(); | ||||
|     const highSeverity = await screen.getByLabelText('High'); | ||||
|     jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockCVEListFilteredBySeverity('HIGH') } }); | ||||
|     fireEvent.click(highSeverity); | ||||
|     await waitFor(() => expect(screen.getAllByText(/CVE-/)).toHaveLength(1)); | ||||
|     expect(screen.getByLabelText('Critical')).toBeInTheDocument(); | ||||
|     const criticalSeverity = await screen.getByLabelText('Critical'); | ||||
|     jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockCVEListFilteredBySeverity('CRITICAL') } }); | ||||
|     fireEvent.click(criticalSeverity); | ||||
|     await waitFor(() => expect(screen.getAllByText(/CVE-/)).toHaveLength(1)); | ||||
|     expect(screen.getByLabelText('Low')).toBeInTheDocument(); | ||||
|     const lowSeverity = await screen.getByLabelText('Low'); | ||||
|     jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockCVEListFilteredBySeverity('LOW') } }); | ||||
|     fireEvent.click(lowSeverity); | ||||
|     await waitFor(() => expect(screen.getAllByText(/CVE-/)).toHaveLength(10)); | ||||
|     expect(screen.getByLabelText('Unknown')).toBeInTheDocument(); | ||||
|     const unknownSeverity = await screen.getByLabelText('Unknown'); | ||||
|     jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockCVEListFilteredBySeverity('UNKNOWN') } }); | ||||
|     fireEvent.click(unknownSeverity); | ||||
|     await waitFor(() => expect(screen.getAllByText(/CVE-/)).toHaveLength(1)); | ||||
|     expect(screen.getByText('Total 5')).toBeInTheDocument(); | ||||
|     const totalSeverity = await screen.getByText('Total 5'); | ||||
|     jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockCVEListFilteredBySeverity('') } }); | ||||
|     fireEvent.click(totalSeverity); | ||||
|     await waitFor(() => expect(screen.getAllByText(/CVE-/)).toHaveLength(20)); | ||||
|   }); | ||||
|  | ||||
|   it('sends filtered query if user types in the search bar', async () => { | ||||
|     jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockCVEList } }); | ||||
|     jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockCVEList } }); | ||||
|     render(<StateVulnerabilitiesWrapper />); | ||||
|     await waitFor(() => expect(screen.getAllByText(/CVE/)).toHaveLength(20)); | ||||
|     const cveSearchInput = screen.getByPlaceholderText(/search/i); | ||||
|     jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockCVEListFiltered } }); | ||||
|     await userEvent.type(cveSearchInput, '2022'); | ||||
|     expect(cveSearchInput).toHaveValue('2022') | ||||
|     await waitFor(() => expect(screen.queryAllByText(/2022/i)).toHaveLength(7)); | ||||
|     await waitFor(() => expect(screen.queryAllByText(/2021/i)).toHaveLength(1)); | ||||
|   }); | ||||
|  | ||||
|   it('should have a collapsable search bar', async () => { | ||||
|     jest.spyOn(api, 'get'). | ||||
|       mockResolvedValueOnce({ status: 200, data: { data: mockCVEList } }). | ||||
|       mockResolvedValue({ status: 200, data: { data: mockCVEListFilteredExclude } }); | ||||
|     render(<StateVulnerabilitiesWrapper />); | ||||
|     const cveSearchInput = screen.getByPlaceholderText(/search/i); | ||||
|     jest.spyOn(api, 'get').mockRejectedValueOnce({ status: 200, data: { data: mockCVEListFiltered } }); | ||||
|     await userEvent.type(cveSearchInput, '2022'); | ||||
|     expect((await screen.queryAllByText(/2023/i).length) === 0); | ||||
|     expect((await screen.findAllByText(/2022/i)).length === 6); | ||||
|   }); | ||||
|     const expandSearch = cveSearchInput.parentElement.parentElement.parentElement.parentElement.childNodes[0]; | ||||
|     await fireEvent.click(expandSearch); | ||||
|     await waitFor(() =>  | ||||
|       expect(screen.getAllByPlaceholderText("Exclude")).toHaveLength(1) | ||||
|     ); | ||||
|     const excludeInput = screen.getByPlaceholderText("Exclude"); | ||||
|     userEvent.type(excludeInput, '2022'); | ||||
|     expect(excludeInput).toHaveValue('2022') | ||||
|     await waitFor(() => expect(screen.queryAllByText(/2022/i)).toHaveLength(0)); | ||||
|     await waitFor(() => expect(screen.queryAllByText(/2021/i)).toHaveLength(6)); | ||||
|   }) | ||||
|  | ||||
|   it('renders no vulnerabilities if there are not any', async () => { | ||||
|     jest.spyOn(api, 'get').mockResolvedValue({ | ||||
|       status: 200, | ||||
|       data: { data: { CVEListForImage: { Tag: '', Page: {}, CVEList: [] } } } | ||||
|       data: { data: { CVEListForImage: { Tag: '', Page: {}, CVEList: [], Summary: {} } } } | ||||
|     }); | ||||
|     render(<StateVulnerabilitiesWrapper />); | ||||
|     await waitFor(() => expect(screen.getAllByText('No Vulnerabilities')).toHaveLength(1)); | ||||
|   }); | ||||
|  | ||||
|   it('should open and close description dropdown for vulnerabilities', async () => { | ||||
|     jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockCVEList } }); | ||||
|   it('should show description for vulnerabilities', async () => { | ||||
|     jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockCVEList } }) | ||||
|       .mockResolvedValue({ status: 200, data: { data: mockCVEFixed.pageOne } }); | ||||
|     render(<StateVulnerabilitiesWrapper />); | ||||
|     await waitFor(() => expect(screen.getAllByText(/description/i)).toHaveLength(20)); | ||||
|     const openText = screen.getAllByText(/description/i); | ||||
|     await fireEvent.click(openText[0]); | ||||
|     const expandListBtn = await screen.findAllByTestId('ViewAgendaIcon'); | ||||
|     fireEvent.click(expandListBtn[0]); | ||||
|     await waitFor(() => expect(screen.getAllByText(/Description/)).toHaveLength(20)); | ||||
|     await waitFor(() => | ||||
|       expect(screen.getAllByText(/CPAN 2.28 allows Signature Verification Bypass./i)).toHaveLength(1) | ||||
|     ); | ||||
|     await fireEvent.click(openText[0]); | ||||
|     await waitFor(() => | ||||
|       expect(screen.queryByText(/CPAN 2.28 allows Signature Verification Bypass./i)).not.toBeInTheDocument() | ||||
|     ); | ||||
|   }); | ||||
|  | ||||
|   it("should log an error when data can't be fetched", async () => { | ||||
| @@ -551,10 +657,11 @@ describe('Vulnerabilties page', () => { | ||||
|       .mockResolvedValueOnce({ status: 200, data: { data: mockCVEFixed.pageTwo } }); | ||||
|     render(<StateVulnerabilitiesWrapper />); | ||||
|     await waitFor(() => expect(screen.getAllByText('Vulnerabilities')).toHaveLength(1)); | ||||
|     await fireEvent.click(screen.getAllByText(/fixed in/i)[0]); | ||||
|     const expandListBtn = await screen.findAllByTestId('KeyboardArrowRightIcon'); | ||||
|     fireEvent.click(expandListBtn[1]); | ||||
|     await waitFor(() => expect(screen.getByText('1.0.16')).toBeInTheDocument()); | ||||
|     const loadMoreBtn = screen.getByText(/load more/i); | ||||
|     expect(loadMoreBtn).toBeInTheDocument(); | ||||
|     await waitFor(() => expect(screen.getAllByText(/Load more/).length).toBe(1)); | ||||
|     const loadMoreBtn = screen.getAllByText(/Load more/)[0]; | ||||
|     await fireEvent.click(loadMoreBtn); | ||||
|     await waitFor(() => expect(loadMoreBtn).not.toBeInTheDocument()); | ||||
|     expect(await screen.findByText('latest')).toBeInTheDocument(); | ||||
| @@ -564,28 +671,55 @@ describe('Vulnerabilties page', () => { | ||||
|     const xlsxMock = jest.createMockFromModule('xlsx'); | ||||
|     xlsxMock.writeFile = jest.fn(); | ||||
|  | ||||
|     jest | ||||
|       .spyOn(api, 'get') | ||||
|       .mockResolvedValueOnce({ status: 200, data: { data: mockCVEList } }) | ||||
|       .mockResolvedValueOnce({ status: 200, data: { data: mockCVEList } }); | ||||
|     jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockCVEList } }); | ||||
|     render(<StateVulnerabilitiesWrapper />); | ||||
|     await waitFor(() => expect(screen.getAllByText('Vulnerabilities')).toHaveLength(1)); | ||||
|     const downloadBtn = await screen.findAllByTestId('DownloadIcon'); | ||||
|     fireEvent.click(downloadBtn[0]); | ||||
|     expect(await screen.findByTestId('export-csv-menuItem')).toBeInTheDocument(); | ||||
|     expect(await screen.findByTestId('export-excel-menuItem')).toBeInTheDocument(); | ||||
|     const exportAsCSVBtn = screen.getByText(/CSV/i); | ||||
|     const exportAsCSVBtn = screen.getByText(/csv/i); | ||||
|     expect(exportAsCSVBtn).toBeInTheDocument(); | ||||
|     global.URL.createObjectURL = jest.fn(); | ||||
|     await fireEvent.click(exportAsCSVBtn); | ||||
|     expect(await screen.findByTestId('export-csv-menuItem')).not.toBeInTheDocument(); | ||||
|     fireEvent.click(downloadBtn[0]); | ||||
|     const exportAsExcelBtn = screen.getByText(/MS Excel/i); | ||||
|     const exportAsExcelBtn = screen.getByText(/xlsx/i); | ||||
|     expect(exportAsExcelBtn).toBeInTheDocument(); | ||||
|     await fireEvent.click(exportAsExcelBtn); | ||||
|     expect(await screen.findByTestId('export-excel-menuItem')).not.toBeInTheDocument(); | ||||
|   }); | ||||
|  | ||||
|   it("should log an error when data can't be fetched for downloading", async () => { | ||||
|     const xlsxMock = jest.createMockFromModule('xlsx'); | ||||
|     xlsxMock.writeFile = jest.fn(); | ||||
|  | ||||
|     jest.spyOn(api, 'get'). | ||||
|       mockResolvedValueOnce({ status: 200, data: { data: mockCVEList } }). | ||||
|       mockRejectedValue({ status: 500, data: {} }); | ||||
|     const error = jest.spyOn(console, 'error').mockImplementation(() => {}); | ||||
|     render(<StateVulnerabilitiesWrapper />); | ||||
|     await waitFor(() => expect(screen.getAllByText('Vulnerabilities')).toHaveLength(1)); | ||||
|     const downloadBtn = await screen.findAllByTestId('DownloadIcon'); | ||||
|     fireEvent.click(downloadBtn[0]); | ||||
|     expect(await screen.findByTestId('export-csv-menuItem')).toBeInTheDocument(); | ||||
|     expect(await screen.findByTestId('export-excel-menuItem')).toBeInTheDocument(); | ||||
|     await waitFor(() => expect(error).toBeCalledTimes(1)); | ||||
|   }); | ||||
|  | ||||
|   it('should expand/collapse the list of CVEs', async () => { | ||||
|     jest.spyOn(api, 'get').mockResolvedValueOnce({ status: 200, data: { data: mockCVEList } }); | ||||
|     render(<StateVulnerabilitiesWrapper />); | ||||
|     await waitFor(() => expect(screen.getAllByText('Vulnerabilities')).toHaveLength(1)); | ||||
|     jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockCVEFixed.pageOne } }); | ||||
|     const expandListBtn = await screen.findAllByTestId('ViewAgendaIcon'); | ||||
|     fireEvent.click(expandListBtn[0]); | ||||
|     await waitFor(() => expect(screen.getAllByText('Fixed in')).toHaveLength(20)); | ||||
|     const collapseListBtn = await screen.findAllByTestId('ViewHeadlineIcon'); | ||||
|     fireEvent.click(collapseListBtn[0]); | ||||
|     expect(await screen.findByText('Fixed in')).not.toBeVisible(); | ||||
|   }); | ||||
|  | ||||
|   it('should handle fixed CVE query errors', async () => { | ||||
|     jest | ||||
|       .spyOn(api, 'get') | ||||
| @@ -594,7 +728,8 @@ describe('Vulnerabilties page', () => { | ||||
|     render(<StateVulnerabilitiesWrapper />); | ||||
|     await waitFor(() => expect(screen.getAllByText('Vulnerabilities')).toHaveLength(1)); | ||||
|     const error = jest.spyOn(console, 'error').mockImplementation(() => {}); | ||||
|     await fireEvent.click(screen.getAllByText(/fixed in/i)[0]); | ||||
|     const expandListBtn = await screen.findAllByTestId('KeyboardArrowRightIcon'); | ||||
|     fireEvent.click(expandListBtn[1]); | ||||
|     await waitFor(() => expect(screen.getByText(/not fixed/i)).toBeInTheDocument()); | ||||
|     await waitFor(() => expect(error).toBeCalledTimes(1)); | ||||
|   }); | ||||
|   | ||||
							
								
								
									
										22
									
								
								src/api.js
									
									
									
									
									
								
							
							
						
						
									
										22
									
								
								src/api.js
									
									
									
									
									
								
							| @@ -67,11 +67,14 @@ const api = { | ||||
|     return axios.put(urli, payload, config); | ||||
|   }, | ||||
|  | ||||
|   delete(urli, abortSignal, cfg) { | ||||
|   delete(urli, params, abortSignal, cfg) { | ||||
|     let config = isEmpty(cfg) ? this.getRequestCfg() : cfg; | ||||
|     if (!isEmpty(abortSignal) && isEmpty(config.signal)) { | ||||
|       config = { ...config, signal: abortSignal }; | ||||
|     } | ||||
|     if (!isEmpty(params)) { | ||||
|       config = { ...config, params }; | ||||
|     } | ||||
|     return axios.delete(urli, config); | ||||
|   } | ||||
| }; | ||||
| @@ -81,6 +84,7 @@ const endpoints = { | ||||
|   authConfig: `/v2/_zot/ext/mgmt`, | ||||
|   openidAuth: `/zot/auth/login`, | ||||
|   logout: `/zot/auth/logout`, | ||||
|   apiKeys: '/zot/auth/apikey', | ||||
|   deleteImage: (name, tag) => `/v2/${name}/manifests/${tag}`, | ||||
|   repoList: ({ pageNumber = 1, pageSize = 15 } = {}) => | ||||
|     `/v2/_zot/ext/search?query={RepoListWithNewestImage(requestedPage: {limit:${pageSize} offset:${ | ||||
| @@ -90,14 +94,26 @@ const endpoints = { | ||||
|     `/v2/_zot/ext/search?query={ExpandedRepoInfo(repo:"${name}"){Images {Manifests {Digest Platform {Os Arch} Size} Vulnerabilities {MaxSeverity Count} Tag LastUpdated Vendor IsDeletable } Summary {Name LastUpdated Size Platforms {Os Arch} Vendors IsStarred IsBookmarked NewestImage {RepoName IsSigned SignatureInfo { Tool IsTrusted Author } Vulnerabilities {MaxSeverity Count} Manifests {Digest} Tag Vendor Title Documentation DownloadCount Source Description Licenses}}}}`, | ||||
|   detailedImageInfo: (name, tag) => | ||||
|     `/v2/_zot/ext/search?query={Image(image: "${name}:${tag}"){RepoName IsSigned SignatureInfo { Tool IsTrusted Author } Vulnerabilities {MaxSeverity Count}  Referrers {MediaType ArtifactType Size Digest Annotations{Key Value}} Tag Manifests {History {Layer {Size Digest} HistoryDescription {CreatedBy EmptyLayer}} Digest ConfigDigest LastUpdated Size Platform {Os Arch}} Vendor Licenses }}`, | ||||
|   vulnerabilitiesForRepo: (name, { pageNumber = 1, pageSize = 15 }, searchTerm = '') => { | ||||
|   vulnerabilitiesForRepo: ( | ||||
|     name, | ||||
|     { pageNumber = 1, pageSize = 15 }, | ||||
|     searchTerm = '', | ||||
|     excludedTerm = '', | ||||
|     severity = '' | ||||
|   ) => { | ||||
|     let query = `/v2/_zot/ext/search?query={CVEListForImage(image: "${name}", requestedPage: {limit:${pageSize} offset:${ | ||||
|       (pageNumber - 1) * pageSize | ||||
|     }}`; | ||||
|     if (!isEmpty(searchTerm)) { | ||||
|       query += `, searchedCVE: "${searchTerm}"`; | ||||
|     } | ||||
|     return `${query}){Tag Page {TotalCount ItemCount} CVEList {Id Title Description Severity PackageList {Name InstalledVersion FixedVersion}}}}`; | ||||
|     if (!isEmpty(excludedTerm)) { | ||||
|       query += `, excludedCVE: "${excludedTerm}"`; | ||||
|     } | ||||
|     if (!isEmpty(severity)) { | ||||
|       query += `, severity: "${severity}"`; | ||||
|     } | ||||
|     return `${query}){Tag Page {TotalCount ItemCount} CVEList {Id Title Description Severity Reference PackageList {Name InstalledVersion FixedVersion}} Summary {Count UnknownCount LowCount MediumCount HighCount CriticalCount}}}`; | ||||
|   }, | ||||
|   allVulnerabilitiesForRepo: (name) => | ||||
|     `/v2/_zot/ext/search?query={CVEListForImage(image: "${name}"){Tag Page {TotalCount ItemCount} CVEList {Id Title Description Severity Reference PackageList {Name InstalledVersion FixedVersion}}}}`, | ||||
|   | ||||
| @@ -2,11 +2,17 @@ import React, { useState } from 'react'; | ||||
|  | ||||
| import { Menu, MenuItem, IconButton, Avatar, Divider } from '@mui/material'; | ||||
|  | ||||
| import { getLoggedInUser, logoutUser } from '../../utilities/authUtilities'; | ||||
| import { getLoggedInUser, logoutUser, isApiKeyEnabled } from '../../utilities/authUtilities'; | ||||
| import { useNavigate } from 'react-router-dom'; | ||||
|  | ||||
| function UserAccountMenu() { | ||||
|   const [anchorEl, setAnchorEl] = useState(null); | ||||
|   const openMenu = Boolean(anchorEl); | ||||
|   const navigate = useNavigate(); | ||||
|  | ||||
|   const apiKeyManagement = () => { | ||||
|     navigate('/user/apikey'); | ||||
|   }; | ||||
|  | ||||
|   const handleUserClick = (event) => { | ||||
|     setAnchorEl(event.currentTarget); | ||||
| @@ -37,6 +43,8 @@ function UserAccountMenu() { | ||||
|       > | ||||
|         <MenuItem onClick={handleUserClose}>{getLoggedInUser()}</MenuItem> | ||||
|         <Divider /> | ||||
|         {isApiKeyEnabled() && <MenuItem onClick={apiKeyManagement}>API Keys</MenuItem>} | ||||
|         <Divider /> | ||||
|         <MenuItem onClick={logoutUser}>Log out</MenuItem> | ||||
|       </Menu> | ||||
|     </> | ||||
|   | ||||
| @@ -312,7 +312,13 @@ export default function SignIn({ isLoggedIn, setIsLoggedIn, wrapperSetLoading = | ||||
|               Welcome back! Please login. | ||||
|             </Typography> | ||||
|             {renderThirdPartyLoginMethods()} | ||||
|             {Object.keys(authMethods).length > 1 && <Divider className={classes.divider}>or</Divider>} | ||||
|             {Object.keys(authMethods).length > 1 && | ||||
|               Object.keys(authMethods).includes('openid') && | ||||
|               Object.keys(authMethods.openid.providers).length > 0 && ( | ||||
|                 <Divider className={classes.divider} data-testId="openid-divider"> | ||||
|                   or | ||||
|                 </Divider> | ||||
|               )} | ||||
|             {Object.keys(authMethods).includes('htpasswd') && ( | ||||
|               <Box component="form" onSubmit={null} noValidate autoComplete="off"> | ||||
|                 <TextField | ||||
|   | ||||
| @@ -9,6 +9,8 @@ import { isEmpty, uniq } from 'lodash'; | ||||
| import { api, endpoints } from '../../api'; | ||||
| import { host } from '../../host'; | ||||
| import { useParams, useNavigate, createSearchParams } from 'react-router-dom'; | ||||
| import { mapToRepoFromRepoInfo } from 'utilities/objectModels'; | ||||
| import { isAuthenticated } from 'utilities/authUtilities'; | ||||
|  | ||||
| // components | ||||
| import { Card, CardContent, CardMedia, Chip, Grid, Stack, Tooltip, Typography, IconButton } from '@mui/material'; | ||||
| @@ -16,7 +18,11 @@ import BookmarkIcon from '@mui/icons-material/Bookmark'; | ||||
| import BookmarkBorderIcon from '@mui/icons-material/BookmarkBorder'; | ||||
| import StarIcon from '@mui/icons-material/Star'; | ||||
| import StarBorderIcon from '@mui/icons-material/StarBorder'; | ||||
| import makeStyles from '@mui/styles/makeStyles'; | ||||
| import Tags from './Tabs/Tags.jsx'; | ||||
| import RepoDetailsMetadata from './RepoDetailsMetadata'; | ||||
| import Loading from '../Shared/Loading'; | ||||
| import { Markdown } from 'utilities/MarkdowntojsxWrapper'; | ||||
| import { VulnerabilityIconCheck, SignatureIconCheck } from 'utilities/vulnerabilityAndSignatureCheck'; | ||||
|  | ||||
| // placeholder images | ||||
| import repocube1 from '../../assets/repocube-1.png'; | ||||
| @@ -24,13 +30,7 @@ import repocube2 from '../../assets/repocube-2.png'; | ||||
| import repocube3 from '../../assets/repocube-3.png'; | ||||
| import repocube4 from '../../assets/repocube-4.png'; | ||||
|  | ||||
| import Tags from './Tabs/Tags.jsx'; | ||||
| import RepoDetailsMetadata from './RepoDetailsMetadata'; | ||||
| import Loading from '../Shared/Loading'; | ||||
| import { Markdown } from 'utilities/MarkdowntojsxWrapper'; | ||||
| import { VulnerabilityIconCheck, SignatureIconCheck } from 'utilities/vulnerabilityAndSignatureCheck'; | ||||
| import { mapToRepoFromRepoInfo } from 'utilities/objectModels'; | ||||
| import { isAuthenticated } from 'utilities/authUtilities'; | ||||
| import makeStyles from '@mui/styles/makeStyles'; | ||||
|  | ||||
| const useStyles = makeStyles((theme) => ({ | ||||
|   pageWrapper: { | ||||
|   | ||||
| @@ -1,9 +1,11 @@ | ||||
| import React from 'react'; | ||||
| import makeStyles from '@mui/styles/makeStyles'; | ||||
| import React, { useState } from 'react'; | ||||
|  | ||||
| import transform from 'utilities/transform'; | ||||
|  | ||||
| import { Card, CardContent, Typography, Grid, Divider, Stack, Collapse } from '@mui/material'; | ||||
| import { KeyboardArrowDown, KeyboardArrowRight } from '@mui/icons-material'; | ||||
| import transform from 'utilities/transform'; | ||||
| import { useState } from 'react'; | ||||
|  | ||||
| import makeStyles from '@mui/styles/makeStyles'; | ||||
|  | ||||
| const useStyles = makeStyles(() => ({ | ||||
|   card: { | ||||
|   | ||||
| @@ -29,18 +29,46 @@ const useStyles = makeStyles((theme) => ({ | ||||
|     marginTop: '2rem', | ||||
|     marginBottom: '2rem' | ||||
|   }, | ||||
|   cardCollapsed: { | ||||
|     display: 'flex', | ||||
|     flexDirection: 'row', | ||||
|     alignItems: 'center', | ||||
|     background: '#FFFFFF', | ||||
|     boxShadow: '0rem 0.3125rem 0.625rem rgba(131, 131, 131, 0.08)', | ||||
|     border: '1px solid #E0E5EB', | ||||
|     borderRadius: '0.75rem', | ||||
|     flex: 'none', | ||||
|     alignSelf: 'stretch', | ||||
|     width: '100%' | ||||
|   }, | ||||
|   content: { | ||||
|     textAlign: 'left', | ||||
|     color: '#606060', | ||||
|     padding: '2% 3% 2% 3%', | ||||
|     width: '100%' | ||||
|   }, | ||||
|   contentCollapsed: { | ||||
|     textAlign: 'left', | ||||
|     color: '#606060', | ||||
|     padding: '1% 3% 1% 3%', | ||||
|     width: '100%', | ||||
|     '&:last-child': { | ||||
|       paddingBottom: '1%' | ||||
|     } | ||||
|   }, | ||||
|   cveId: { | ||||
|     color: theme.palette.primary.main, | ||||
|     fontSize: '1rem', | ||||
|     fontWeight: 400, | ||||
|     textDecoration: 'underline' | ||||
|   }, | ||||
|   cveIdCollapsed: { | ||||
|     color: theme.palette.primary.main, | ||||
|     fontSize: '0.75rem', | ||||
|     fontWeight: 500, | ||||
|     textDecoration: 'underline', | ||||
|     flexBasis: '19%' | ||||
|   }, | ||||
|   cveSummary: { | ||||
|     color: theme.palette.secondary.dark, | ||||
|     fontSize: '0.75rem', | ||||
| @@ -48,6 +76,13 @@ const useStyles = makeStyles((theme) => ({ | ||||
|     textOverflow: 'ellipsis', | ||||
|     marginTop: '0.5rem' | ||||
|   }, | ||||
|   cveSummaryCollapsed: { | ||||
|     color: theme.palette.secondary.dark, | ||||
|     fontSize: '0.75rem', | ||||
|     fontWeight: '600', | ||||
|     textOverflow: 'ellipsis', | ||||
|     flexBasis: '82%' | ||||
|   }, | ||||
|   link: { | ||||
|     color: '#52637A', | ||||
|     fontSize: '1rem', | ||||
| @@ -66,15 +101,21 @@ const useStyles = makeStyles((theme) => ({ | ||||
|     cursor: 'pointer', | ||||
|     textAlign: 'center' | ||||
|   }, | ||||
|   dropdownCVE: { | ||||
|     color: '#1479FF', | ||||
|     cursor: 'pointer' | ||||
|   }, | ||||
|   vulnerabilityCardDivider: { | ||||
|     margin: '1rem 0' | ||||
|   }, | ||||
|   cveInfo: { | ||||
|     marginTop: '2%' | ||||
|   } | ||||
| })); | ||||
| function VulnerabilitiyCard(props) { | ||||
|   const classes = useStyles(); | ||||
|   const { cve, name, platform } = props; | ||||
|   const [openDesc, setOpenDesc] = useState(false); | ||||
|   const [openFixed, setOpenFixed] = useState(false); | ||||
|   const { cve, name, platform, expand } = props; | ||||
|   const [openCVE, setOpenCVE] = useState(expand); | ||||
|   const [loadingFixed, setLoadingFixed] = useState(true); | ||||
|   const [fixedInfo, setFixedInfo] = useState([]); | ||||
|   const abortController = useMemo(() => new AbortController(), []); | ||||
| @@ -82,9 +123,10 @@ function VulnerabilitiyCard(props) { | ||||
|   // pagination props | ||||
|   const [pageNumber, setPageNumber] = useState(1); | ||||
|   const [isEndOfList, setIsEndOfList] = useState(false); | ||||
|   const [loadMoreInfo, setLoadMoreInfo] = useState(false); | ||||
|  | ||||
|   const getPaginatedResults = () => { | ||||
|     if (!openFixed || isEndOfList) { | ||||
|     if (!openCVE || (!loadMoreInfo && !isEmpty(fixedInfo)) || isEndOfList) { | ||||
|       return; | ||||
|     } | ||||
|     setLoadingFixed(true); | ||||
| @@ -107,11 +149,13 @@ function VulnerabilitiyCard(props) { | ||||
|           ); | ||||
|         } | ||||
|         setLoadingFixed(false); | ||||
|         setLoadMoreInfo(false); | ||||
|       }) | ||||
|       .catch((e) => { | ||||
|         console.error(e); | ||||
|         setIsEndOfList(true); | ||||
|         setLoadingFixed(false); | ||||
|         setLoadMoreInfo(false); | ||||
|       }); | ||||
|   }; | ||||
|  | ||||
| @@ -120,10 +164,15 @@ function VulnerabilitiyCard(props) { | ||||
|     return () => { | ||||
|       abortController.abort(); | ||||
|     }; | ||||
|   }, [openFixed, pageNumber]); | ||||
|   }, [openCVE, pageNumber, loadMoreInfo]); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     setOpenCVE(expand); | ||||
|   }, [expand]); | ||||
|  | ||||
|   const loadMore = () => { | ||||
|     if (loadingFixed || isEndOfList) return; | ||||
|     setLoadMoreInfo(true); | ||||
|     setPageNumber((pageNumber) => pageNumber + 1); | ||||
|   }; | ||||
|  | ||||
| @@ -163,27 +212,81 @@ function VulnerabilitiyCard(props) { | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <Card className={classes.card} raised> | ||||
|       <CardContent className={classes.content}> | ||||
|         <Stack direction="row" spacing="1.25rem"> | ||||
|           <Typography variant="body1" align="left" className={classes.cveId}> | ||||
|     <Card className={openCVE ? classes.card : classes.cardCollapsed} raised> | ||||
|       <CardContent className={openCVE ? classes.content : classes.contentCollapsed}> | ||||
|         <Stack direction="row" spacing={openCVE ? '1.25rem' : '0.5rem'}> | ||||
|           {!openCVE ? ( | ||||
|             <KeyboardArrowRight className={classes.dropdownCVE} onClick={() => setOpenCVE(!openCVE)} /> | ||||
|           ) : ( | ||||
|             <KeyboardArrowDown className={classes.dropdownCVE} onClick={() => setOpenCVE(!openCVE)} /> | ||||
|           )} | ||||
|           <Typography variant="body1" align="left" className={openCVE ? classes.cveId : classes.cveIdCollapsed}> | ||||
|             {cve.id} | ||||
|           </Typography> | ||||
|           <VulnerabilityChipCheck vulnerabilitySeverity={cve.severity} /> | ||||
|         </Stack> | ||||
|         <Typography variant="body1" align="left" className={classes.cveSummary}> | ||||
|           {cve.title} | ||||
|         </Typography> | ||||
|         <Divider className={classes.vulnerabilityCardDivider} /> | ||||
|         <Stack className={classes.dropdown} onClick={() => setOpenFixed(!openFixed)}> | ||||
|           {!openFixed ? ( | ||||
|             <KeyboardArrowRight className={classes.dropdownText} /> | ||||
|           {openCVE ? ( | ||||
|             <VulnerabilityChipCheck vulnerabilitySeverity={cve.severity} /> | ||||
|           ) : ( | ||||
|             <KeyboardArrowDown className={classes.dropdownText} /> | ||||
|             <Stack direction="row" spacing="0.5rem" flexBasis="90%"> | ||||
|               <div style={{ transform: 'scale(0.8)', flexBasis: '18%', flexShrink: '0' }}> | ||||
|                 <VulnerabilityChipCheck vulnerabilitySeverity={cve.severity} /> | ||||
|               </div> | ||||
|               <Typography variant="body1" align="left" className={classes.cveSummaryCollapsed}> | ||||
|                 {cve.title} | ||||
|               </Typography> | ||||
|             </Stack> | ||||
|           )} | ||||
|           <Typography className={classes.dropdownText}>Fixed in</Typography> | ||||
|         </Stack> | ||||
|         <Collapse in={openFixed} timeout="auto" unmountOnExit> | ||||
|         <Collapse in={openCVE} timeout="auto" unmountOnExit> | ||||
|           <Typography variant="body1" align="left" className={classes.cveSummary}> | ||||
|             {cve.title} | ||||
|           </Typography> | ||||
|           <Divider className={classes.vulnerabilityCardDivider} /> | ||||
|           <Typography variant="body2" align="left" className={classes.cveInfo}> | ||||
|             External reference | ||||
|           </Typography> | ||||
|           <Typography | ||||
|             variant="body2" | ||||
|             align="left" | ||||
|             sx={{ color: '#0F2139', fontSize: '1rem', textDecoration: 'underline' }} | ||||
|             component={Link} | ||||
|             to={cve.reference} | ||||
|             target="_blank" | ||||
|             rel="noreferrer" | ||||
|           > | ||||
|             {cve.reference} | ||||
|           </Typography> | ||||
|           <Typography variant="body2" align="left" className={classes.cveInfo}> | ||||
|             Packages | ||||
|           </Typography> | ||||
|           <Stack direction="column" sx={{ width: '100%', padding: '0.5rem 0' }}> | ||||
|             <Stack direction="row" spacing="1.25rem" display="flex"> | ||||
|               <Typography variant="body1" flexBasis="33.33%"> | ||||
|                 Name | ||||
|               </Typography> | ||||
|               <Typography variant="body1" flexBasis="33.33%" textAlign="right"> | ||||
|                 Installed Version | ||||
|               </Typography> | ||||
|               <Typography variant="body1" flexBasis="33.33%" textAlign="right"> | ||||
|                 Fixed Version | ||||
|               </Typography> | ||||
|             </Stack> | ||||
|             {cve.packageList.map((el) => ( | ||||
|               <Stack direction="row" key={cve.packageName} spacing="1.25rem" display="flex"> | ||||
|                 <Typography variant="body1" color="primary" flexBasis="33.33%"> | ||||
|                   {el.packageName} | ||||
|                 </Typography> | ||||
|                 <Typography variant="body1" color="primary" flexBasis="33.33%" textAlign="right"> | ||||
|                   {el.packageInstalledVersion} | ||||
|                 </Typography> | ||||
|                 <Typography variant="body1" color="primary" flexBasis="33.33%" textAlign="right"> | ||||
|                   {el.packageFixedVersion} | ||||
|                 </Typography> | ||||
|               </Stack> | ||||
|             ))} | ||||
|           </Stack> | ||||
|           <Typography variant="body2" align="left" className={classes.cveInfo}> | ||||
|             Fixed in | ||||
|           </Typography> | ||||
|           <Box sx={{ width: '100%', padding: '0.5rem 0' }}> | ||||
|             {loadingFixed ? ( | ||||
|               'Loading...' | ||||
| @@ -194,16 +297,9 @@ function VulnerabilitiyCard(props) { | ||||
|               </Stack> | ||||
|             )} | ||||
|           </Box> | ||||
|         </Collapse> | ||||
|         <Stack className={classes.dropdown} onClick={() => setOpenDesc(!openDesc)}> | ||||
|           {!openDesc ? ( | ||||
|             <KeyboardArrowRight className={classes.dropdownText} /> | ||||
|           ) : ( | ||||
|             <KeyboardArrowDown className={classes.dropdownText} /> | ||||
|           )} | ||||
|           <Typography className={classes.dropdownText}>Description</Typography> | ||||
|         </Stack> | ||||
|         <Collapse in={openDesc} timeout="auto" unmountOnExit> | ||||
|           <Typography variant="body2" align="left" className={classes.cveInfo}> | ||||
|             Description | ||||
|           </Typography> | ||||
|           <Box sx={{ padding: '0.5rem 0' }}> | ||||
|             <Typography variant="body2" align="left" sx={{ color: '#0F2139', fontSize: '1rem' }}> | ||||
|               {cve.description} | ||||
|   | ||||
							
								
								
									
										100
									
								
								src/components/Shared/VulnerabilityCountCard.jsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										100
									
								
								src/components/Shared/VulnerabilityCountCard.jsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,100 @@ | ||||
| import React from 'react'; | ||||
|  | ||||
| import makeStyles from '@mui/styles/makeStyles'; | ||||
| import { Stack, Tooltip } from '@mui/material'; | ||||
|  | ||||
| const criticalColor = '#ff5c74'; | ||||
| const criticalBorderColor = '#f9546d'; | ||||
|  | ||||
| const highColor = '#ff6840'; | ||||
| const highBorderColor = '#ee6b49'; | ||||
|  | ||||
| const mediumColor = '#ffa052'; | ||||
| const mediumBorderColor = '#f19d5b'; | ||||
|  | ||||
| const lowColor = '#f9f486'; | ||||
| const lowBorderColor = '#f0ed94'; | ||||
|  | ||||
| const unknownColor = '#f2ffdd'; | ||||
| const unknownBorderColor = '#e9f4d7'; | ||||
|  | ||||
| const totalBorderColor = '#e0e5eb'; | ||||
|  | ||||
| const fontSize = '0.75rem'; | ||||
|  | ||||
| const useStyles = makeStyles((theme) => ({ | ||||
|   cveCountCard: { | ||||
|     display: 'flex', | ||||
|     alignItems: 'center', | ||||
|     paddingLeft: '0.5rem', | ||||
|     paddingRight: '0.5rem', | ||||
|     color: theme.palette.primary.main, | ||||
|     fontSize: fontSize, | ||||
|     fontWeight: '600', | ||||
|     borderRadius: '3px', | ||||
|     marginBottom: '0', | ||||
|     cursor: 'pointer' | ||||
|   }, | ||||
|   totalSeverity: { | ||||
|     border: '1px solid ' + totalBorderColor | ||||
|   }, | ||||
|   severityList: { | ||||
|     fontSize: fontSize, | ||||
|     display: 'flex', | ||||
|     flexWrap: 'wrap', | ||||
|     alignItems: 'center', | ||||
|     gap: '0.5em' | ||||
|   }, | ||||
|   criticalSeverity: { | ||||
|     backgroundColor: criticalColor, | ||||
|     border: '1px solid ' + criticalBorderColor | ||||
|   }, | ||||
|   highSeverity: { | ||||
|     backgroundColor: highColor, | ||||
|     border: '1px solid ' + highBorderColor | ||||
|   }, | ||||
|   mediumSeverity: { | ||||
|     backgroundColor: mediumColor, | ||||
|     border: '1px solid ' + mediumBorderColor | ||||
|   }, | ||||
|   lowSeverity: { | ||||
|     backgroundColor: lowColor, | ||||
|     border: '1px solid ' + lowBorderColor | ||||
|   }, | ||||
|   unknownSeverity: { | ||||
|     backgroundColor: unknownColor, | ||||
|     border: '1px solid ' + unknownBorderColor | ||||
|   } | ||||
| })); | ||||
|  | ||||
| function VulnerabilitiyCountCard(props) { | ||||
|   const classes = useStyles(); | ||||
|   const { total, critical, high, medium, low, unknown, filterBySeverity } = props; | ||||
|  | ||||
|   return ( | ||||
|     <Stack direction="row" spacing="0.5em"> | ||||
|       <Tooltip title="Total" onClick={() => filterBySeverity('')}> | ||||
|         <div className={[classes.cveCountCard, classes.totalSeverity].join(' ')}>Total {total}</div> | ||||
|       </Tooltip> | ||||
|       <div className={classes.severityList}> | ||||
|         <Tooltip title="Critical" onClick={() => filterBySeverity('CRITICAL')}> | ||||
|           <div className={[classes.cveCountCard, classes.criticalSeverity].join(' ')}>C {critical}</div> | ||||
|         </Tooltip> | ||||
|         <Tooltip title="High" onClick={() => filterBySeverity('HIGH')}> | ||||
|           <div className={[classes.cveCountCard, classes.highSeverity].join(' ')}>H {high}</div> | ||||
|         </Tooltip> | ||||
|         <Tooltip title="Medium" onClick={() => filterBySeverity('MEDIUM')}> | ||||
|           <div className={[classes.cveCountCard, classes.mediumSeverity].join(' ')}>M {medium}</div> | ||||
|         </Tooltip> | ||||
|         <Tooltip title="Low" onClick={() => filterBySeverity('LOW')}> | ||||
|           <div className={[classes.cveCountCard, classes.lowSeverity].join(' ')}>L {low}</div> | ||||
|         </Tooltip> | ||||
|         <Tooltip title="Unknown" onClick={() => filterBySeverity('UNKNOWN')}> | ||||
|           <div className={[classes.cveCountCard, classes.unknownSeverity].join(' ')}>U {unknown}</div> | ||||
|         </Tooltip> | ||||
|       </div> | ||||
|     </Stack> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| export default VulnerabilitiyCountCard; | ||||
| @@ -9,6 +9,7 @@ import { | ||||
|   Stack, | ||||
|   Typography, | ||||
|   InputBase, | ||||
|   ToggleButton, | ||||
|   Menu, | ||||
|   MenuItem, | ||||
|   Divider, | ||||
| @@ -26,16 +27,32 @@ import DownloadIcon from '@mui/icons-material/Download'; | ||||
|  | ||||
| import * as XLSX from 'xlsx'; | ||||
| import exportFromJSON from 'export-from-json'; | ||||
| import ViewHeadlineIcon from '@mui/icons-material/ViewHeadline'; | ||||
| import ViewAgendaIcon from '@mui/icons-material/ViewAgenda'; | ||||
|  | ||||
| import { KeyboardArrowDown, KeyboardArrowRight } from '@mui/icons-material'; | ||||
| import Collapse from '@mui/material/Collapse'; | ||||
|  | ||||
| import VulnerabilitiyCard from '../../Shared/VulnerabilityCard'; | ||||
| import VulnerabilityCountCard from '../../Shared/VulnerabilityCountCard'; | ||||
|  | ||||
| const useStyles = makeStyles((theme) => ({ | ||||
|   searchAndDisplayBar: { | ||||
|     display: 'flex', | ||||
|     justifyContent: 'space-between' | ||||
|   }, | ||||
|   title: { | ||||
|     color: theme.palette.primary.main, | ||||
|     fontSize: '1.5rem', | ||||
|     fontWeight: '600', | ||||
|     marginBottom: '0' | ||||
|   }, | ||||
|   cveCountSummary: { | ||||
|     color: theme.palette.primary.main, | ||||
|     fontSize: '1.5rem', | ||||
|     fontWeight: '600', | ||||
|     marginBottom: '0' | ||||
|   }, | ||||
|   cveId: { | ||||
|     color: theme.palette.primary.main, | ||||
|     fontSize: '1rem', | ||||
| @@ -64,6 +81,7 @@ const useStyles = makeStyles((theme) => ({ | ||||
|   search: { | ||||
|     position: 'relative', | ||||
|     maxWidth: '100%', | ||||
|     flex: 0.95, | ||||
|     flexDirection: 'row', | ||||
|     alignItems: 'center', | ||||
|     justifyContent: 'space-between', | ||||
| @@ -71,6 +89,20 @@ const useStyles = makeStyles((theme) => ({ | ||||
|     border: '0.063rem solid #E7E7E7', | ||||
|     borderRadius: '0.625rem' | ||||
|   }, | ||||
|   expandableSearchInput: { | ||||
|     flexGrow: 0.95 | ||||
|   }, | ||||
|   view: { | ||||
|     alignContent: 'right', | ||||
|     variant: 'outlined' | ||||
|   }, | ||||
|   viewModes: { | ||||
|     position: 'relative', | ||||
|     alignItems: 'baseline', | ||||
|     maxWidth: '100%', | ||||
|     flexDirection: 'row', | ||||
|     justifyContent: 'right' | ||||
|   }, | ||||
|   searchIcon: { | ||||
|     color: '#52637A', | ||||
|     paddingRight: '3%' | ||||
| @@ -87,15 +119,27 @@ const useStyles = makeStyles((theme) => ({ | ||||
|       opacity: '1' | ||||
|     } | ||||
|   }, | ||||
|   export: { | ||||
|     alignContent: 'right' | ||||
|   }, | ||||
|   popper: { | ||||
|     width: '100%', | ||||
|     overflow: 'hidden', | ||||
|     padding: '0.3rem', | ||||
|     display: 'flex', | ||||
|     justifyContent: 'left' | ||||
|   }, | ||||
|   dropdownArrowBox: { | ||||
|     display: 'flex', | ||||
|     alignItems: 'center', | ||||
|     justifyContent: 'center' | ||||
|   }, | ||||
|   dropdownText: { | ||||
|     color: '#1479FF', | ||||
|     fontSize: '1.5rem', | ||||
|     fontWeight: '600', | ||||
|     cursor: 'pointer', | ||||
|     textAlign: 'center' | ||||
|   }, | ||||
|   test: { | ||||
|     width: '95%' | ||||
|   } | ||||
| })); | ||||
|  | ||||
| @@ -103,13 +147,18 @@ function VulnerabilitiesDetails(props) { | ||||
|   const classes = useStyles(); | ||||
|   const [cveData, setCveData] = useState([]); | ||||
|   const [allCveData, setAllCveData] = useState([]); | ||||
|   const [cveSummary, setCVESummary] = useState({}); | ||||
|   const [isLoading, setIsLoading] = useState(true); | ||||
|   const [isLoadingAllCve, setIsLoadingAllCve] = useState(true); | ||||
|   const abortController = useMemo(() => new AbortController(), []); | ||||
|   const { name, tag, digest, platform } = props; | ||||
|  | ||||
|   const [openExcludeSearch, setOpenExcludeSearch] = useState(false); | ||||
|  | ||||
|   // pagination props | ||||
|   const [cveFilter, setCveFilter] = useState(''); | ||||
|   const [cveExcludeFilter, setCveExcludeFilter] = useState(''); | ||||
|   const [cveSeverityFilter, setCveSeverityFilter] = useState(''); | ||||
|   const [pageNumber, setPageNumber] = useState(1); | ||||
|   const [isEndOfList, setIsEndOfList] = useState(false); | ||||
|   const listBottom = useRef(null); | ||||
| @@ -117,6 +166,8 @@ function VulnerabilitiesDetails(props) { | ||||
|   const [anchorExport, setAnchorExport] = useState(null); | ||||
|   const openExport = Boolean(anchorExport); | ||||
|  | ||||
|   const [selectedViewMore, setSelectedViewMore] = useState(false); | ||||
|  | ||||
|   const getCVERequestName = () => { | ||||
|     return digest !== '' ? `${name}@${digest}` : `${name}:${tag}`; | ||||
|   }; | ||||
| @@ -127,16 +178,32 @@ function VulnerabilitiesDetails(props) { | ||||
|         `${host()}${endpoints.vulnerabilitiesForRepo( | ||||
|           getCVERequestName(), | ||||
|           { pageNumber, pageSize: EXPLORE_PAGE_SIZE }, | ||||
|           cveFilter | ||||
|           cveFilter, | ||||
|           cveExcludeFilter, | ||||
|           cveSeverityFilter | ||||
|         )}`, | ||||
|         abortController.signal | ||||
|       ) | ||||
|       .then((response) => { | ||||
|         if (response.data && response.data.data) { | ||||
|           let cveInfo = response.data.data.CVEListForImage?.CVEList; | ||||
|           let summary = response.data.data.CVEListForImage?.Summary; | ||||
|           let cveListData = mapCVEInfo(cveInfo); | ||||
|           setCveData((previousState) => (pageNumber === 1 ? cveListData : [...previousState, ...cveListData])); | ||||
|           setIsEndOfList(response.data.data.CVEListForImage.Page?.ItemCount < EXPLORE_PAGE_SIZE); | ||||
|           setCVESummary((previousState) => { | ||||
|             if (isEmpty(summary)) { | ||||
|               return previousState; | ||||
|             } | ||||
|             return { | ||||
|               Count: summary.Count, | ||||
|               UnknownCount: summary.UnknownCount, | ||||
|               LowCount: summary.LowCount, | ||||
|               MediumCount: summary.MediumCount, | ||||
|               HighCount: summary.HighCount, | ||||
|               CriticalCount: summary.CriticalCount | ||||
|             }; | ||||
|           }); | ||||
|         } else if (response.data.errors) { | ||||
|           setIsEndOfList(true); | ||||
|         } | ||||
| @@ -146,6 +213,7 @@ function VulnerabilitiesDetails(props) { | ||||
|         console.error(e); | ||||
|         setIsLoading(false); | ||||
|         setCveData([]); | ||||
|         setCVESummary(() => {}); | ||||
|         setIsEndOfList(true); | ||||
|       }); | ||||
|   }; | ||||
| @@ -182,7 +250,7 @@ function VulnerabilitiesDetails(props) { | ||||
|     const wb = XLSX.utils.book_new(), | ||||
|       ws = XLSX.utils.json_to_sheet(allCveData); | ||||
|  | ||||
|     XLSX.utils.book_append_sheet(wb, ws, name + '_' + tag); | ||||
|     XLSX.utils.book_append_sheet(wb, ws, name.replaceAll('/', '_') + '_' + tag); | ||||
|  | ||||
|     XLSX.writeFile(wb, `${name}:${tag}-vulnerabilities.xlsx`); | ||||
|  | ||||
| @@ -211,7 +279,17 @@ function VulnerabilitiesDetails(props) { | ||||
|     setAnchorExport(null); | ||||
|   }; | ||||
|  | ||||
|   const handleExpandCVESearch = () => { | ||||
|     setOpenExcludeSearch((openExcludeSearch) => !openExcludeSearch); | ||||
|   }; | ||||
|  | ||||
|   const handleCveExcludeFilterChange = (e) => { | ||||
|     const { value } = e.target; | ||||
|     setCveExcludeFilter(value); | ||||
|   }; | ||||
|  | ||||
|   const debouncedChangeHandler = useMemo(() => debounce(handleCveFilterChange, 300)); | ||||
|   const debouncedExcludeFilterChangeHandler = useMemo(() => debounce(handleCveExcludeFilterChange, 300)); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     getPaginatedCVEs(); | ||||
| @@ -245,12 +323,13 @@ function VulnerabilitiesDetails(props) { | ||||
|   useEffect(() => { | ||||
|     if (isLoading) return; | ||||
|     resetPagination(); | ||||
|   }, [cveFilter]); | ||||
|   }, [cveFilter, cveExcludeFilter, cveSeverityFilter]); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     return () => { | ||||
|       abortController.abort(); | ||||
|       debouncedChangeHandler.cancel(); | ||||
|       debouncedExcludeFilterChangeHandler.cancel(); | ||||
|     }; | ||||
|   }, []); | ||||
|  | ||||
| @@ -263,13 +342,33 @@ function VulnerabilitiesDetails(props) { | ||||
|   const renderCVEs = () => { | ||||
|     return !isEmpty(cveData) ? ( | ||||
|       cveData.map((cve, index) => { | ||||
|         return <VulnerabilitiyCard key={index} cve={cve} name={name} platform={platform} />; | ||||
|         return <VulnerabilitiyCard key={index} cve={cve} name={name} platform={platform} expand={selectedViewMore} />; | ||||
|       }) | ||||
|     ) : ( | ||||
|       <div>{!isLoading && <Typography className={classes.none}> No Vulnerabilities </Typography>}</div> | ||||
|     ); | ||||
|   }; | ||||
|  | ||||
|   const renderCVESummary = () => { | ||||
|     if (cveSummary === undefined) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     return !isEmpty(cveSummary) ? ( | ||||
|       <VulnerabilityCountCard | ||||
|         total={cveSummary.Count} | ||||
|         critical={cveSummary.CriticalCount} | ||||
|         high={cveSummary.HighCount} | ||||
|         medium={cveSummary.MediumCount} | ||||
|         low={cveSummary.LowCount} | ||||
|         unknown={cveSummary.UnknownCount} | ||||
|         filterBySeverity={setCveSeverityFilter} | ||||
|       /> | ||||
|     ) : ( | ||||
|       <></> | ||||
|     ); | ||||
|   }; | ||||
|  | ||||
|   const renderListBottom = () => { | ||||
|     if (isLoading) { | ||||
|       return <Loading />; | ||||
| @@ -286,14 +385,36 @@ function VulnerabilitiesDetails(props) { | ||||
|         <Typography variant="h4" gutterBottom component="div" align="left" className={classes.title}> | ||||
|           Vulnerabilities | ||||
|         </Typography> | ||||
|         <IconButton disableRipple onClick={handleClickExport} className={classes.export}> | ||||
|           <DownloadIcon /> | ||||
|         </IconButton> | ||||
|         <Snackbar | ||||
|           open={openExport && isLoadingAllCve} | ||||
|           message="Getting your data ready for export" | ||||
|           action={<CircularProgress size="2rem" sx={{ color: '#FFFFFF' }} />} | ||||
|         /> | ||||
|         <Stack direction="row" spacing="1rem" className={classes.viewModes}> | ||||
|           <IconButton disableRipple onClick={handleClickExport}> | ||||
|             <DownloadIcon /> | ||||
|           </IconButton> | ||||
|           <Snackbar | ||||
|             open={openExport && isLoadingAllCve} | ||||
|             message="Getting your data ready for export" | ||||
|             action={<CircularProgress size="2rem" sx={{ color: '#FFFFFF' }} />} | ||||
|           /> | ||||
|           <ToggleButton | ||||
|             value="viewLess" | ||||
|             title="Collapse list view" | ||||
|             size="small" | ||||
|             className={classes.view} | ||||
|             selected={!selectedViewMore} | ||||
|             onChange={() => setSelectedViewMore(false)} | ||||
|           > | ||||
|             <ViewHeadlineIcon /> | ||||
|           </ToggleButton> | ||||
|           <ToggleButton | ||||
|             value="viewMore" | ||||
|             title="Expand list view" | ||||
|             size="small" | ||||
|             className={classes.view} | ||||
|             selected={selectedViewMore} | ||||
|             onChange={() => setSelectedViewMore(true)} | ||||
|           > | ||||
|             <ViewAgendaIcon /> | ||||
|           </ToggleButton> | ||||
|         </Stack> | ||||
|         <Menu | ||||
|           anchorEl={anchorExport} | ||||
|           open={openExport} | ||||
| @@ -315,7 +436,7 @@ function VulnerabilitiesDetails(props) { | ||||
|             className={classes.popper} | ||||
|             data-testid="export-csv-menuItem" | ||||
|           > | ||||
|             CSV | ||||
|             csv | ||||
|           </MenuItem> | ||||
|           <Divider sx={{ my: 0.5 }} /> | ||||
|           <MenuItem | ||||
| @@ -325,22 +446,46 @@ function VulnerabilitiesDetails(props) { | ||||
|             className={classes.popper} | ||||
|             data-testid="export-excel-menuItem" | ||||
|           > | ||||
|             MS Excel | ||||
|             xlsx | ||||
|           </MenuItem> | ||||
|         </Menu> | ||||
|       </Stack> | ||||
|       <Stack className={classes.search}> | ||||
|         <InputBase | ||||
|           placeholder={'Search'} | ||||
|           classes={{ root: classes.searchInputBase, input: classes.input }} | ||||
|           onChange={debouncedChangeHandler} | ||||
|         /> | ||||
|         <div className={classes.searchIcon}> | ||||
|           <SearchIcon /> | ||||
|       {renderCVESummary()} | ||||
|       <Stack direction="row"> | ||||
|         <div className={classes.dropdownArrowBox} onClick={handleExpandCVESearch}> | ||||
|           {!openExcludeSearch ? ( | ||||
|             <KeyboardArrowRight className={classes.dropdownText} /> | ||||
|           ) : ( | ||||
|             <KeyboardArrowDown className={classes.dropdownText} /> | ||||
|           )} | ||||
|         </div> | ||||
|         <Stack className={classes.test} direction="column" spacing="0.25em"> | ||||
|           <Stack className={classes.search}> | ||||
|             <InputBase | ||||
|               placeholder={'Search'} | ||||
|               classes={{ root: classes.searchInputBase, input: classes.input }} | ||||
|               onChange={debouncedChangeHandler} | ||||
|             /> | ||||
|             <div className={classes.searchIcon}> | ||||
|               <SearchIcon /> | ||||
|             </div> | ||||
|           </Stack> | ||||
|  | ||||
|           <Collapse in={openExcludeSearch} timeout="auto" unmountOnExit> | ||||
|             <Stack className={classes.search}> | ||||
|               <InputBase | ||||
|                 placeholder={'Exclude'} | ||||
|                 classes={{ root: classes.searchInputBase, input: classes.input }} | ||||
|                 onChange={debouncedExcludeFilterChangeHandler} | ||||
|               /> | ||||
|             </Stack> | ||||
|           </Collapse> | ||||
|         </Stack> | ||||
|       </Stack> | ||||
|       <Stack direction="column" spacing={selectedViewMore ? '1rem' : '0.5rem'}> | ||||
|         {renderCVEs()} | ||||
|         {renderListBottom()} | ||||
|       </Stack> | ||||
|       {renderCVEs()} | ||||
|       {renderListBottom()} | ||||
|     </Stack> | ||||
|   ); | ||||
| } | ||||
|   | ||||
| @@ -3,7 +3,10 @@ import React, { useEffect, useMemo, useState, useRef } from 'react'; | ||||
|  | ||||
| // utility | ||||
| import { api, endpoints } from '../../api'; | ||||
| import { host } from '../../host'; | ||||
| import { mapToImage } from '../../utilities/objectModels'; | ||||
| import { isEmpty, head } from 'lodash'; | ||||
|  | ||||
| // components | ||||
| import { | ||||
|   Card, | ||||
| @@ -19,23 +22,21 @@ import { | ||||
|   Typography, | ||||
|   InputLabel | ||||
| } from '@mui/material'; | ||||
| import TagDetailsMetadata from './TagDetailsMetadata'; | ||||
| import VulnerabilitiesDetails from './Tabs/VulnerabilitiesDetails'; | ||||
| import HistoryLayers from './Tabs/HistoryLayers'; | ||||
| import DependsOn from './Tabs/DependsOn'; | ||||
| import IsDependentOn from './Tabs/IsDependentOn'; | ||||
| import Loading from '../Shared/Loading'; | ||||
| import { VulnerabilityIconCheck, SignatureIconCheck } from 'utilities/vulnerabilityAndSignatureCheck'; | ||||
| import ReferredBy from './Tabs/ReferredBy'; | ||||
| import makeStyles from '@mui/styles/makeStyles'; | ||||
| import { host } from '../../host'; | ||||
|  | ||||
| // placeholder images | ||||
| import repocube1 from '../../assets/repocube-1.png'; | ||||
| import repocube2 from '../../assets/repocube-2.png'; | ||||
| import repocube3 from '../../assets/repocube-3.png'; | ||||
| import repocube4 from '../../assets/repocube-4.png'; | ||||
| import TagDetailsMetadata from './TagDetailsMetadata'; | ||||
| import VulnerabilitiesDetails from './Tabs/VulnerabilitiesDetails'; | ||||
| import HistoryLayers from './Tabs/HistoryLayers'; | ||||
| import DependsOn from './Tabs/DependsOn'; | ||||
| import IsDependentOn from './Tabs/IsDependentOn'; | ||||
| import { isEmpty, head } from 'lodash'; | ||||
| import Loading from '../Shared/Loading'; | ||||
| import { VulnerabilityIconCheck, SignatureIconCheck } from 'utilities/vulnerabilityAndSignatureCheck'; | ||||
| import ReferredBy from './Tabs/ReferredBy'; | ||||
|  | ||||
| const useStyles = makeStyles((theme) => ({ | ||||
|   pageWrapper: { | ||||
|   | ||||
| @@ -1,12 +1,14 @@ | ||||
| import React from 'react'; | ||||
|  | ||||
| import { Card, CardContent, Grid, Typography, Tooltip } from '@mui/material'; | ||||
| import makeStyles from '@mui/styles/makeStyles'; | ||||
| import transform from '../../utilities/transform'; | ||||
| import { DateTime } from 'luxon'; | ||||
| import { Markdown } from 'utilities/MarkdowntojsxWrapper'; | ||||
| import transform from '../../utilities/transform'; | ||||
|  | ||||
| import { Card, CardContent, Grid, Typography, Tooltip } from '@mui/material'; | ||||
| import PullCommandButton from 'components/Shared/PullCommandButton'; | ||||
|  | ||||
| import makeStyles from '@mui/styles/makeStyles'; | ||||
|  | ||||
| const useStyles = makeStyles((theme) => ({ | ||||
|   card: { | ||||
|     display: 'flex', | ||||
|   | ||||
							
								
								
									
										163
									
								
								src/components/User/ApiKeys/ApiKeyCard.jsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										163
									
								
								src/components/User/ApiKeys/ApiKeyCard.jsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,163 @@ | ||||
| import React, { useState } from 'react'; | ||||
|  | ||||
| import { DateTime } from 'luxon'; | ||||
| import { isNil } from 'lodash'; | ||||
|  | ||||
| import { Card, CardContent, Typography, Grid, Divider, Stack, Collapse, Button } from '@mui/material'; | ||||
| import { KeyboardArrowDown, KeyboardArrowRight } from '@mui/icons-material'; | ||||
| import ApiKeyRevokeDialog from './ApiKeyRevokeDialog'; | ||||
|  | ||||
| import makeStyles from '@mui/styles/makeStyles'; | ||||
|  | ||||
| const useStyles = makeStyles(() => ({ | ||||
|   card: { | ||||
|     marginBottom: 2, | ||||
|     display: 'flex', | ||||
|     flexDirection: 'row', | ||||
|     alignItems: 'center', | ||||
|     backgroundColor: '#FFFFFF', | ||||
|     border: '1px solid #E0E5EB', | ||||
|     borderRadius: '0.75rem', | ||||
|     alignSelf: 'stretch', | ||||
|     flexGrow: 0, | ||||
|     order: 0, | ||||
|     width: '100%' | ||||
|   }, | ||||
|   content: { | ||||
|     textAlign: 'left', | ||||
|     color: '#52637A', | ||||
|     width: '100%', | ||||
|     boxSizing: 'border-box', | ||||
|     padding: '1rem', | ||||
|     backgroundColor: '#FFFFFF', | ||||
|     '&:hover': { | ||||
|       backgroundColor: '#FFFFFF' | ||||
|     }, | ||||
|     '&:last-child': { | ||||
|       paddingBottom: '1rem' | ||||
|     } | ||||
|   }, | ||||
|   label: { | ||||
|     fontSize: '1rem', | ||||
|     fontWeight: '400', | ||||
|     paddingRight: '0.5rem', | ||||
|     paddingBottom: '0.5rem', | ||||
|     paddingTop: '0.5rem', | ||||
|     textAlign: 'left', | ||||
|     width: '100%', | ||||
|     whiteSpace: 'nowrap', | ||||
|     overflow: 'hidden', | ||||
|     textOverflow: 'ellipsis', | ||||
|     cursor: 'pointer' | ||||
|   }, | ||||
|   expirationDate: { | ||||
|     fontSize: '1rem', | ||||
|     fontWeight: '400', | ||||
|     paddingBottom: '0.5rem', | ||||
|     paddingTop: '0.5rem', | ||||
|     textAlign: 'right' | ||||
|   }, | ||||
|   revokeButton: { | ||||
|     display: 'flex', | ||||
|     alignItems: 'center', | ||||
|     justifyContent: 'right' | ||||
|   }, | ||||
|   dropdownText: { | ||||
|     color: '#1479FF', | ||||
|     fontSize: '1rem', | ||||
|     fontWeight: '600', | ||||
|     cursor: 'pointer', | ||||
|     textAlign: 'center' | ||||
|   }, | ||||
|   dropdownButton: { | ||||
|     color: '#1479FF', | ||||
|     fontSize: '0.8125rem', | ||||
|     fontWeight: '600', | ||||
|     cursor: 'pointer' | ||||
|   }, | ||||
|   dropdownContentBox: { | ||||
|     boxSizing: 'border-box', | ||||
|     color: '#52637A', | ||||
|     fontSize: '1rem', | ||||
|     fontWeight: '400', | ||||
|     padding: '0.75rem', | ||||
|     backgroundColor: '#F7F7F7', | ||||
|     borderRadius: '0.9rem', | ||||
|     overflowWrap: 'break-word' | ||||
|   }, | ||||
|   keyCardDivider: { | ||||
|     margin: '1rem 0' | ||||
|   } | ||||
| })); | ||||
|  | ||||
| function ApiKeyCard(props) { | ||||
|   const classes = useStyles(); | ||||
|   const { apiKey, onRevoke } = props; | ||||
|   const [openDropdown, setOpenDropdown] = useState(false); | ||||
|   const [apiKeyRevokeOpen, setApiKeyRevokeOpen] = useState(false); | ||||
|  | ||||
|   const getExpirationDisplay = () => { | ||||
|     const expDateTime = DateTime.fromISO(apiKey.expirationDate); | ||||
|     return `Expires on ${expDateTime.toLocaleString(DateTime.DATE_MED_WITH_WEEKDAY)}`; | ||||
|   }; | ||||
|  | ||||
|   const handleApiKeyRevokeDialogOpen = () => { | ||||
|     setApiKeyRevokeOpen(true); | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <Card variant="outlined" className={classes.card}> | ||||
|       <CardContent className={classes.content}> | ||||
|         <Grid container alignItems="center" justifyContent="space-between"> | ||||
|           <Grid item xs={6}> | ||||
|             <Typography variant="body1" className={classes.label}> | ||||
|               {apiKey.label} | ||||
|             </Typography> | ||||
|           </Grid> | ||||
|           <Grid item xs={4}> | ||||
|             <Typography variant="body1" className={classes.expirationDate}> | ||||
|               {getExpirationDisplay()} | ||||
|             </Typography> | ||||
|           </Grid> | ||||
|           <Grid item xs={2} className={classes.revokeButton}> | ||||
|             <Button color="error" variant="contained" onClick={handleApiKeyRevokeDialogOpen}> | ||||
|               Revoke | ||||
|             </Button> | ||||
|           </Grid> | ||||
|           {!isNil(apiKey.apiKey) && ( | ||||
|             <> | ||||
|               <Grid item xs={12}> | ||||
|                 <Divider className={classes.keyCardDivider} /> | ||||
|               </Grid> | ||||
|               <Grid item xs={12}> | ||||
|                 <Stack direction="row" onClick={() => setOpenDropdown((prevOpenState) => !prevOpenState)}> | ||||
|                   {!openDropdown ? ( | ||||
|                     <KeyboardArrowRight className={classes.dropdownText} /> | ||||
|                   ) : ( | ||||
|                     <KeyboardArrowDown className={classes.dropdownText} /> | ||||
|                   )} | ||||
|                   <Typography className={classes.dropdownButton}>KEY</Typography> | ||||
|                 </Stack> | ||||
|                 <Collapse in={openDropdown} timeout="auto" unmountOnExit sx={{ marginTop: '1rem' }}> | ||||
|                   <Stack direction="column" spacing="1.2rem"> | ||||
|                     <Typography variant="body1" align="left" className={classes.dropdownContentBox}> | ||||
|                       {apiKey.apiKey} | ||||
|                     </Typography> | ||||
|                   </Stack> | ||||
|                 </Collapse> | ||||
|               </Grid> | ||||
|             </> | ||||
|           )} | ||||
|         </Grid> | ||||
|         <ApiKeyRevokeDialog | ||||
|           open={apiKeyRevokeOpen} | ||||
|           setOpen={setApiKeyRevokeOpen} | ||||
|           apiKey={apiKey} | ||||
|           onConfirm={onRevoke} | ||||
|         /> | ||||
|       </CardContent> | ||||
|     </Card> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| export default ApiKeyCard; | ||||
							
								
								
									
										57
									
								
								src/components/User/ApiKeys/ApiKeyConfirmDialog.jsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										57
									
								
								src/components/User/ApiKeys/ApiKeyConfirmDialog.jsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,57 @@ | ||||
| import React from 'react'; | ||||
|  | ||||
| import { Dialog, DialogContent, DialogTitle, DialogActions, Button, Typography, Grid } from '@mui/material'; | ||||
|  | ||||
| import { makeStyles } from '@mui/styles'; | ||||
|  | ||||
| const useStyles = makeStyles(() => ({ | ||||
|   gridWrapper: { | ||||
|     paddingTop: '2rem', | ||||
|     paddingBottom: '2rem' | ||||
|   }, | ||||
|   apiKeyDisplay: { | ||||
|     boxSizing: 'border-box', | ||||
|     color: '#52637A', | ||||
|     fontSize: '1rem', | ||||
|     fontWeight: '400', | ||||
|     padding: '0.75rem', | ||||
|     backgroundColor: '#F7F7F7', | ||||
|     borderRadius: '0.9rem', | ||||
|     overflowWrap: 'break-word' | ||||
|   } | ||||
| })); | ||||
|  | ||||
| function ApiKeyConfirmDialog(props) { | ||||
|   const { open, setOpen, apiKey } = props; | ||||
|  | ||||
|   const classes = useStyles(); | ||||
|  | ||||
|   const handleClose = () => { | ||||
|     setOpen(false); | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <Dialog open={open} onClose={handleClose}> | ||||
|       <DialogTitle>Api Key "{apiKey?.label}" Created</DialogTitle> | ||||
|       <DialogContent className={classes.apiKeyForm}> | ||||
|         <Grid container className={classes.gridWrapper}> | ||||
|           <Grid item xs={12}> | ||||
|             <Typography>Please copy the api key, you will not be able to see it once the page is refreshed</Typography> | ||||
|           </Grid> | ||||
|           <Grid item xs={12}> | ||||
|             <Typography variant="body1" align="center" className={classes.apiKeyDisplay}> | ||||
|               {apiKey?.apiKey} | ||||
|             </Typography> | ||||
|           </Grid> | ||||
|         </Grid> | ||||
|       </DialogContent> | ||||
|       <DialogActions> | ||||
|         <Button variant="outlined" onClick={handleClose}> | ||||
|           Close | ||||
|         </Button> | ||||
|       </DialogActions> | ||||
|     </Dialog> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| export default ApiKeyConfirmDialog; | ||||
							
								
								
									
										172
									
								
								src/components/User/ApiKeys/ApiKeyDialog.jsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										172
									
								
								src/components/User/ApiKeys/ApiKeyDialog.jsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,172 @@ | ||||
| import React, { useState } from 'react'; | ||||
|  | ||||
| import { isNil, isNumber } from 'lodash'; | ||||
| import { DateTime } from 'luxon'; | ||||
| import { api, endpoints } from 'api'; | ||||
| import { host } from 'host'; | ||||
|  | ||||
| import { | ||||
|   Dialog, | ||||
|   DialogContent, | ||||
|   TextField, | ||||
|   DialogTitle, | ||||
|   DialogActions, | ||||
|   Button, | ||||
|   FormControl, | ||||
|   InputLabel, | ||||
|   Select, | ||||
|   MenuItem, | ||||
|   Typography, | ||||
|   Grid | ||||
| } from '@mui/material'; | ||||
| import { DatePicker } from '@mui/x-date-pickers'; | ||||
|  | ||||
| import { makeStyles } from '@mui/styles'; | ||||
|  | ||||
| const useStyles = makeStyles(() => ({ | ||||
|   gridWrapper: { | ||||
|     paddingTop: '2rem', | ||||
|     paddingBottom: '2rem' | ||||
|   }, | ||||
|   apiKeyLabel: { | ||||
|     paddingBottom: '1rem' | ||||
|   }, | ||||
|   expirationDateContainer: { | ||||
|     width: '100%' | ||||
|   }, | ||||
|   expirationDateInput: { | ||||
|     width: '100%' | ||||
|   }, | ||||
|   expirationDateDisplay: { | ||||
|     display: 'flex', | ||||
|     alignItems: 'center', | ||||
|     justifyContent: 'center' | ||||
|   } | ||||
| })); | ||||
|  | ||||
| function ApiKeyDialog(props) { | ||||
|   const { open, setOpen, onConfirm } = props; | ||||
|  | ||||
|   const [apiKeyLabel, setApiKeyLabel] = useState(); | ||||
|   const [expirationDateOffset, setExpirationDateOffset] = useState(30); | ||||
|   const [selectedExpirationDate, setSelectedExpirationDate] = useState(); | ||||
|  | ||||
|   const classes = useStyles(); | ||||
|  | ||||
|   const handleClose = () => { | ||||
|     setOpen(false); | ||||
|   }; | ||||
|  | ||||
|   const handleSubmit = () => { | ||||
|     api | ||||
|       .post(`${host()}${endpoints.apiKeys}`, { | ||||
|         label: apiKeyLabel, | ||||
|         expirationDate: getExpirationDatetime().toISO() | ||||
|       }) | ||||
|       .then((response) => { | ||||
|         if (response.data) { | ||||
|           onConfirm(response.data); | ||||
|           setOpen(false); | ||||
|         } | ||||
|       }) | ||||
|       .catch((error) => { | ||||
|         console.error(error); | ||||
|       }); | ||||
|   }; | ||||
|  | ||||
|   const handleLabelChange = (e) => { | ||||
|     const { value } = e.target; | ||||
|     setApiKeyLabel(value); | ||||
|   }; | ||||
|  | ||||
|   const handleExpirationDateChange = (e) => { | ||||
|     const { value } = e.target; | ||||
|     setExpirationDateOffset(value); | ||||
|   }; | ||||
|  | ||||
|   const handleDatePickerChange = (newValue) => { | ||||
|     setSelectedExpirationDate(newValue); | ||||
|   }; | ||||
|  | ||||
|   const getExpirationDatetime = () => { | ||||
|     if (isNumber(expirationDateOffset)) { | ||||
|       return DateTime.now().plus({ days: expirationDateOffset }).endOf('day'); | ||||
|     } else if (expirationDateOffset === 'custom') { | ||||
|       return DateTime.fromISO(selectedExpirationDate); | ||||
|     } | ||||
|     return null; | ||||
|   }; | ||||
|  | ||||
|   const getExpirationDisplay = () => { | ||||
|     const expDateTime = getExpirationDatetime(); | ||||
|     return `Expires on ${expDateTime.toLocaleString(DateTime.DATE_MED_WITH_WEEKDAY)}`; | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <Dialog open={open} onClose={handleClose}> | ||||
|       <DialogTitle>Create Api Key</DialogTitle> | ||||
|       <DialogContent className={classes.apiKeyForm}> | ||||
|         <Grid container className={classes.gridWrapper}> | ||||
|           <Grid item container className={classes.apiKeyLabel} xs={12}> | ||||
|             <TextField | ||||
|               autoFocus | ||||
|               required | ||||
|               id="apikeylabel" | ||||
|               label="Label" | ||||
|               fullWidth | ||||
|               variant="outlined" | ||||
|               onChange={handleLabelChange} | ||||
|             /> | ||||
|           </Grid> | ||||
|           <Grid container item xs={12}> | ||||
|             <Grid item xs={5}> | ||||
|               <FormControl className={classes.expirationDateContainer} size="small" required> | ||||
|                 <InputLabel disableAnimation>Expiration date</InputLabel> | ||||
|                 <Select | ||||
|                   labelId="expirationDate" | ||||
|                   id="expirationDate" | ||||
|                   label="Expiration time" | ||||
|                   onChange={handleExpirationDateChange} | ||||
|                   value={expirationDateOffset} | ||||
|                   className={classes.expirationDateInput} | ||||
|                 > | ||||
|                   <MenuItem value={7}>7 days</MenuItem> | ||||
|                   <MenuItem value={30}>30 days</MenuItem> | ||||
|                   <MenuItem value={60}>60 days</MenuItem> | ||||
|                   <MenuItem value={90}>90 days</MenuItem> | ||||
|                   <MenuItem value="custom">custom</MenuItem> | ||||
|                 </Select> | ||||
|               </FormControl> | ||||
|             </Grid> | ||||
|             <Grid item className={classes.expirationDateDisplay} xs={7}> | ||||
|               {expirationDateOffset === 'custom' ? ( | ||||
|                 <DatePicker | ||||
|                   valueType="date" | ||||
|                   slotProps={{ textField: { size: 'small' } }} | ||||
|                   onChange={handleDatePickerChange} | ||||
|                 /> | ||||
|               ) : ( | ||||
|                 <Typography>{getExpirationDisplay()}</Typography> | ||||
|               )} | ||||
|             </Grid> | ||||
|           </Grid> | ||||
|         </Grid> | ||||
|       </DialogContent> | ||||
|       <DialogActions> | ||||
|         <Button | ||||
|           variant="contained" | ||||
|           color="success" | ||||
|           onClick={handleSubmit} | ||||
|           disabled={expirationDateOffset === 'custom' && isNil(selectedExpirationDate)} | ||||
|         > | ||||
|           Create | ||||
|         </Button> | ||||
|         <Button variant="outlined" onClick={handleClose}> | ||||
|           Cancel | ||||
|         </Button> | ||||
|       </DialogActions> | ||||
|     </Dialog> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| export default ApiKeyDialog; | ||||
							
								
								
									
										71
									
								
								src/components/User/ApiKeys/ApiKeyRevokeDialog.jsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										71
									
								
								src/components/User/ApiKeys/ApiKeyRevokeDialog.jsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,71 @@ | ||||
| import React from 'react'; | ||||
|  | ||||
| import { api, endpoints } from 'api'; | ||||
| import { host } from 'host'; | ||||
|  | ||||
| import { Dialog, DialogContent, DialogTitle, DialogActions, Button, Typography, Grid } from '@mui/material'; | ||||
|  | ||||
| import { makeStyles } from '@mui/styles'; | ||||
|  | ||||
| const useStyles = makeStyles(() => ({ | ||||
|   gridWrapper: { | ||||
|     paddingTop: '2rem', | ||||
|     paddingBottom: '2rem' | ||||
|   }, | ||||
|   apiKeyDisplay: { | ||||
|     boxSizing: 'border-box', | ||||
|     color: '#52637A', | ||||
|     fontSize: '1rem', | ||||
|     fontWeight: '400', | ||||
|     padding: '0.75rem', | ||||
|     backgroundColor: '#F7F7F7', | ||||
|     borderRadius: '0.9rem', | ||||
|     overflowWrap: 'break-word' | ||||
|   } | ||||
| })); | ||||
|  | ||||
| function ApiKeyRevokeDialog(props) { | ||||
|   const { open, setOpen, apiKey, onConfirm } = props; | ||||
|  | ||||
|   const classes = useStyles(); | ||||
|  | ||||
|   const handleClose = () => { | ||||
|     setOpen(false); | ||||
|   }; | ||||
|  | ||||
|   const handleSubmit = () => { | ||||
|     api | ||||
|       .delete(`${host()}${endpoints.apiKeys}`, { id: apiKey.uuid }) | ||||
|       .then((response) => { | ||||
|         onConfirm(response?.status, apiKey); | ||||
|         setOpen(false); | ||||
|       }) | ||||
|       .catch((error) => { | ||||
|         console.error(error); | ||||
|       }); | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <Dialog open={open} onClose={handleClose}> | ||||
|       <DialogTitle>Revoke "{apiKey?.label}" key</DialogTitle> | ||||
|       <DialogContent className={classes.apiKeyForm}> | ||||
|         <Grid container className={classes.gridWrapper}> | ||||
|           <Grid item xs={12}> | ||||
|             <Typography>Are you sure you want to revoke this api key?</Typography> | ||||
|           </Grid> | ||||
|         </Grid> | ||||
|       </DialogContent> | ||||
|  | ||||
|       <DialogActions> | ||||
|         <Button variant="contained" color="error" onClick={handleSubmit}> | ||||
|           Revoke | ||||
|         </Button> | ||||
|         <Button variant="outlined" onClick={handleClose}> | ||||
|           Close | ||||
|         </Button> | ||||
|       </DialogActions> | ||||
|     </Dialog> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| export default ApiKeyRevokeDialog; | ||||
							
								
								
									
										146
									
								
								src/components/User/ApiKeys/ApiKeys.jsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										146
									
								
								src/components/User/ApiKeys/ApiKeys.jsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,146 @@ | ||||
| import React, { useEffect, useMemo, useState } from 'react'; | ||||
|  | ||||
| import { isEmpty, isNil } from 'lodash'; | ||||
| import { api, endpoints } from 'api'; | ||||
| import { host } from '../../../host'; | ||||
|  | ||||
| import { Grid, Stack, Card, CardContent, Typography, Button } from '@mui/material'; | ||||
| import Loading from '../../Shared/Loading'; | ||||
| import ApiKeyDialog from './ApiKeyDialog'; | ||||
| import ApiKeyConfirmDialog from './ApiKeyConfirmDialog'; | ||||
| import ApiKeyCard from './ApiKeyCard'; | ||||
|  | ||||
| import { makeStyles } from '@mui/styles'; | ||||
|  | ||||
| const useStyles = makeStyles((theme) => ({ | ||||
|   pageWrapper: { | ||||
|     backgroundColor: 'transparent', | ||||
|     height: '100%' | ||||
|   }, | ||||
|   header: { | ||||
|     [theme.breakpoints.down('md')]: { | ||||
|       padding: '0' | ||||
|     } | ||||
|   }, | ||||
|   cardRoot: { | ||||
|     boxShadow: 'none!important' | ||||
|   }, | ||||
|   pageTitle: { | ||||
|     fontWeight: '600', | ||||
|     fontSize: '1.5rem', | ||||
|     color: theme.palette.secondary.main, | ||||
|     textAlign: 'left' | ||||
|   }, | ||||
|   apikeysContainer: { | ||||
|     marginTop: '1.5rem', | ||||
|     height: '100%', | ||||
|     [theme.breakpoints.down('md')]: { | ||||
|       padding: '0' | ||||
|     } | ||||
|   }, | ||||
|   apikeysContent: { | ||||
|     padding: '1.5rem' | ||||
|   } | ||||
| })); | ||||
|  | ||||
| function ApiKeys() { | ||||
|   const abortController = useMemo(() => new AbortController(), []); | ||||
|   const [isLoading, setIsLoading] = useState(true); | ||||
|   const [apiKeys, setApiKeys] = useState([]); | ||||
|   const [newApiKey, setNewApiKey] = useState(); | ||||
|   const classes = useStyles(); | ||||
|  | ||||
|   // ApiKey dialog props | ||||
|   const [apiKeyDialogOpen, setApiKeyDialogOpen] = useState(false); | ||||
|   const [apiKeyConfirmationOpen, setApiKeyConfirmationOpen] = useState(false); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     setIsLoading(true); | ||||
|     api | ||||
|       .get(`${host()}${endpoints.apiKeys}`) | ||||
|       .then((response) => { | ||||
|         if (response.data && response.data.apiKeys) { | ||||
|           setApiKeys(response.data.apiKeys); | ||||
|         } | ||||
|         setIsLoading(false); | ||||
|       }) | ||||
|       .catch((e) => { | ||||
|         console.error(e); | ||||
|         setIsLoading(false); | ||||
|       }); | ||||
|     return () => { | ||||
|       abortController.abort(); | ||||
|     }; | ||||
|   }, []); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     if (!isNil(newApiKey) && !apiKeyConfirmationOpen) { | ||||
|       setApiKeyConfirmationOpen(true); | ||||
|     } | ||||
|   }, [newApiKey]); | ||||
|  | ||||
|   const handleApiKeyDialogOpen = () => { | ||||
|     setApiKeyDialogOpen(true); | ||||
|   }; | ||||
|  | ||||
|   const handleApiKeyCreateConfirm = (apiKey) => { | ||||
|     setNewApiKey(apiKey); | ||||
|     setApiKeys((prevState) => [...prevState, apiKey]); | ||||
|   }; | ||||
|  | ||||
|   const handleApiKeyRevokeConfirm = (status, apiKey) => { | ||||
|     if (status === 200) setApiKeys((prevState) => prevState.filter((ak) => ak.uuid != apiKey.uuid)); | ||||
|   }; | ||||
|  | ||||
|   const renderApiKeys = () => { | ||||
|     return apiKeys.map((apiKey) => ( | ||||
|       <ApiKeyCard key={apiKey.uuid} apiKey={apiKey} onRevoke={handleApiKeyRevokeConfirm} /> | ||||
|     )); | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       {isLoading ? ( | ||||
|         <Loading /> | ||||
|       ) : ( | ||||
|         <Grid container className={classes.pageWrapper}> | ||||
|           <Grid item xs={12} md={12}> | ||||
|             <Card className={classes.cardRoot}> | ||||
|               <CardContent> | ||||
|                 <Grid container className={classes.header}> | ||||
|                   <Grid item xs={12}> | ||||
|                     <Stack direction="row" justifyContent="space-between"> | ||||
|                       <Typography variant="h4" className={classes.pageTitle}> | ||||
|                         Manage your API Keys | ||||
|                       </Typography> | ||||
|                       <Button variant="contained" color="success" onClick={handleApiKeyDialogOpen}> | ||||
|                         Create new API key | ||||
|                       </Button> | ||||
|                     </Stack> | ||||
|                   </Grid> | ||||
|                 </Grid> | ||||
|               </CardContent> | ||||
|             </Card> | ||||
|           </Grid> | ||||
|           {!isLoading && !isEmpty(apiKeys) && ( | ||||
|             <Grid item xs={12} className={classes.apikeysContainer}> | ||||
|               <Card className={classes.cardRoot}> | ||||
|                 <CardContent className={classes.apikeysContent}> | ||||
|                   <Stack direction="column" spacing={1}> | ||||
|                     {renderApiKeys()} | ||||
|                   </Stack> | ||||
|                 </CardContent> | ||||
|               </Card> | ||||
|             </Grid> | ||||
|           )} | ||||
|           <ApiKeyDialog open={apiKeyDialogOpen} setOpen={setApiKeyDialogOpen} onConfirm={handleApiKeyCreateConfirm} /> | ||||
|           {!isNil(newApiKey) && ( | ||||
|             <ApiKeyConfirmDialog open={apiKeyConfirmationOpen} setOpen={setApiKeyConfirmationOpen} apiKey={newApiKey} /> | ||||
|           )} | ||||
|         </Grid> | ||||
|       )} | ||||
|     </> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| export default ApiKeys; | ||||
| @@ -5,6 +5,8 @@ import App from './App'; | ||||
| import reportWebVitals from './reportWebVitals'; | ||||
|  | ||||
| import { createTheme, ThemeProvider, StyledEngineProvider, adaptV4Theme } from '@mui/material/styles'; | ||||
| import { LocalizationProvider } from '@mui/x-date-pickers'; | ||||
| import { AdapterLuxon } from '@mui/x-date-pickers/AdapterLuxon'; | ||||
|  | ||||
| const theme = createTheme( | ||||
|   adaptV4Theme({ | ||||
| @@ -36,7 +38,9 @@ ReactDOM.render( | ||||
|   <React.StrictMode> | ||||
|     <StyledEngineProvider injectFirst> | ||||
|       <ThemeProvider theme={theme}> | ||||
|         <App /> | ||||
|         <LocalizationProvider dateAdapter={AdapterLuxon}> | ||||
|           <App /> | ||||
|         </LocalizationProvider> | ||||
|       </ThemeProvider> | ||||
|     </StyledEngineProvider> | ||||
|   </React.StrictMode>, | ||||
|   | ||||
| @@ -14,7 +14,6 @@ const useStyles = makeStyles(() => ({ | ||||
|     minWidth: '60%' | ||||
|   }, | ||||
|   gridWrapper: { | ||||
|     // backgroundColor: "#fff", | ||||
|     border: '0.0625em #f2f2f2 dashed' | ||||
|   }, | ||||
|   pageWrapper: { | ||||
|   | ||||
							
								
								
									
										57
									
								
								src/pages/UserManagementPage.jsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										57
									
								
								src/pages/UserManagementPage.jsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,57 @@ | ||||
| import React, { useEffect } from 'react'; | ||||
| import { useNavigate } from 'react-router-dom'; | ||||
| import { isEmpty } from 'lodash'; | ||||
|  | ||||
| import { getLoggedInUser } from 'utilities/authUtilities.js'; | ||||
|  | ||||
| import { Container, Grid, Stack } from '@mui/material'; | ||||
|  | ||||
| import Header from '../components/Header/Header.jsx'; | ||||
| import ApiKeys from '../components/User/ApiKeys/ApiKeys.jsx'; | ||||
|  | ||||
| import makeStyles from '@mui/styles/makeStyles'; | ||||
|  | ||||
| const useStyles = makeStyles(() => ({ | ||||
|   container: { | ||||
|     paddingTop: 30, | ||||
|     paddingBottom: 5, | ||||
|     height: '100%', | ||||
|     minWidth: '60%' | ||||
|   }, | ||||
|   gridWrapper: { | ||||
|     border: '0.0625rem #f2f2f2 dashed' | ||||
|   }, | ||||
|   pageWrapper: { | ||||
|     height: '100%' | ||||
|   }, | ||||
|   tile: { | ||||
|     width: '100%', | ||||
|     padding: 5 | ||||
|   } | ||||
| })); | ||||
|  | ||||
| function UserManagementPage() { | ||||
|   const classes = useStyles(); | ||||
|   const navigate = useNavigate(); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     if (isEmpty(getLoggedInUser())) { | ||||
|       navigate('/home'); | ||||
|     } | ||||
|   }, []); | ||||
|  | ||||
|   return ( | ||||
|     <Stack className={classes.pageWrapper} direction="column" data-testid="explore-container"> | ||||
|       <Header /> | ||||
|       <Container className={classes.container}> | ||||
|         <Grid container className={classes.gridWrapper}> | ||||
|           <Grid item className={classes.tile}> | ||||
|             <ApiKeys /> | ||||
|           </Grid> | ||||
|         </Grid> | ||||
|       </Container> | ||||
|     </Stack> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| export default UserManagementPage; | ||||
| @@ -41,10 +41,15 @@ const isAuthenticationEnabled = () => { | ||||
|   return Object.keys(authMethods).length > 0; | ||||
| }; | ||||
|  | ||||
| const isApiKeyEnabled = () => { | ||||
|   const authConfig = JSON.parse(localStorage.getItem('authConfig')) || {}; | ||||
|   return authConfig?.apikey; | ||||
| }; | ||||
|  | ||||
| const getLoggedInUser = () => { | ||||
|   const userCookie = getCookie('user'); | ||||
|   if (!userCookie) return null; | ||||
|   return userCookie; | ||||
| }; | ||||
|  | ||||
| export { isAuthenticated, isAuthenticationEnabled, getLoggedInUser, logoutUser }; | ||||
| export { isAuthenticated, isAuthenticationEnabled, isApiKeyEnabled, getLoggedInUser, logoutUser }; | ||||
|   | ||||
| @@ -96,7 +96,13 @@ const mapCVEInfo = (cveInfo) => { | ||||
|       id: cve.Id, | ||||
|       severity: cve.Severity, | ||||
|       title: cve.Title, | ||||
|       description: cve.Description | ||||
|       description: cve.Description, | ||||
|       reference: cve.Reference, | ||||
|       packageList: cve.PackageList?.map((pkg) => ({ | ||||
|         packageName: pkg.Name, | ||||
|         packageInstalledVersion: pkg.InstalledVersion, | ||||
|         packageFixedVersion: pkg.FixedVersion | ||||
|       })) | ||||
|     }; | ||||
|   }); | ||||
|   return cveList; | ||||
|   | ||||
| @@ -37,7 +37,8 @@ test.describe('Tag page test', () => { | ||||
|     await page.goto(`${hosts.ui}/image/${tagWithVulnerabilities.title}/tag/${tagWithVulnerabilities.tag}`); | ||||
|     await page.getByRole('tab', { name: 'Vulnerabilities' }).click(); | ||||
|     await expect(page.getByTestId('vulnerability-container').locator('div').nth(1)).toBeVisible({ timeout: 100000 }); | ||||
|     await expect(await page.getByText('CVE-').count()).toBeGreaterThan(0); | ||||
|     await expect(await page.getByText('CVE-').count()).toBeLessThanOrEqual(pageSizes.EXPLORE); | ||||
|     await expect(page.getByText(/CVE-/).nth(0)).toBeVisible({ timeout: 100000 }); | ||||
|     await expect(await page.getByText(/CVE-/).count()).toBeGreaterThan(0); | ||||
|     await expect(await page.getByText(/CVE-/).count()).toBeLessThanOrEqual(pageSizes.EXPLORE); | ||||
|   }); | ||||
| }); | ||||
|   | ||||
		Reference in New Issue
	
	Block a user