diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index 7ce396ac..db19ee0f 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -38,32 +38,32 @@ jobs: run: yarn e2e docker: - # This job triggers only if all the other jobs succeed. It builds the Docker image and if successful, - # it pushes it to Harbor. + # This job triggers only if all the other jobs succeed. It builds the Docker image to ensure it builds correctly. needs: [test] name: Docker runs-on: ubuntu-20.04 steps: - name: Checkout repo - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Login to Harbor - uses: docker/login-action@465a07811f14bebb1938fbed4728c6a1ff8901fc # v2.2.0 + uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0 with: registry: ${{ secrets.HARBOR_URL }} username: ${{ secrets.HARBOR_USERNAME }} password: ${{ secrets.HARBOR_TOKEN }} - - name: Extract metadata (tags, labels) for Docker + - name: Extract metadata (tags, labels, annotations) for Docker id: meta - uses: docker/metadata-action@818d4b7b91585d195f67373fd9cb0332e31a7175 # v4.6.0 + uses: docker/metadata-action@8e5442c4ef9f78752691e2d8f8d19755c6f78e81 # v5.5.1 with: images: ${{ secrets.HARBOR_URL }}/scigateway - - name: Build and push Docker image to Harbor - uses: docker/build-push-action@0a97817b6ade9f46837855d676c4cca3a2471fc9 # v4.2.1 + - name: Build Docker image + uses: docker/build-push-action@4f58ea79222b3b9dc2c8bbdd6debcef730109a75 # v6.9.0 with: context: . - push: true + push: false tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + annotations: ${{ steps.meta.outputs.annotations }} diff --git a/.github/workflows/docker-release-build.yml b/.github/workflows/docker-release-build.yml new file mode 100644 index 00000000..307c66e2 --- /dev/null +++ b/.github/workflows/docker-release-build.yml @@ -0,0 +1,40 @@ +name: Docker Release Build +on: + push: + tags: '*' + +jobs: + docker: + # This job builds the Docker image and if successful, it pushes it to Harbor. + name: Docker + runs-on: ubuntu-latest + steps: + - name: Checkout repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Login to Harbor + uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0 + with: + registry: ${{ secrets.HARBOR_URL }} + username: ${{ secrets.HARBOR_USERNAME }} + password: ${{ secrets.HARBOR_TOKEN }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@8e5442c4ef9f78752691e2d8f8d19755c6f78e81 # v5.5.1 + with: + images: ${{ secrets.HARBOR_URL }}/scigateway + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=ref,event=tag,pattern={{ref}} + + - name: Build and push Docker image to Harbor + uses: docker/build-push-action@4f58ea79222b3b9dc2c8bbdd6debcef730109a75 # v6.9.0 + with: + context: . + file: ./Dockerfile + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + annotations: ${{ steps.meta.outputs.annotations }} diff --git a/.gitignore b/.gitignore index 86281662..343d0d06 100644 --- a/.gitignore +++ b/.gitignore @@ -31,5 +31,6 @@ npm-debug.log* yarn-debug.log* yarn-error.log* -public/settings.json +**/public/*settings*.json +!**/public/*settings.example.json public/*.js \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index bcd3fdfc..1dd40e51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,38 @@ # Changelog +## [v3.0.0](https://github.com/ral-facilities/scigateway/tree/v3.0.0) (2024-11-27) + +## What's Changed + +### Features + +* add darkBlue colour to theming by @joshuadkitenge in https://github.com/ral-facilities/scigateway/pull/1375 +* Increase toast functionailty #355 by @joshuadkitenge in https://github.com/ral-facilities/scigateway/pull/1376 +* Print improvement for IMS by @joelvdavies in https://github.com/ral-facilities/scigateway/pull/1385 +* Change accessibility page variable to be more general #356 by @joshuadkitenge in https://github.com/ral-facilities/scigateway/pull/1377 +* Fix error colour inconsistency for IMS by @joelvdavies in https://github.com/ral-facilities/scigateway/pull/1393 +* Ims maintenance endpoints by @MatteoGuarnaccia5 in https://github.com/ral-facilities/scigateway/pull/1398 +* Add docker image push when tags pushed #1390 by @joelvdavies in https://github.com/ral-facilities/scigateway/pull/1420 +* Generate custom admin tabs from plugin routes #1418 by @joshuadkitenge in https://github.com/ral-facilities/scigateway/pull/1419 +* React 18 #1205 by @louise-davies in https://github.com/ral-facilities/scigateway/pull/1275 + +### Dependencies + +* Bump webpack from 5.76.1 to 5.94.0 by @dependabot in https://github.com/ral-facilities/scigateway/pull/1408 +* Update dependency axios to v1.7.4 [SECURITY] by @renovate in https://github.com/ral-facilities/scigateway/pull/1407 +* Update Node.js to v20.17.0 by @renovate in https://github.com/ral-facilities/scigateway/pull/1411 +* Update httpd:2.4.59-alpine3.20 Docker image to http:2.4.62-alpine3.20 by @renovate in https://github.com/ral-facilities/scigateway/pull/1410 +* Update dependency express to v4.20.0 [SECURITY] by @renovate in https://github.com/ral-facilities/scigateway/pull/1414 +* Bump rollup from 2.79.1 to 2.79.2 by @dependabot in https://github.com/ral-facilities/scigateway/pull/1416 +* Bump http-proxy-middleware from 2.0.6 to 2.0.7 by @dependabot in https://github.com/ral-facilities/scigateway/pull/1417 +* Bump cross-spawn from 7.0.3 to 7.0.6 by @dependabot in https://github.com/ral-facilities/scigateway/pull/1421 + +## New Contributors + +* @MatteoGuarnaccia5 made their first contribution in https://github.com/ral-facilities/scigateway/pull/1398 + +**Full Changelog**: https://github.com/ral-facilities/scigateway/compare/v2.0.0...v3.0.0 + ## [v2.0.0](https://github.com/ral-facilities/scigateway/tree/v2.0.0) (2024-07-24) ## What's Changed diff --git a/Dockerfile b/Dockerfile index ebcf5089..04070e53 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ # Dockerfile to build and serve scigateway # Build stage -FROM node:20.14.0-alpine3.20@sha256:928b24aaadbd47c1a7722c563b471195ce54788bf8230ce807e1dd500aec0549 as builder +FROM node:20.17.0-alpine3.20@sha256:2d07db07a2df6830718ae2a47db6fedce6745f5bcd174c398f2acdda90a11c03 as builder WORKDIR /scigateway-build @@ -24,7 +24,7 @@ COPY docker/settings.json public/settings.json RUN yarn build # Run stage -FROM httpd:2.4.59-alpine3.20@sha256:554f25b8496f360a58febaaa5df9effb8e037cc1b70b27d40b7353a85e8edbf0 +FROM httpd:2.4.62-alpine3.20@sha256:66c49302c02430619abb84240a438bcfc083015661009fcaaeaac931450f62cd WORKDIR /usr/local/apache2/htdocs diff --git a/cypress/e2e/login.cy.ts b/cypress/e2e/login.cy.ts index 9bd106b5..c884d136 100644 --- a/cypress/e2e/login.cy.ts +++ b/cypress/e2e/login.cy.ts @@ -185,6 +185,24 @@ describe('Login', () => { cy.url().should('eq', 'http://127.0.0.1:3000/login'); }); + it('should redirect to login page when navigating to a plugin then back to the plugin after login', () => { + cy.visit('/plugin1'); + + cy.contains('Sign in').should('be.visible'); + + cy.contains('Username*').parent().find('input').type(' username '); + cy.contains('Password*').parent().find('input').type('password'); + + cy.contains('Username*') + .parent() + .parent() + .contains('button', 'Sign in') + .click(); + + cy.url().should('eq', 'http://127.0.0.1:3000/plugin1'); + cy.get('#demo_plugin').contains('Demo Plugin').should('be.visible'); + }); + it('should not be logged in if invalid or unsigned token in localStorage', () => { // if token cannot be deciphered cy.contains('Sign in').should('be.visible'); @@ -320,10 +338,10 @@ describe('Login', () => { ]); cy.intercept('POST', '/login', (req) => { req.reply(loginResponse); - }); + }).as('login'); cy.intercept('POST', '/verify', (req) => { req.reply(verifyResponse); - }); + }).as('verify'); }); it('should allow access to plugins and yet still show the Sign in button', () => { @@ -331,6 +349,8 @@ describe('Login', () => { loginResponse = loginSuccess; cy.visit('/plugin1'); + cy.wait('@login'); + cy.get('#demo_plugin').contains('Demo Plugin').should('be.visible'); cy.contains('Sign in').should('be.visible'); @@ -340,9 +360,13 @@ describe('Login', () => { cy.contains('Sign in').should('be.visible'); // test that autologin works after token validation + refresh fail - verifyResponse = failure; + cy.window().then(() => { + // use cy.window command just so that this line is async and executed at the right time + verifyResponse = failure; + }); cy.intercept('POST', '/refresh', { statusCode: 403 }); cy.reload(); + cy.wait('@login'); cy.get('#demo_plugin').contains('Demo Plugin').should('be.visible'); cy.contains('Sign in').should('be.visible'); }); @@ -356,7 +380,10 @@ describe('Login', () => { cy.contains('h1', 'Sign in').should('be.visible'); // test that autologin fails after token validation + refresh fail - verifyResponse = failure; + cy.window().then(() => { + // use cy.window command just so that this line is async and executed at the right time + verifyResponse = failure; + }); cy.intercept('POST', '/refresh', { statusCode: 403 }); cy.window().then(($window) => $window.localStorage.setItem('scigateway:token', 'invalidtoken') @@ -374,6 +401,37 @@ describe('Login', () => { cy.get('#demo_plugin').contains('Demo Plugin').should('be.visible'); }); + it('can remove toasts with esc keydown when a plugin error occurs', () => { + cy.intercept('/settings.json', { + plugins: [ + { + name: 'demo_plugin', + src: '/plugins/main.js', + enable: true, + location: 'main', + }, + ], + 'ui-strings': 'res/default.json', + 'auth-provider': 'icat', + authUrl: 'http://localhost:8000', + autoLogin: true, + 'help-tour-steps': [], + }); + verifyResponse = verifySuccess; + loginResponse = loginSuccess; + cy.visit('/plugin1'); + + cy.contains( + 'Failed to load plugin demo_plugin from /plugins/main.js.' + ).should('exist'); + + cy.get('body').type('{esc}'); + + cy.contains( + 'Failed to load plugin demo_plugin from /plugins/main.js.' + ).should('not.exist'); + }); + it('should be able to switch authenticators and still be "auto logged in"', () => { verifyResponse = verifySuccess; loginResponse = loginSuccess; @@ -421,6 +479,8 @@ describe('Login', () => { cy.contains('Sign out').click(); cy.contains('Sign in').should('be.visible'); + cy.wait('@login'); + cy.contains('a', 'Demo Plugin').should('be.visible'); cy.contains('a', 'Demo Plugin').click(); diff --git a/cypress/support/commands.js b/cypress/support/commands.js index e4337c83..c8504af3 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -26,11 +26,11 @@ Cypress.Commands.add('login', (username, password) => { return cy.readFile('server/e2e-settings.json').then((settings) => { - cy.request('POST', `${settings.authUrl}/api/jwt/authenticate`, { + cy.request('POST', `${settings.authUrl}/login`, { username: username, password: password, }).then((response) => { - window.localStorage.setItem('scigateway:token', response.body.token); + window.localStorage.setItem('scigateway:token', response.body); }); }); }); diff --git a/micro-frontend-tools/.gitignore b/micro-frontend-tools/.gitignore index bc71dcdf..b2664113 100644 --- a/micro-frontend-tools/.gitignore +++ b/micro-frontend-tools/.gitignore @@ -1 +1,2 @@ -dev-plugin-settings.json \ No newline at end of file +/*settings*.json +!/*settings.example.json \ No newline at end of file diff --git a/package.json b/package.json index bd1d5f88..4b167c7a 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,10 @@ { "name": "scigateway", - "version": "2.0.0", + "version": "3.0.0", "private": true, "resolutions": { - "@types/react": "17.0.38", - "@types/react-dom": "17.0.11", + "@types/react": "18.0.33", + "@types/react-dom": "18.0.11", "@typescript-eslint/eslint-plugin": "7.0.2", "@typescript-eslint/parser": "7.0.2" }, @@ -13,22 +13,22 @@ "@emotion/styled": "11.11.0", "@mui/icons-material": "5.15.10", "@mui/material": "5.15.10", - "@types/history": "4.7.3", + "@types/history": "4.7.11", "@types/jest": "29.5.2", "@types/js-cookie": "3.0.1", - "@types/react-dom": "17.0.11", - "@types/react-redux-toastr": "7.6.0", - "@types/react-router-dom": "5.3.1", + "@types/react-dom": "18.0.11", + "@types/react-redux-toastr": "7.6.2", + "@types/react-router-dom": "5.3.3", "@types/redux-logger": "3.0.8", - "axios": "1.6.2", - "connected-react-router": "6.9.1", + "axios": "1.7.4", + "connected-react-router": "6.9.3", "cookie-parser": "1.4.5", "custom-event-polyfill": "1.0.7", "cypress-failed-log": "2.10.0", "eslint-config-prettier": "9.1.0", "eslint-plugin-cypress": "2.15.1", "eslint-plugin-prettier": "5.1.3", - "express": "4.19.2", + "express": "4.20.0", "husky": "9.0.6", "i18next": "23.8.2", "i18next-browser-languagedetector": "7.2.0", @@ -39,19 +39,19 @@ "prettier": "3.2.5", "prop-types": "15.8.1", "query-string": "7.1.1", - "react": "17.0.2", + "react": "18.2.0", "react-app-polyfill": "3.0.0", - "react-dom": "17.0.2", + "react-dom": "18.2.0", "react-i18next": "14.0.1", "react-joyride": "2.7.2", "react-redux": "8.1.2", "react-redux-toastr": "7.6.8", "react-router-dom": "5.3.0", "react-scripts": "5.0.0", - "redux": "4.2.0", + "redux": "4.2.1", "redux-logger": "3.0.6", "redux-thunk": "3.1.0", - "single-spa": "5.9.1", + "single-spa": "5.9.4", "typeface-roboto": "1.1.13", "typescript": "5.3.3" }, @@ -105,13 +105,13 @@ "devDependencies": { "@babel/eslint-parser": "7.23.3", "@testing-library/jest-dom": "6.4.1", - "@testing-library/react": "12.1.5", + "@testing-library/react": "14.0.0", "@testing-library/user-event": "14.5.2", "@types/jsonwebtoken": "9.0.1", - "@types/node": "20.14.0", - "@types/react": "17.0.38", + "@types/node": "20.16.5", + "@types/react": "18.0.33", "@types/react-redux": "7.1.20", - "@types/react-router": "5.1.12", + "@types/react-router": "5.1.20", "@types/redux-mock-store": "1.0.2", "@typescript-eslint/eslint-plugin": "7.0.2", "@typescript-eslint/parser": "7.0.2", diff --git a/public/index.html b/public/index.html index 6de9f64d..fdecfc10 100644 --- a/public/index.html +++ b/public/index.html @@ -1,19 +1,17 @@ - - - - - - - - - SciGateway - - - - - - - -
- - - + + + \ No newline at end of file diff --git a/public/res/default.json b/public/res/default.json index 1f3bd189..53c7d57d 100644 --- a/public/res/default.json +++ b/public/res/default.json @@ -145,7 +145,7 @@ "website-developed-by": "These sites are maintained by the Data Software and Engineering Group, in the Scientific Computing Department. ", "how-accessible-this-website-is": "How Accessible This Website Is ", "how-accessible-this-website-is-text": "We want as many people as possible to be able to use this website. For example, that means you should be able to: ", - "accessibile-parts-of-datagateway-list": [ + "accessible-parts-of-list": [ "listen to most of the website using a screen reader", "clearly see all components and text as the contrast has been checked against different types of colour-blindness in normal or high contrast modes (which includes protanopia, protanomaly, deuteranopia, deuteranomaly, tritanopia, tritanomaly, achromatopsia and achromatomaly) ", "navigate most of the website using just a keyboard ", @@ -153,8 +153,8 @@ ], "advice-on-how-to-make-device-more-accessible": "<0>AbilityNet has advice on making your device easier to use if you have a disability.", "advice-on-how-to-make-device-more-accessible-link": "https://mcmw.abilitynet.org.uk/", - "non-accessibile-parts-of-datagateway-text": "We know some parts of this website are not fully accessible:", - "non-accessibile-parts-of-datagateway-list": [ + "non-accessible-parts-of-text": "We know some parts of this website are not fully accessible:", + "non-accessible-parts-of-list": [ "the columns on table view cannot be resized using the keyboard", "pages sometimes have unreadable ARIA labels", "table view has aria-hidden elements that contain focusable elements and elements in the table with an ARIA role that require a parent are not contained by them ", diff --git a/server/auth-server.js b/server/auth-server.js index e24d5d3a..5a59f88e 100644 --- a/server/auth-server.js +++ b/server/auth-server.js @@ -42,7 +42,7 @@ function isValidLogin(username, password) { return username === 'username' && password === 'password'; } -app.post(`/api/jwt/authenticate`, function (req, res) { +app.post(`/login`, function (req, res) { const { username, password } = req.body; if (username === 'error') { @@ -70,10 +70,7 @@ app.post(`/api/jwt/authenticate`, function (req, res) { sameSite: 'lax', maxAge: 604800, }); - res.status(200).json({ - username, - token: accessToken, - }); + res.status(200).json(accessToken); } else { res.status(401).json({ error: 'Incorrect email or password', @@ -81,7 +78,7 @@ app.post(`/api/jwt/authenticate`, function (req, res) { } }); -app.post(`/api/jwt/checkToken`, withAuth, function (req, res) { +app.post(`/verify`, withAuth, function (req, res) { const { token } = req.body; if (jwt.verify(token, jwtSecret)) { res.sendStatus(200); @@ -92,7 +89,7 @@ app.post(`/api/jwt/checkToken`, withAuth, function (req, res) { } }); -app.post(`/api/jwt/refresh`, function (req, res) { +app.post(`/refresh`, function (req, res) { const refreshToken = req.cookies['scigateway:refresh_token']; const accessToken = req.body.token; @@ -123,7 +120,7 @@ app.post(`/api/jwt/refresh`, function (req, res) { } }); -app.post(`/api/github/authenticate`, function (req, res) { +app.post(`/github/login`, function (req, res) { const { code } = req.body; const headers = { @@ -160,7 +157,7 @@ app.post(`/api/github/authenticate`, function (req, res) { }); }); -app.post(`/api/github/checkToken`, function (req, res) { +app.post(`/github/verify`, function (req, res) { const { token } = req.body; axios .get('https://api.github.com/user', { diff --git a/src/App.test.tsx b/src/App.test.tsx index aaf6d7b7..1dbdc25e 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -1,11 +1,11 @@ +import { useMediaQuery } from '@mui/material'; +import { act, fireEvent, render, screen } from '@testing-library/react'; +import axios from 'axios'; import React from 'react'; -import ReactDOM from 'react-dom'; +import { createRoot } from 'react-dom/client'; import App, { AppSansHoc } from './App'; -import { act, fireEvent, render, screen } from '@testing-library/react'; import { flushPromises } from './setupTests'; -import axios from 'axios'; import { RegisterRouteType } from './state/scigateway.types'; -import { useMediaQuery } from '@mui/material'; jest.mock('./state/actions/loadMicroFrontends', () => ({ init: jest.fn(() => Promise.resolve()), @@ -36,8 +36,13 @@ describe('App', () => { it('renders without crashing', () => { const div = document.createElement('div'); - ReactDOM.render(, div); - ReactDOM.unmountComponentAtNode(div); + const root = createRoot(div); + act(() => { + root.render(); + }); + act(() => { + root.unmount(); + }); }); it('should show preloader when react-i18next is not ready', () => { @@ -73,11 +78,14 @@ describe('App', () => { order: 0, }, }; - document.dispatchEvent( - new CustomEvent('scigateway', { - detail: registerRouteAction, - }) - ); + + act(() => { + document.dispatchEvent( + new CustomEvent('scigateway', { + detail: registerRouteAction, + }) + ); + }); // go to plugin page await fireEvent.click(screen.getByRole('link', { name: 'Test plugin' })); @@ -95,7 +103,9 @@ describe('App', () => { }) ); - jest.runOnlyPendingTimers(); + act(() => { + jest.runOnlyPendingTimers(); + }); await act(async () => { await flushPromises(); diff --git a/src/__snapshots__/example.component.test.tsx.snap b/src/__snapshots__/example.component.test.tsx.snap index 90e6b261..d74f7669 100644 --- a/src/__snapshots__/example.component.test.tsx.snap +++ b/src/__snapshots__/example.component.test.tsx.snap @@ -3,7 +3,9 @@ exports[`Example component renders correctly 1`] = `
- test notification +
+ warning test notification +
`; diff --git a/src/__snapshots__/pageContainer.test.tsx.snap b/src/__snapshots__/pageContainer.test.tsx.snap index 408197c7..52839bc5 100644 --- a/src/__snapshots__/pageContainer.test.tsx.snap +++ b/src/__snapshots__/pageContainer.test.tsx.snap @@ -40,7 +40,7 @@ exports[`PageContainer - Tests renders correctly 1`] = `
  • - accessibility-page.accessibile-parts-of-datagateway-list0 + accessibility-page.accessible-parts-of-list0
  • - accessibility-page.accessibile-parts-of-datagateway-list1 + accessibility-page.accessible-parts-of-list1
  • @@ -86,16 +86,16 @@ exports[`Accessibility page component should render correctly and display contac

    - accessibility-page.non-accessibile-parts-of-datagateway-text + accessibility-page.non-accessible-parts-of-text

    • - accessibility-page.non-accessibile-parts-of-datagateway-list0 + accessibility-page.accessible-parts-of-list0
    • - accessibility-page.non-accessibile-parts-of-datagateway-list1 + accessibility-page.accessible-parts-of-list1
    diff --git a/src/accessibilityPage/accessibilityPage.component.tsx b/src/accessibilityPage/accessibilityPage.component.tsx index 43a56986..db4dd53a 100644 --- a/src/accessibilityPage/accessibilityPage.component.tsx +++ b/src/accessibilityPage/accessibilityPage.component.tsx @@ -41,41 +41,41 @@ const DescriptionTypography = styled(Typography)<{ color: theme.palette.text.primary, })); -const AccessibiiltyPage = (): React.ReactElement => { +const AccessibilityPage = (): React.ReactElement => { const [t] = useTranslation(); - let datagatewayDomains: string[] = t('accessibility-page.domains-list', { + let domains: string[] = t('accessibility-page.domains-list', { returnObjects: true, }); - let datagatewayAccessibileParts: string[] = t( - 'accessibility-page.accessibile-parts-of-datagateway-list', + let accessibleParts: string[] = t( + 'accessibility-page.accessible-parts-of-list', { returnObjects: true, } ); - let datagatewayNonAccessibileParts: string[] = t( - 'accessibility-page.non-accessibile-parts-of-datagateway-list', + let nonAccessibleParts: string[] = t( + 'accessibility-page.non-accessible-parts-of-list', { returnObjects: true, } ); - let datagatewayNonCompliance: string[] = t( + let nonCompliance: string[] = t( 'accessibility-page.non-compliance-with-the-accessibility-regulations-list', { returnObjects: true, } ); - let datagatewayDisproportionateBurdenTable: string[] = t( + let disproportionateBurdenTable: string[] = t( 'accessibility-page.disproportionate-burden.table-view-list', { returnObjects: true, } ); - let datagatewayDisproportionateBurdenCard: string[] = t( + let disproportionateBurdenCard: string[] = t( 'accessibility-page.disproportionate-burden.card-view-list', { returnObjects: true, @@ -87,17 +87,14 @@ const AccessibiiltyPage = (): React.ReactElement => { //When testing can't easily mock i18next data, but **.map will fail if //given a string, so replace the formats here - if (!Array.isArray(datagatewayDomains)) datagatewayDomains = listPlaceholder; - if (!Array.isArray(datagatewayAccessibileParts)) - datagatewayAccessibileParts = listPlaceholder; - if (!Array.isArray(datagatewayNonAccessibileParts)) - datagatewayNonAccessibileParts = listPlaceholder; - if (!Array.isArray(datagatewayNonCompliance)) - datagatewayNonCompliance = listPlaceholder; - if (!Array.isArray(datagatewayDisproportionateBurdenTable)) - datagatewayDisproportionateBurdenTable = listPlaceholder; - if (!Array.isArray(datagatewayDisproportionateBurdenCard)) - datagatewayDisproportionateBurdenCard = listPlaceholder; + if (!Array.isArray(domains)) domains = listPlaceholder; + if (!Array.isArray(accessibleParts)) accessibleParts = listPlaceholder; + if (!Array.isArray(nonAccessibleParts)) nonAccessibleParts = listPlaceholder; + if (!Array.isArray(nonCompliance)) nonCompliance = listPlaceholder; + if (!Array.isArray(disproportionateBurdenTable)) + disproportionateBurdenTable = listPlaceholder; + if (!Array.isArray(disproportionateBurdenCard)) + disproportionateBurdenCard = listPlaceholder; return ( @@ -111,7 +108,7 @@ const AccessibiiltyPage = (): React.ReactElement => { - {datagatewayDomains.map((item) => ( + {domains.map((item) => (
  • {item}
  • @@ -132,7 +129,7 @@ const AccessibiiltyPage = (): React.ReactElement => { {t('accessibility-page.how-accessible-this-website-is-text')}
    - {datagatewayAccessibileParts.map((item) => ( + {accessibleParts.map((item) => (
  • {item}
  • ))}
    @@ -157,11 +154,11 @@ const AccessibiiltyPage = (): React.ReactElement => { - {t('accessibility-page.non-accessibile-parts-of-datagateway-text')} + {t('accessibility-page.non-accessible-parts-of-text')} - {datagatewayNonAccessibileParts.map((item) => ( + {accessibleParts.map((item) => (
  • {item}
  • ))}
    @@ -243,7 +240,7 @@ const AccessibiiltyPage = (): React.ReactElement => { - {datagatewayNonCompliance.map((item) => ( + {nonCompliance.map((item) => (
  • {item}
  • ))}
    @@ -264,7 +261,7 @@ const AccessibiiltyPage = (): React.ReactElement => { - {datagatewayDisproportionateBurdenTable.map((item) => ( + {disproportionateBurdenTable.map((item) => (
  • {item}
  • ))}
    @@ -275,7 +272,7 @@ const AccessibiiltyPage = (): React.ReactElement => { - {datagatewayDisproportionateBurdenCard.map((item) => ( + {disproportionateBurdenCard.map((item) => (
  • {item}
  • ))}
    @@ -319,4 +316,4 @@ const AccessibiiltyPage = (): React.ReactElement => { ); }; -export default AccessibiiltyPage; +export default AccessibilityPage; diff --git a/src/adminPage/adminPage.component.test.tsx b/src/adminPage/adminPage.component.test.tsx index e2aaba7d..76e32697 100644 --- a/src/adminPage/adminPage.component.test.tsx +++ b/src/adminPage/adminPage.component.test.tsx @@ -1,19 +1,18 @@ -import React from 'react'; +import { StyledEngineProvider, ThemeProvider } from '@mui/material'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { createLocation, createMemoryHistory, History } from 'history'; +import React from 'react'; +import { Provider } from 'react-redux'; +import { Router } from 'react-router'; +import configureStore from 'redux-mock-store'; +import { thunk } from 'redux-thunk'; +import TestAuthProvider from '../authentication/testAuthProvider'; import { authState, initialState } from '../state/reducers/scigateway.reducer'; -import { StateType } from '../state/state.types'; import { PluginConfig } from '../state/scigateway.types'; -import configureStore from 'redux-mock-store'; -import AdminPage from './adminPage.component'; -import { Provider } from 'react-redux'; +import { StateType } from '../state/state.types'; import { buildTheme } from '../theming'; -import TestAuthProvider from '../authentication/testAuthProvider'; -import { thunk } from 'redux-thunk'; -import { Router } from 'react-router'; -import { StyledEngineProvider, ThemeProvider } from '@mui/material'; -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { getPluginRoutes } from './adminPage.component'; +import AdminPage, { getAdminPluginRoutes } from './adminPage.component'; describe('Admin page component', () => { let mockStore; @@ -50,9 +49,24 @@ describe('Admin page component', () => { ); } + afterEach(() => { + jest.clearAllMocks(); + }); + it('should render maintenance page correctly', () => { - history.replace('/admin/maintenance'); state.scigateway.adminPageDefaultTab = 'download'; + state.scigateway.plugins = [ + ...state.scigateway.plugins, + { + order: 1, + plugin: 'datagateway-download', + link: '/admin/download', + section: 'Admin', + displayName: 'Admin Download', + admin: true, + }, + ]; + history.replace('/admin/maintenance'); render(, { wrapper: Wrapper }); @@ -131,9 +145,58 @@ describe('Admin page component', () => { ).toBeInTheDocument(); }); + it("falls back to 'maintenance' when adminPageDefaultTab is not provided", () => { + state.scigateway.adminPageDefaultTab = undefined; + history.replace('/admin'); + + render(, { wrapper: Wrapper }); + + // Assert that the `maintenance` tab is selected by default + expect(screen.getByRole('tab', { name: 'Maintenance' })).toHaveAttribute( + 'aria-selected', + 'true' + ); + }); + + it("falls back to 'maintenance' when on an invalid route", () => { + state.scigateway.plugins = [ + { + order: 1, + plugin: 'datagateway-download', + link: '/admin/download', + section: 'Admin', + displayName: 'Admin Download', + admin: true, + }, + ]; + state.scigateway.adminPageDefaultTab = 'maintenance'; + history.replace('/admin/test'); + + render(, { wrapper: Wrapper }); + + // Assert that the `maintenance` tab is selected by default + expect(screen.getByRole('tab', { name: 'Maintenance' })).toHaveAttribute( + 'aria-selected', + 'true' + ); + }); + + it("falls back to 'maintenance' when adminPageDefaultTab doesn't match any key in adminRoutes", () => { + state.scigateway.adminPageDefaultTab = 'nonexistentTab'; + history.replace('/admin'); + + render(, { wrapper: Wrapper }); + + // Assert that the `maintenance` tab is selected by default + expect(screen.getByRole('tab', { name: 'Maintenance' })).toHaveAttribute( + 'aria-selected', + 'true' + ); + }); + it('should return an empty object when given an empty plugins array', () => { const plugins = []; - const result = getPluginRoutes(plugins); + const result = getAdminPluginRoutes({ plugins }); expect(result).toEqual({}); }); @@ -164,9 +227,9 @@ describe('Admin page component', () => { order: 3, }, ]; - const result = getPluginRoutes(plugins, true); // Admin user + const result = getAdminPluginRoutes({ plugins }); // Admin user expect(result).toEqual({ - PluginA: ['/admin/pluginA', '/admin/pluginA2'], + PluginA: { pluginA: '/admin/pluginA', pluginA2: '/admin/pluginA2' }, }); }); @@ -175,7 +238,7 @@ describe('Admin page component', () => { { plugin: 'PluginA', admin: true, - link: '/admin/pluginA', + link: '/admin/pluginALink', section: 'A', displayName: 'A', order: 1, @@ -183,15 +246,17 @@ describe('Admin page component', () => { { plugin: 'PluginB', admin: false, - link: '/public/pluginB', + link: '/public/pluginBLink', section: 'B', displayName: 'B', order: 2, }, ]; - const result = getPluginRoutes(plugins, false); // Non-admin user + const result = getAdminPluginRoutes({ plugins }); // Non-admin user expect(result).toEqual({ - PluginB: ['/public/pluginB'], + PluginA: { + pluginALink: '/admin/pluginALink', + }, }); }); }); diff --git a/src/adminPage/adminPage.component.tsx b/src/adminPage/adminPage.component.tsx index d5adbf23..5739de2e 100644 --- a/src/adminPage/adminPage.component.tsx +++ b/src/adminPage/adminPage.component.tsx @@ -1,55 +1,67 @@ -import React, { ReactElement } from 'react'; -import Typography from '@mui/material/Typography'; import { Paper } from '@mui/material'; +import Typography from '@mui/material/Typography'; +import React, { ReactElement } from 'react'; import { connect } from 'react-redux'; +import { PluginConfig } from '../state/scigateway.types'; import { StateType } from '../state/state.types'; -import { adminRoutes, PluginConfig } from '../state/scigateway.types'; -import Tabs from '@mui/material/Tabs'; import Tab from '@mui/material/Tab'; +import Tabs from '@mui/material/Tabs'; +import { useTranslation } from 'react-i18next'; import { Link, Route, Switch, useLocation } from 'react-router-dom'; import PageNotFound from '../pageNotFound/pageNotFound.component'; -import { PluginPlaceHolder } from '../routing/routing.component'; +import { + getAdminRoutes, + PluginPlaceHolder, +} from '../routing/routing.component'; import MaintenancePage from './maintenancePage.component'; -import { useTranslation } from 'react-i18next'; export interface AdminPageProps { plugins: PluginConfig[]; - adminPageDefaultTab?: 'maintenance' | 'download'; + adminPageDefaultTab?: string; } -export const getPluginRoutes = ( - plugins: PluginConfig[], - admin?: boolean -): Record => { - const pluginRoutes: Record = {}; +export const getAdminPluginRoutes = (props: { + plugins: PluginConfig[]; +}): Record> => { + const { plugins } = props; + const pluginRoutes: Record> = {}; plugins.forEach((p) => { - const isAdmin = admin ? p.admin : !p.admin; const basePluginLink = p.link.split('?')[0]; - if (isAdmin) { - if (pluginRoutes[p.plugin]) { - pluginRoutes[p.plugin].push(basePluginLink); - } else { - pluginRoutes[p.plugin] = [basePluginLink]; + + if (p.admin) { + // Extract `plugin` and `tabName` values from the link + const tabName = basePluginLink.split('/')[2]; // Ignore `/admin` part, get the tabName as the third part + + // Initialize nested structure for each plugin and tabName + if (!pluginRoutes[p.plugin]) { + pluginRoutes[p.plugin] = {}; + } + + // Only store the first route (or the most relevant one) + if (!pluginRoutes[p.plugin][tabName]) { + pluginRoutes[p.plugin][tabName] = basePluginLink; } } }); + return pluginRoutes; }; const AdminPage = (props: AdminPageProps): ReactElement => { - const pluginRoutes = getPluginRoutes(props.plugins, true); + const pluginRoutes = getAdminPluginRoutes({ plugins: props.plugins }); + const adminRoutes = getAdminRoutes({ plugins: props.plugins }); const location = useLocation(); - - const [tabValue, setTabValue] = React.useState<'maintenance' | 'download'>( - // allows direct access to a tab when another tab is the default - (Object.keys(adminRoutes) as (keyof typeof adminRoutes)[]).find( - (key) => adminRoutes[key] === location.pathname + const [tabValue, setTabValue] = React.useState( + (Object.keys(adminRoutes) as (keyof typeof adminRoutes)[]).find((key) => + location.pathname.startsWith(adminRoutes[key]) ) ?? - props.adminPageDefaultTab ?? - 'maintenance' + (props.adminPageDefaultTab && + adminRoutes.hasOwnProperty(props.adminPageDefaultTab) + ? props.adminPageDefaultTab + : 'maintenance') ); const [t] = useTranslation(); @@ -76,22 +88,26 @@ const AdminPage = (props: AdminPageProps): ReactElement => { setTabValue(newValue); }} > - - + {Object.entries(adminRoutes).map(([key, value]) => { + const pluginDetails = props.plugins.find( + (plugin) => plugin.link === value + ); + + return ( + + ); + })} @@ -105,20 +121,21 @@ const AdminPage = (props: AdminPageProps): ReactElement => {
    - {Object.entries(pluginRoutes).map(([key, value]) => { - return ( - + {Object.entries(pluginRoutes).map(([pluginName, tabRoutes]) => + Object.entries(tabRoutes).map(([tabName, route]) => ( + - ); - })} + )) + )} + diff --git a/src/authentication/githubAuthProvider.tsx b/src/authentication/githubAuthProvider.tsx index 714ca3ec..9d5ed76a 100644 --- a/src/authentication/githubAuthProvider.tsx +++ b/src/authentication/githubAuthProvider.tsx @@ -18,7 +18,7 @@ export default class GithubAuthProvider extends BaseAuthProvider { return Promise.resolve(); } - return Axios.post(`${this.authUrl}/api/github/authenticate`, { + return Axios.post(`${this.authUrl}/github/login`, { code: params.code, }) .then((res) => { @@ -32,7 +32,7 @@ export default class GithubAuthProvider extends BaseAuthProvider { } public verifyLogIn(): Promise { - return Axios.post(`${this.authUrl}/api/github/checkToken`, { + return Axios.post(`${this.authUrl}/github/verify`, { token: this.token, }) .then((res) => { diff --git a/src/authentication/jwtAuthProvider.test.tsx b/src/authentication/jwtAuthProvider.test.tsx index 0ebece5f..6b85b0dc 100644 --- a/src/authentication/jwtAuthProvider.test.tsx +++ b/src/authentication/jwtAuthProvider.test.tsx @@ -56,9 +56,7 @@ describe('jwt auth provider', () => { it('should call the api to authenticate', async () => { (mockAxios.post as jest.Mock).mockImplementation(() => Promise.resolve({ - data: { - token: testToken, - }, + data: testToken, }) ); @@ -99,12 +97,9 @@ describe('jwt auth provider', () => { await jwtAuthProvider.verifyLogIn(); - expect(mockAxios.post).toBeCalledWith( - 'http://localhost:8000/api/jwt/checkToken', - { - token: testToken, - } - ); + expect(mockAxios.post).toBeCalledWith('http://localhost:8000/verify', { + token: testToken, + }); }); it('should call refresh if the access token has expired', async () => { @@ -130,14 +125,14 @@ describe('jwt auth provider', () => { it('should update the token if the refresh method is successful', async () => { (mockAxios.post as jest.Mock).mockImplementation(() => Promise.resolve({ - data: { token: 'new-token' }, + data: 'new-token', }) ); await jwtAuthProvider.refresh(); expect(mockAxios.post).toHaveBeenCalledWith( - 'http://localhost:8000/api/jwt/refresh', + 'http://localhost:8000/refresh', { token: testToken, } diff --git a/src/authentication/jwtAuthProvider.tsx b/src/authentication/jwtAuthProvider.tsx index 534757ec..98cc0ac7 100644 --- a/src/authentication/jwtAuthProvider.tsx +++ b/src/authentication/jwtAuthProvider.tsx @@ -7,12 +7,12 @@ export default class JWTAuthProvider extends BaseAuthProvider { return Promise.resolve(); } - return Axios.post(`${this.authUrl}/api/jwt/authenticate`, { + return Axios.post(`${this.authUrl}/login`, { username, password, }) .then((res) => { - this.storeToken(res.data.token); + this.storeToken(res.data); this.storeUser(username); return; }) @@ -22,7 +22,7 @@ export default class JWTAuthProvider extends BaseAuthProvider { } public verifyLogIn(): Promise { - return Axios.post(`${this.authUrl}/api/jwt/checkToken`, { + return Axios.post(`${this.authUrl}/verify`, { token: this.token, }) .then(() => { @@ -32,11 +32,11 @@ export default class JWTAuthProvider extends BaseAuthProvider { } public refresh(): Promise { - return Axios.post(`${this.authUrl}/api/jwt/refresh`, { + return Axios.post(`${this.authUrl}/refresh`, { token: this.token, }) .then((res) => { - this.storeToken(res.data.token); + this.storeToken(res.data); }) .catch((err) => this.handleRefreshError(err)); } diff --git a/src/authentication/ldapJWTAuthProvider.test.tsx b/src/authentication/ldapJWTAuthProvider.test.tsx new file mode 100644 index 00000000..20382800 --- /dev/null +++ b/src/authentication/ldapJWTAuthProvider.test.tsx @@ -0,0 +1,75 @@ +import mockAxios from 'axios'; +import LDAPJWTAuthProvider from './ldapJWTAuthProvider'; + +describe('LDAP-JWT Auth provider', () => { + let ldapJWTAuthProvider: LDAPJWTAuthProvider; + + beforeEach(() => { + ldapJWTAuthProvider = new LDAPJWTAuthProvider('http://localhost:8000'); + }); + + it('should call api to fetch maintenance state', async () => { + (mockAxios.get as jest.Mock).mockImplementation(() => + Promise.resolve({ + data: { + show: false, + message: 'test', + }, + }) + ); + + await ldapJWTAuthProvider.fetchMaintenanceState(); + expect(mockAxios.get).toHaveBeenCalledWith( + 'http://localhost:8000/maintenance' + ); + }); + + it('should call api to fetch scheduled maintenance state', async () => { + (mockAxios.get as jest.Mock).mockImplementation(() => + Promise.resolve({ + data: { + show: false, + message: 'test', + severity: 'error', + }, + }) + ); + + await ldapJWTAuthProvider.fetchScheduledMaintenanceState(); + expect(mockAxios.get).toHaveBeenCalledWith( + 'http://localhost:8000/scheduled_maintenance' + ); + }); + + it('should log the user out if it fails to fetch maintenance state', async () => { + (mockAxios.get as jest.Mock).mockImplementation(() => + Promise.reject({ + response: { + status: 401, + }, + }) + ); + + await ldapJWTAuthProvider.fetchMaintenanceState().catch(() => { + // catch error + }); + + expect(ldapJWTAuthProvider.isLoggedIn()).toBeFalsy(); + }); + + it('should log the user out if it fails to fetch scheduled maintenance state', async () => { + (mockAxios.get as jest.Mock).mockImplementation(() => + Promise.reject({ + response: { + status: 401, + }, + }) + ); + + await ldapJWTAuthProvider.fetchScheduledMaintenanceState().catch(() => { + // catch error + }); + + expect(ldapJWTAuthProvider.isLoggedIn()).toBeFalsy(); + }); +}); diff --git a/src/authentication/ldapJWTAuthProvider.tsx b/src/authentication/ldapJWTAuthProvider.tsx new file mode 100644 index 00000000..b414f6bd --- /dev/null +++ b/src/authentication/ldapJWTAuthProvider.tsx @@ -0,0 +1,26 @@ +import Axios from 'axios'; +import JWTAuthProvider from './jwtAuthProvider'; +import { ScheduledMaintenanceState } from '../state/scigateway.types'; +import { MaintenanceState } from '../state/scigateway.types'; + +export default class LDAPJWTAuthProvider extends JWTAuthProvider { + public fetchScheduledMaintenanceState(): Promise { + return Axios.get(`${this.authUrl}/scheduled_maintenance`) + .then((res) => { + return res.data; + }) + .catch((err) => { + this.handleAuthError(err); + }); + } + + public fetchMaintenanceState(): Promise { + return Axios.get(`${this.authUrl}/maintenance`) + .then((res) => { + return res.data; + }) + .catch((err) => { + this.handleAuthError(err); + }); + } +} diff --git a/src/cookieConsent/cookiesPage.component.tsx b/src/cookieConsent/cookiesPage.component.tsx index 8da30680..aee0e5f1 100644 --- a/src/cookieConsent/cookiesPage.component.tsx +++ b/src/cookieConsent/cookiesPage.component.tsx @@ -128,9 +128,12 @@ const CookiesPage = (props: CombinedCookiesPageProps): React.ReactElement => { {getString(props.res, 'essential-cookies-description')} - {t('cookies-page.essential-cookies-list', { - returnObjects: true, - }).map((s: string, i: number) => ( + {t( + 'cookies-page.essential-cookies-list', + { + returnObjects: true, + } + ).map((s: string, i: number) => ( { it('renders correctly', () => { // update the notification - state.scigateway.notifications = ['test notification']; + state.scigateway.notifications = [ + { severity: 'warning', message: 'test notification' }, + ]; const { asFragment } = render( diff --git a/src/example.component.tsx b/src/example.component.tsx index 01899290..566c5300 100644 --- a/src/example.component.tsx +++ b/src/example.component.tsx @@ -7,7 +7,13 @@ interface ExampleComponentProps { } const ExampleComponent = (props: ExampleComponentProps): React.ReactElement => ( -
    {props.notifications}
    +
    + {props.notifications.map((notification, index) => ( +
    + {notification.severity} {notification.message} +
    + ))} +
    ); const mapStateToProps = (state: StateType): ExampleComponentProps => { diff --git a/src/footer/__snapshots__/footer.component.test.tsx.snap b/src/footer/__snapshots__/footer.component.test.tsx.snap index d290e863..57805864 100644 --- a/src/footer/__snapshots__/footer.component.test.tsx.snap +++ b/src/footer/__snapshots__/footer.component.test.tsx.snap @@ -3,7 +3,7 @@ exports[`Footer component footer renders correctly 1`] = `
    ({ position: 'absolute', bottom: 0, - paddingBottom: theme.footerPaddingBottom, - paddingTop: theme.footerPaddingTop, + paddingBottom: theme.spacing(1), + paddingTop: theme.spacing(1), width: '100%', height: theme.footerHeight, display: 'flex', @@ -29,6 +29,9 @@ const RootDiv = styled('div')(({ theme }) => ({ color: theme.colours.footerLink.active, }, }, + '@media print': { + display: 'none', + }, })); const StyledLink = styled(Link)<{ component?: React.ElementType; to?: string }>( diff --git a/src/index.tsx b/src/index.tsx index a165bb45..bb213cf1 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -3,11 +3,11 @@ import 'react-app-polyfill/stable'; import 'custom-event-polyfill'; import './i18n'; import React from 'react'; -import ReactDOM from 'react-dom'; +import { createRoot } from 'react-dom/client'; import App from './App'; import 'typeface-roboto'; -ReactDOM.render( - , - document.getElementById('scigateway') -); +const container = document.getElementById('scigateway'); +// eslint-disable-next-line @typescript-eslint/no-non-null-assertion +const root = createRoot(container!); +root.render(); diff --git a/src/loginPage/loginPage.component.test.tsx b/src/loginPage/loginPage.component.test.tsx index 7270c166..f567ac27 100644 --- a/src/loginPage/loginPage.component.test.tsx +++ b/src/loginPage/loginPage.component.test.tsx @@ -10,7 +10,7 @@ import LoginPage, { import { buildTheme } from '../theming'; import { ThemeProvider } from '@mui/material/styles'; import TestAuthProvider from '../authentication/testAuthProvider'; -import { createLocation } from 'history'; +import { createLocation, createMemoryHistory, MemoryHistory } from 'history'; import axios from 'axios'; import { ICATAuthenticator, StateType } from '../state/state.types'; import configureStore from 'redux-mock-store'; @@ -25,22 +25,17 @@ import { AnyAction } from 'redux'; import { NotificationType } from '../state/scigateway.types'; import * as log from 'loglevel'; import { + act, render, screen, waitFor, waitForElementToBeRemoved, } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { Router } from 'react-router-dom'; jest.mock('loglevel'); -jest.mock('react-router-dom', () => ({ - ...jest.requireActual('react-router-dom'), - useLocation: () => ({ - pathname: 'localhost:3000/login', - }), -})); - describe('Login selector component', () => { let props: CombinedLoginProps; @@ -52,8 +47,9 @@ describe('Login selector component', () => { loading: false, provider: new TestAuthProvider(null), }, - location: createLocation('/'), res: undefined, + verifyUsernameAndPassword: jest.fn(), + resetAuthState: jest.fn(), }; }); @@ -96,13 +92,19 @@ describe('Login page component', () => { let props: CombinedLoginProps; let mockStore; let state: StateType; + let history: MemoryHistory; beforeEach(() => { mockStore = configureStore([thunk]); + history = createMemoryHistory({ initialEntries: ['/login'] }); + state = { scigateway: { ...initialState, authorisation: { ...authState } }, - router: { location: createLocation('/') }, + router: { + location: { ...createLocation('/'), query: {} }, + action: 'POP', + }, }; props = { @@ -112,9 +114,9 @@ describe('Login page component', () => { loading: false, provider: new TestAuthProvider(null), }, - location: createLocation('/'), res: undefined, verifyUsernameAndPassword: () => Promise.resolve(), + resetAuthState: jest.fn(), }; state.scigateway.authorisation = props.auth; @@ -125,7 +127,11 @@ describe('Login page component', () => { }: { children: React.ReactElement; }): JSX.Element { - return {children}; + return ( + + {children} + + ); } it('credential component renders correctly', () => { @@ -379,7 +385,7 @@ describe('Login page component', () => { }); it('on submit verification method should be called with username and password arguments', async () => { - const mockLoginfn = jest.fn(); + const mockLoginfn = jest.fn(() => Promise.resolve()); const user = userEvent.setup(); props.verifyUsernameAndPassword = mockLoginfn; @@ -436,15 +442,20 @@ describe('Login page component', () => { expect(window.location.href).toEqual('test redirect'); }); - it('on location.search filled in verification method should be called with blank username and query string', () => { + it('on location.search filled in verification method should be called with blank username and query string', async () => { props.auth.provider.redirectUrl = 'test redirect'; - props.location.search = '?token=test_token'; + history.replace('/login?token=test_token'); - const mockLoginfn = jest.fn(); + const promise = Promise.resolve(); + const mockLoginfn = jest.fn(() => promise); props.verifyUsernameAndPassword = mockLoginfn; render(, { wrapper: Wrapper }); + await act(async () => { + await promise; + }); + expect(mockLoginfn.mock.calls.length).toEqual(1); expect(mockLoginfn.mock.calls[0]).toEqual([ '', @@ -454,7 +465,7 @@ describe('Login page component', () => { }); it('on submit verification method should be called when logs in via keyless authenticator', async () => { - const mockLoginfn = jest.fn(); + const mockLoginfn = jest.fn(() => Promise.resolve()); const user = userEvent.setup(); props.verifyUsernameAndPassword = mockLoginfn; props.auth.provider.mnemonic = 'nokeys'; @@ -482,7 +493,7 @@ describe('Login page component', () => { it('verifyUsernameAndPassword action should be sent when the verifyUsernameAndPassword function is called', async () => { state.scigateway.authorisation.provider.redirectUrl = 'test redirect'; - state.router.location.search = '?token=test_token'; + history.replace('/login?token=test_token'); state.scigateway.authorisation.provider.mnemonic = 'nokeys'; (axios.get as jest.Mock).mockImplementation(() => diff --git a/src/loginPage/loginPage.component.tsx b/src/loginPage/loginPage.component.tsx index 90223c81..c9a82ae5 100644 --- a/src/loginPage/loginPage.component.tsx +++ b/src/loginPage/loginPage.component.tsx @@ -16,7 +16,6 @@ import { } from '../state/actions/scigateway.actions'; import { AppStrings, NotificationType } from '../state/scigateway.types'; import { AuthState, ICATAuthenticator, StateType } from '../state/state.types'; -import { Location } from 'history'; import { Box, FormControl, @@ -90,7 +89,6 @@ const DividerWithText = (props: { interface LoginPageProps { auth: AuthState; res?: AppStrings; - location: Location; } interface LoginPageDispatchProps { @@ -145,15 +143,21 @@ export const CredentialsLoginScreen = ( const [t] = useTranslation(); + const { verifyUsernameAndPassword, mnemonic } = props; + + const login = React.useCallback(async () => { + return await verifyUsernameAndPassword(username, password, mnemonic); + }, [password, verifyUsernameAndPassword, mnemonic, username]); + return ( { + onKeyDown={(e) => { if ( !props.auth.provider.redirectUrl && e.key === 'Enter' && isInputValid() ) { - props.verifyUsernameAndPassword(username, password, props.mnemonic); + login(); } }} > @@ -194,9 +198,7 @@ export const CredentialsLoginScreen = ( color="primary" sx={buttonStyles} disabled={!isInputValid() || props.auth.loading} - onClick={() => { - props.verifyUsernameAndPassword(username, password, props.mnemonic); - }} + onClick={login} > { const [t] = useTranslation(); + const { verifyUsernameAndPassword, mnemonic } = props; + + const login = React.useCallback(async () => { + return await verifyUsernameAndPassword('', '', mnemonic); + }, [verifyUsernameAndPassword, mnemonic]); + return ( { + onKeyDown={(e) => { if (e.key === 'Enter') { - props.verifyUsernameAndPassword('', '', props.mnemonic); + login(); } }} > @@ -260,9 +268,7 @@ export const AnonLoginScreen = ( variant="contained" color="primary" sx={buttonStyles} - onClick={() => { - props.verifyUsernameAndPassword('', '', props.mnemonic); - }} + onClick={login} > {t('login.login-button')} @@ -351,7 +357,13 @@ export const LoginPageComponent = ( const [mnemonic, setMnemonic] = useState( props.auth.provider.mnemonic ); - const location = useLocation(); + const location = useLocation<{ referrer?: string } | undefined>(); + + const { verifyUsernameAndPassword } = props; + + const login = React.useCallback(async () => { + return await verifyUsernameAndPassword('', location.search, mnemonic); + }, [location.search, verifyUsernameAndPassword, mnemonic]); React.useEffect(() => { if (typeof mnemonic !== 'undefined' && !fetchedMnemonics) { @@ -377,12 +389,12 @@ export const LoginPageComponent = ( React.useEffect(() => { if ( props.auth.provider.redirectUrl && - props.location.search && + location.search && !props.auth.loading && !props.auth.failedToLogin ) { - if (props.location.search) { - props.verifyUsernameAndPassword('', props.location.search, mnemonic); + if (location.search) { + login(); } } }); @@ -488,7 +500,6 @@ export const LoginPageComponent = ( const mapStateToProps = (state: StateType): LoginPageProps => ({ auth: state.scigateway.authorisation, - location: state.router.location, }); const mapDispatchToProps = ( diff --git a/src/mainAppBar/mainAppBar.component.test.tsx b/src/mainAppBar/mainAppBar.component.test.tsx index a1aefd2e..44fd1bcf 100644 --- a/src/mainAppBar/mainAppBar.component.test.tsx +++ b/src/mainAppBar/mainAppBar.component.test.tsx @@ -1,25 +1,25 @@ -import React from 'react'; -import MainAppBarComponent from './mainAppBar.component'; +import { useMediaQuery } from '@mui/material'; +import { StyledEngineProvider, ThemeProvider } from '@mui/material/styles'; +import { render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { push } from 'connected-react-router'; import { createLocation, createMemoryHistory, History } from 'history'; -import { StateType } from '../state/state.types'; -import { PluginConfig } from '../state/scigateway.types'; +import React from 'react'; +import { Provider } from 'react-redux'; +import { Router } from 'react-router-dom'; import configureStore, { MockStore } from 'redux-mock-store'; -import { push } from 'connected-react-router'; -import { initialState } from '../state/reducers/scigateway.reducer'; +import TestAuthProvider from '../authentication/testAuthProvider'; import { loadDarkModePreference, loadHighContrastModePreference, toggleDrawer, toggleHelp, } from '../state/actions/scigateway.actions'; -import { Provider } from 'react-redux'; -import TestAuthProvider from '../authentication/testAuthProvider'; +import { initialState } from '../state/reducers/scigateway.reducer'; +import { PluginConfig } from '../state/scigateway.types'; +import { StateType } from '../state/state.types'; import { buildTheme } from '../theming'; -import { StyledEngineProvider, ThemeProvider } from '@mui/material/styles'; -import { Router } from 'react-router-dom'; -import { render, screen, waitFor, within } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { useMediaQuery } from '@mui/material'; +import MainAppBarComponent from './mainAppBar.component'; jest.mock('@mui/material', () => ({ __esmodule: true, @@ -219,6 +219,17 @@ describe('Main app bar component', () => { it('redirects to Admin page when Admin button clicked (download is default)', async () => { state.scigateway.adminPageDefaultTab = 'download'; + state.scigateway.plugins = [ + ...state.scigateway.plugins, + { + section: 'Admin', + link: '/admin/download', + displayName: 'Admin Download', + admin: true, + order: 1, + plugin: 'plugin', + }, + ]; const user = userEvent.setup(); render(, { wrapper: Wrapper }); diff --git a/src/mainAppBar/mainAppBar.component.tsx b/src/mainAppBar/mainAppBar.component.tsx index d9bd48b6..56ace706 100644 --- a/src/mainAppBar/mainAppBar.component.tsx +++ b/src/mainAppBar/mainAppBar.component.tsx @@ -1,37 +1,36 @@ -import React, { useState } from 'react'; -import { Dispatch, Action } from 'redux'; -import { connect } from 'react-redux'; -import AppBar from '@mui/material/AppBar'; -import Toolbar from '@mui/material/Toolbar'; -import Button from '@mui/material/Button'; -import IconButton from '@mui/material/IconButton'; import HelpIcon from '@mui/icons-material/HelpOutline'; import MenuIcon from '@mui/icons-material/Menu'; -import SettingsIcon from '@mui/icons-material/Settings'; -import MoreVertIcon from '@mui/icons-material/MoreVert'; -import { styled, useMediaQuery } from '@mui/material'; import MenuOpenIcon from '@mui/icons-material/MenuOpen'; +import MoreVertIcon from '@mui/icons-material/MoreVert'; +import SettingsIcon from '@mui/icons-material/Settings'; +import { Box, styled, useMediaQuery } from '@mui/material'; +import AppBar from '@mui/material/AppBar'; +import Button from '@mui/material/Button'; +import IconButton from '@mui/material/IconButton'; import { Theme, useTheme } from '@mui/material/styles'; +import Toolbar from '@mui/material/Toolbar'; +import { push } from 'connected-react-router'; +import React, { useState } from 'react'; +import { connect } from 'react-redux'; +import { useLocation } from 'react-router-dom'; +import { Action, Dispatch } from 'redux'; +import NullAuthProvider from '../authentication/nullAuthProvider'; import ScigatewayLogo from '../images/scigateway-white-text-blue-mark-logo.svg'; +import NotificationBadgeComponent from '../notifications/notificationBadge.component'; import { - toggleDrawer, - toggleHelp, loadDarkModePreference, loadHighContrastModePreference, + toggleDrawer, + toggleHelp, } from '../state/actions/scigateway.actions'; -import { AppStrings } from '../state/scigateway.types'; +import { AppStrings, PluginConfig } from '../state/scigateway.types'; import { StateType } from '../state/state.types'; -import { push } from 'connected-react-router'; import { getAppStrings, getString } from '../state/strings'; -import UserProfileComponent from './userProfile.component'; -import NotificationBadgeComponent from '../notifications/notificationBadge.component'; -import { PluginConfig } from '../state/scigateway.types'; -import { useLocation } from 'react-router-dom'; -import SettingsMenu from './settingsMenu.component'; import MobileOverflowMenu from './mobileOverflowMenu.component'; -import { appBarIconButtonStyle, appBarMenuItemIconStyle } from './styles'; import PageLinks from './pageLinks.component'; -import NullAuthProvider from '../authentication/nullAuthProvider'; +import SettingsMenu from './settingsMenu.component'; +import { appBarIconButtonStyle, appBarMenuItemIconStyle } from './styles'; +import UserProfileComponent from './userProfile.component'; interface MainAppProps { drawerOpen: boolean; @@ -47,7 +46,7 @@ interface MainAppProps { loading: boolean; logo?: string; homepageUrl?: string; - adminPageDefaultTab?: 'maintenance' | 'download'; + adminPageDefaultTab?: string; pathname: string; } @@ -129,7 +128,14 @@ export const MainAppBar = ( }, [isViewportMdOrLarger]); return ( -
    + -
    + ); }; diff --git a/src/mainAppBar/mobileOverflowMenu.component.test.tsx b/src/mainAppBar/mobileOverflowMenu.component.test.tsx index be298589..b62d9da1 100644 --- a/src/mainAppBar/mobileOverflowMenu.component.test.tsx +++ b/src/mainAppBar/mobileOverflowMenu.component.test.tsx @@ -1,20 +1,20 @@ -import configureStore, { MockStore } from 'redux-mock-store'; -import { StateType } from '../state/state.types'; -import { initialState } from '../state/reducers/scigateway.reducer'; +import { StyledEngineProvider, ThemeProvider } from '@mui/material/styles'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { push } from 'connected-react-router'; import { createLocation, createMemoryHistory, History } from 'history'; import * as React from 'react'; -import { StyledEngineProvider, ThemeProvider } from '@mui/material/styles'; import { Provider } from 'react-redux'; import { Router } from 'react-router-dom'; -import { buildTheme } from '../theming'; -import { render, screen } from '@testing-library/react'; -import MobileOverflowMenu from './mobileOverflowMenu.component'; +import configureStore, { MockStore } from 'redux-mock-store'; import TestAuthProvider, { NonAdminTestAuthProvider, } from '../authentication/testAuthProvider'; -import userEvent from '@testing-library/user-event'; -import { push } from 'connected-react-router'; import { toggleHelp } from '../state/actions/scigateway.actions'; +import { initialState } from '../state/reducers/scigateway.reducer'; +import { StateType } from '../state/state.types'; +import { buildTheme } from '../theming'; +import MobileOverflowMenu from './mobileOverflowMenu.component'; describe('Mobile overflow menu', () => { let testStore: MockStore; @@ -99,6 +99,17 @@ describe('Mobile overflow menu', () => { it('redirects to Admin page when Admin button clicked (download is default)', async () => { state.scigateway.adminPageDefaultTab = 'download'; + state.scigateway.plugins = [ + ...state.scigateway.plugins, + { + section: 'Admin', + link: '/admin/download', + displayName: 'Admin Download', + admin: true, + order: 1, + plugin: 'plugin', + }, + ]; const user = userEvent.setup(); render(, { diff --git a/src/mainAppBar/mobileOverflowMenu.component.tsx b/src/mainAppBar/mobileOverflowMenu.component.tsx index e6754cbc..5ef77b26 100644 --- a/src/mainAppBar/mobileOverflowMenu.component.tsx +++ b/src/mainAppBar/mobileOverflowMenu.component.tsx @@ -1,18 +1,18 @@ import React from 'react'; import { + Divider, + ListItemText, Menu, MenuItem, - ListItemText, MenuProps, - Divider, } from '@mui/material'; -import { SettingsMenuContent } from './settingsMenu.component'; +import { push } from 'connected-react-router'; import { useDispatch, useSelector } from 'react-redux'; +import { getAdminRoutes } from '../routing/routing.component'; +import { toggleHelp } from '../state/actions/scigateway.actions'; import { StateType } from '../state/state.types'; import { getAppStrings, getString } from '../state/strings'; -import { push } from 'connected-react-router'; -import { adminRoutes } from '../state/scigateway.types'; -import { toggleHelp } from '../state/actions/scigateway.actions'; +import { SettingsMenuContent } from './settingsMenu.component'; interface MobileOverflowMenuProps extends MenuProps { onClose: () => void; @@ -39,6 +39,9 @@ function MobileOverflowMenu({ (state: StateType) => state.scigateway.adminPageDefaultTab ); + const plugins = useSelector((state: StateType) => state.scigateway.plugins); + const adminRoutes = getAdminRoutes({ plugins }); + const dispatch = useDispatch(); function navigateToHelpPage(): void { @@ -46,7 +49,12 @@ function MobileOverflowMenu({ } function navigateToAdminPage(): void { - dispatch(push(adminRoutes[adminPageDefaultTab ?? 'maintenance'])); + const targetRoute = + adminPageDefaultTab && adminRoutes.hasOwnProperty(adminPageDefaultTab) + ? adminRoutes[adminPageDefaultTab] + : adminRoutes['maintenance']; + + dispatch(push(targetRoute)); } function toggleTutorial(): void { diff --git a/src/mainAppBar/pageLinks.component.tsx b/src/mainAppBar/pageLinks.component.tsx index b126ae96..7de64c33 100644 --- a/src/mainAppBar/pageLinks.component.tsx +++ b/src/mainAppBar/pageLinks.component.tsx @@ -1,12 +1,12 @@ +import React from 'react'; +import Button from '@mui/material/Button'; +import Typography from '@mui/material/Typography'; +import { push } from 'connected-react-router'; import { useDispatch, useSelector } from 'react-redux'; +import { getAdminRoutes } from '../routing/routing.component'; import { StateType } from '../state/state.types'; -import Button from '@mui/material/Button'; import { getAppStrings, getString } from '../state/strings'; -import Typography from '@mui/material/Typography'; -import React from 'react'; import { appBarIconButtonStyle } from './styles'; -import { push } from 'connected-react-router'; -import { adminRoutes } from '../state/scigateway.types'; function PageLinks(): JSX.Element { const shouldShowHelpPageButton = useSelector( @@ -20,6 +20,10 @@ function PageLinks(): JSX.Element { const adminPageDefaultTab = useSelector( (state: StateType) => state.scigateway.adminPageDefaultTab ); + + const plugins = useSelector((state: StateType) => state.scigateway.plugins); + const adminRoutes = getAdminRoutes({ plugins }); + const res = useSelector((state: StateType) => getAppStrings(state, 'main-appbar') ); @@ -31,7 +35,12 @@ function PageLinks(): JSX.Element { } function navigateToAdminPage(): void { - dispatch(push(adminRoutes[adminPageDefaultTab ?? 'maintenance'])); + const targetRoute = + adminPageDefaultTab && adminRoutes.hasOwnProperty(adminPageDefaultTab) + ? adminRoutes[adminPageDefaultTab] + : adminRoutes['maintenance']; + + dispatch(push(targetRoute)); } return ( diff --git a/src/navigationDrawer/__snapshots__/navigationDrawer.component.test.tsx.snap b/src/navigationDrawer/__snapshots__/navigationDrawer.component.test.tsx.snap index 2c1a085f..24334704 100644 --- a/src/navigationDrawer/__snapshots__/navigationDrawer.component.test.tsx.snap +++ b/src/navigationDrawer/__snapshots__/navigationDrawer.component.test.tsx.snap @@ -6,7 +6,7 @@ exports[`Navigation drawer component Navigation drawer renders correctly when cl class="MuiDrawer-root MuiDrawer-docked css-ak80xd-MuiDrawer-docked" >