diff --git a/src/__test__/components/ContentWrapper.test.jsx b/src/__test__/components/ContentWrapper.test.jsx index 08671a2d6d..6fffea04f4 100644 --- a/src/__test__/components/ContentWrapper.test.jsx +++ b/src/__test__/components/ContentWrapper.test.jsx @@ -36,7 +36,11 @@ jest.mock('next/router', () => ({ })); jest.mock('@aws-amplify/auth', () => ({ - currentAuthenticatedUser: jest.fn().mockImplementation(async () => true), + currentAuthenticatedUser: jest.fn().mockImplementation(async () => ({ + attributes: { + 'custom:agreed_terms': 'true', + }, + })), federatedSignIn: jest.fn(), })); @@ -124,7 +128,6 @@ describe('ContentWrapper', () => { await store.dispatch(updateExperimentInfo({ experimentId, experimentName, sampleIds })); }); - // PROBLEMATIC it('renders correctly', async () => { getBackendStatus.mockImplementation(() => () => ({ loading: false, diff --git a/src/__test__/components/data-management/SamplesTable.test.jsx b/src/__test__/components/data-management/SamplesTable.test.jsx index 90d83dc85a..4c3ef1cf9c 100644 --- a/src/__test__/components/data-management/SamplesTable.test.jsx +++ b/src/__test__/components/data-management/SamplesTable.test.jsx @@ -19,13 +19,19 @@ import thunk from 'redux-thunk'; import createTestComponentFactory from '__test__/test-utils/testComponentFactory'; import { loadExperiments, setActiveExperiment } from 'redux/actions/experiments'; -import loadEnvironment from 'redux/actions/networkResources/loadEnvironment'; +import loadDeploymentInfo from 'redux/actions/networkResources/loadDeploymentInfo'; import { loadSamples } from 'redux/actions/samples'; import mockDemoExperiments from '__test__/test-utils/mockData/mockDemoExperiments.json'; +import { loadUser } from 'redux/actions/user'; jest.mock('@aws-amplify/auth', () => ({ - currentAuthenticatedUser: jest.fn(() => Promise.resolve({ attributes: { name: 'mockUserName' } })), + currentAuthenticatedUser: jest.fn(() => Promise.resolve({ + attributes: { + name: 'mockUserName', + 'custom:agreed_terms': 'true', + }, + })), federatedSignIn: jest.fn(), })); @@ -103,7 +109,9 @@ describe('Samples table', () => { // Defaults to project with samples await storeState.dispatch(setActiveExperiment(experimentWithSamplesId)); - await storeState.dispatch(loadEnvironment('test')); + await storeState.dispatch(loadDeploymentInfo({ environment: 'test' })); + + await storeState.dispatch(loadUser()); }); it('Does not show prompt to upload datasets if samples are available', async () => { diff --git a/src/__test__/components/header/UserButton.test.jsx b/src/__test__/components/header/UserButton.test.jsx index 39fd2b6716..2a81fe8582 100644 --- a/src/__test__/components/header/UserButton.test.jsx +++ b/src/__test__/components/header/UserButton.test.jsx @@ -1,3 +1,5 @@ +import React from 'react'; + import { screen, render } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { act } from 'react-dom/test-utils'; @@ -6,12 +8,19 @@ import Auth from '@aws-amplify/auth'; import UserButton from 'components/header/UserButton'; import createTestComponentFactory from '__test__/test-utils/testComponentFactory'; +import { Provider } from 'react-redux'; +import { makeStore } from 'redux/store'; +import { loadUser } from 'redux/actions/user'; const UserButtonFactory = createTestComponentFactory(UserButton); -const renderUserButton = async () => { +const renderUserButton = async (store) => { await act(async () => { - render(UserButtonFactory({})); + render( + + {UserButtonFactory(store)} + , + ); }); }; @@ -26,18 +35,25 @@ const getLoginButton = () => { }; describe('UserButton', () => { + let store; + beforeEach(async () => { jest.clearAllMocks(); - Auth.currentAuthenticatedUser = jest.fn(() => Promise.resolve({ attributes: { name: userName } })); + store = makeStore(); + + Auth.currentAuthenticatedUser = jest.fn(() => Promise.resolve({ attributes: { name: userName, 'custom:agreed_terms': 'true' } })); Auth.signOut = jest.fn(() => { }); Auth.federatedSignIn = jest.fn(() => { }); + + store.dispatch(loadUser()); }); it('Shows sign in by default', async () => { Auth.currentAuthenticatedUser = jest.fn(() => Promise.resolve(null)); + store.dispatch(loadUser()); - await renderUserButton(); + await renderUserButton(store); expect(screen.getByText(/Sign in/i)).toBeInTheDocument(); }); @@ -45,13 +61,13 @@ describe('UserButton', () => { it('Shows the user initial for the ', async () => { const userInitial = getUserInitial(); - await renderUserButton(); + await renderUserButton(store); expect(screen.getByText(userInitial)).toBeInTheDocument(); }); it('Clicking on menu opens up the menu bar', async () => { - await renderUserButton(); + await renderUserButton(store); const button = getLoginButton(); diff --git a/src/__test__/pages/__snapshots__/_error.test.jsx.snap b/src/__test__/pages/__snapshots__/_error.test.jsx.snap index aa4ec46aab..dc8a953812 100644 --- a/src/__test__/pages/__snapshots__/_error.test.jsx.snap +++ b/src/__test__/pages/__snapshots__/_error.test.jsx.snap @@ -134,6 +134,7 @@ Array [ }, }, "networkResources": Object { + "domainName": "scp.biomage.net", "environment": "production", }, "samples": Object { @@ -143,6 +144,9 @@ Array [ "saving": false, }, }, + "user": Object { + "current": null, + }, }, ] `; @@ -281,6 +285,7 @@ Array [ }, }, "networkResources": Object { + "domainName": "scp.biomage.net", "environment": "staging", }, "samples": Object { @@ -290,6 +295,9 @@ Array [ "saving": false, }, }, + "user": Object { + "current": null, + }, }, ] `; diff --git a/src/__test__/pages/_error.test.jsx b/src/__test__/pages/_error.test.jsx index 8fba07c472..8358ea18f4 100644 --- a/src/__test__/pages/_error.test.jsx +++ b/src/__test__/pages/_error.test.jsx @@ -6,7 +6,8 @@ import { Provider } from 'react-redux'; import { makeStore } from 'redux/store'; import postErrorToSlack from 'utils/postErrorToSlack'; -import loadEnvironment from 'redux/actions/networkResources/loadEnvironment'; +import loadDeploymentInfo from 'redux/actions/networkResources/loadDeploymentInfo'; +import { DomainName } from 'utils/deploymentInfo'; import createTestComponentFactory from '__test__/test-utils/testComponentFactory'; @@ -36,7 +37,7 @@ describe('ErrorPage', () => { beforeEach(() => { jest.clearAllMocks(); storeState = makeStore(); - storeState.dispatch(loadEnvironment('production')); + storeState.dispatch(loadDeploymentInfo({ environment: 'production', domainName: DomainName.BIOMAGE })); }); it('Renders properly without props', () => { @@ -70,7 +71,7 @@ describe('ErrorPage', () => { }); it('Should post error to Slack if environment is production', () => { - storeState.dispatch(loadEnvironment('production')); + storeState.dispatch(loadDeploymentInfo({ environment: 'production', domainName: DomainName.BIOMAGE })); renderErrorPage(mockErrorProp, storeState); @@ -79,7 +80,7 @@ describe('ErrorPage', () => { }); it('Should post error to Slack if environment is staging', () => { - storeState.dispatch(loadEnvironment('staging')); + storeState.dispatch(loadDeploymentInfo({ environment: 'staging', domainName: DomainName.BIOMAGE })); renderErrorPage(mockErrorProp, storeState); @@ -88,7 +89,7 @@ describe('ErrorPage', () => { }); it('Should not post error to Slack if environment is not production', () => { - storeState.dispatch(loadEnvironment('development')); + storeState.dispatch(loadDeploymentInfo({ environment: 'development', domainName: DomainName.BIOMAGE })); renderErrorPage(mockErrorProp, storeState); diff --git a/src/__test__/pages/experiments/[experimentId]/data-management/index.test.jsx b/src/__test__/pages/experiments/[experimentId]/data-management/index.test.jsx index 412dba3f34..c861cc691b 100644 --- a/src/__test__/pages/experiments/[experimentId]/data-management/index.test.jsx +++ b/src/__test__/pages/experiments/[experimentId]/data-management/index.test.jsx @@ -1,7 +1,7 @@ import React from 'react'; import _ from 'lodash'; -import { render, screen, waitFor } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import { act } from 'react-dom/test-utils'; import fetchMock, { enableFetchMocks } from 'jest-fetch-mock'; import { Provider } from 'react-redux'; @@ -20,7 +20,8 @@ import DataManagementPage from 'pages/data-management'; import userEvent from '@testing-library/user-event'; import { setActiveExperiment } from 'redux/actions/experiments'; -import loadEnvironment from 'redux/actions/networkResources/loadEnvironment'; +import loadDeploymentInfo from 'redux/actions/networkResources/loadDeploymentInfo'; +import { loadUser } from 'redux/actions/user'; jest.mock('utils/data-management/downloadFromUrl'); jest.mock('react-resize-detector', () => (props) => props.children({ width: 100, height: 100 })); @@ -34,7 +35,12 @@ jest.mock('utils/AppRouteProvider', () => ({ })); jest.mock('@aws-amplify/auth', () => ({ - currentAuthenticatedUser: jest.fn(() => Promise.resolve({ attributes: { name: 'mockUserName' } })), + currentAuthenticatedUser: jest.fn(() => Promise.resolve({ + attributes: { + name: 'mockUserName', + 'custom:agreed_terms': 'true', + }, + })), federatedSignIn: jest.fn(), })); @@ -53,11 +59,6 @@ const experimentWithoutSamples = experiments.find( (experiment) => experiment.samplesOrder.length === 0, ); -const expectedSampleNames = [ - 'Example 1', - 'Another-Example no.2', -]; - const experimentWithSamplesId = experimentWithSamples.id; const experimentWithoutSamplesId = experimentWithoutSamples.id; @@ -79,7 +80,8 @@ describe('Data Management page', () => { fetchMock.mockIf(/.*/, mockAPI(mockAPIResponse)); storeState = makeStore(); - storeState.dispatch(loadEnvironment('test')); + storeState.dispatch(loadDeploymentInfo({ environment: 'test' })); + storeState.dispatch(loadUser()); }); it('Shows an empty project list if there are no projects', async () => { diff --git a/src/__test__/pages/settings/profile/index.test.jsx b/src/__test__/pages/settings/profile/index.test.jsx index da30703dee..9e00417160 100644 --- a/src/__test__/pages/settings/profile/index.test.jsx +++ b/src/__test__/pages/settings/profile/index.test.jsx @@ -4,10 +4,15 @@ import { render, screen } from '@testing-library/react'; import { act } from 'react-dom/test-utils'; import userEvent from '@testing-library/user-event'; import { useRouter } from 'next/router'; +import { makeStore } from 'redux/store'; import createTestComponentFactory from '__test__/test-utils/testComponentFactory'; import Auth from '@aws-amplify/auth'; import ProfileSettings from 'pages/settings/profile'; import pushNotificationMessage from 'utils/pushNotificationMessage'; +import { Provider } from 'react-redux'; + +import { loadUser } from 'redux/actions/user'; +import loadDeploymentInfo from 'redux/actions/networkResources/loadDeploymentInfo'; jest.mock('next/router', () => ({ useRouter: jest.fn(), @@ -16,19 +21,43 @@ jest.mock('next/router', () => ({ jest.mock('@aws-amplify/auth', () => jest.fn()); jest.mock('utils/pushNotificationMessage'); -const profileSettingsPageFactory = createTestComponentFactory(ProfileSettings); const updateMock = jest.fn(() => Promise.resolve(true)); +const profileSettingsPageFactory = createTestComponentFactory(ProfileSettings); + +const renderProfileSettingsPage = (store, newState = {}) => { + render( + + {profileSettingsPageFactory(newState)} + , + ); +}; + +const setUpAuthMocks = () => { + Auth.currentAuthenticatedUser = jest.fn(() => Promise.resolve({ + attributes: { + name: userName, + 'custom:agreed_terms': 'true', + }, + })); + Auth.signOut = jest.fn(() => { }); + Auth.federatedSignIn = jest.fn(() => { }); + Auth.updateUserAttributes = updateMock; +}; + const userName = 'Arthur Dent'; jest.mock('components/Header', () => () => <>); describe('Profile page', () => { + const store = makeStore(); + beforeEach(async () => { jest.clearAllMocks(); - Auth.currentAuthenticatedUser = jest.fn(() => Promise.resolve({ attributes: { name: userName } })); - Auth.signOut = jest.fn(() => { }); - Auth.federatedSignIn = jest.fn(() => { }); - Auth.updateUserAttributes = updateMock; + + setUpAuthMocks(); + + store.dispatch(loadDeploymentInfo({ environment: 'test' })); + store.dispatch(loadUser()); }); it('check that the back button is called on cancel', async () => { @@ -38,9 +67,7 @@ describe('Profile page', () => { })); await act(async () => { - render( - profileSettingsPageFactory(), - ); + renderProfileSettingsPage(store); }); await act(async () => { @@ -52,9 +79,7 @@ describe('Profile page', () => { it('check update is called on Save changes', async () => { await act(async () => { - render( - profileSettingsPageFactory(), - ); + renderProfileSettingsPage(store); }); const nameInput = screen.getByPlaceholderText(userName); diff --git a/src/__test__/redux/actions/experiments/__snapshots__/switchExperiment.test.js.snap b/src/__test__/redux/actions/experiments/__snapshots__/switchExperiment.test.js.snap index 02b43dc3d0..e6ef334ab4 100644 --- a/src/__test__/redux/actions/experiments/__snapshots__/switchExperiment.test.js.snap +++ b/src/__test__/redux/actions/experiments/__snapshots__/switchExperiment.test.js.snap @@ -183,6 +183,7 @@ Object { }, }, "networkResources": Object { + "domainName": undefined, "environment": undefined, }, "samples": Object { @@ -321,6 +322,9 @@ Object { "uuid": "test9188-d682-test-mock-cb6d644cmock-2", }, }, + "user": Object { + "current": null, + }, } `; @@ -507,6 +511,7 @@ Object { }, }, "networkResources": Object { + "domainName": undefined, "environment": undefined, }, "samples": Object { @@ -645,5 +650,8 @@ Object { "uuid": "test9188-d682-test-mock-cb6d644cmock-2", }, }, + "user": Object { + "current": null, + }, } `; diff --git a/src/__test__/utils/deploymentInfo.test.js b/src/__test__/utils/deploymentInfo.test.js new file mode 100644 index 0000000000..e1b4181478 --- /dev/null +++ b/src/__test__/utils/deploymentInfo.test.js @@ -0,0 +1,100 @@ +import { + ssrGetDeploymentInfo, DomainName, privacyPolicyIsNotAccepted, Environment, +} from 'utils/deploymentInfo'; + +describe('deploymentInfo', () => { + describe('privacyPolicyIsNotAccepted', () => { + it('Returns false for users that accepted privacy policy', () => { + const user = { attributes: { 'custom:agreed_terms': 'true' } }; + const domainName = DomainName.BIOMAGE; + + expect(privacyPolicyIsNotAccepted(user, domainName)).toEqual(false); + }); + + it('Returns false for users that arent in Biomage deployment', () => { + const user = { attributes: {} }; + const domainName = 'Someotherdomain.com'; + + expect(privacyPolicyIsNotAccepted(user, domainName)).toEqual(false); + }); + + it('Returns true for users that still need to accept terms in Biomage', () => { + const user = { attributes: {} }; + const domainName = DomainName.BIOMAGE; + + expect(privacyPolicyIsNotAccepted(user, domainName)).toEqual(true); + }); + + it('Returns true for users that still need to accept terms in Biomage staging', () => { + const user = { attributes: {} }; + const domainName = DomainName.BIOMAGE_STAGING; + + expect(privacyPolicyIsNotAccepted(user, domainName)).toEqual(true); + }); + }); + + describe('ssrGetDeploymentInfo', () => { + let originalEnv; + + // We are going to mess with the process env so save the original to avoid leak into other tests + beforeAll(() => { + originalEnv = { ...process.env }; + }); + + afterAll(() => { + process.env = originalEnv; + }); + + it('Throws if not called in server side', () => { + process.env = undefined; + + expect(ssrGetDeploymentInfo).toThrowError( + 'ssrGetDeploymentInfo must be called on the server side. Refer to `store.networkResources.environment` for the actual environment.', + ); + }); + + it('Works with test node env', () => { + process.env = { NODE_ENV: 'test' }; + + expect(ssrGetDeploymentInfo()).toEqual({ + environment: Environment.DEVELOPMENT, + domainName: DomainName.BIOMAGE, + }); + }); + + it('Works with prod k8s env in biomage domain', () => { + process.env = { + NODE_ENV: Environment.PRODUCTION, + K8S_ENV: Environment.PRODUCTION, + DOMAIN_NAME: DomainName.BIOMAGE, + }; + + expect(ssrGetDeploymentInfo()).toEqual({ + environment: Environment.PRODUCTION, + domainName: DomainName.BIOMAGE, + }); + }); + + it('Works with staging k8s env in biomage staging domain', () => { + process.env = { + NODE_ENV: Environment.PRODUCTION, + K8S_ENV: Environment.STAGING, + DOMAIN_NAME: DomainName.BIOMAGE_STAGING, + }; + + expect(ssrGetDeploymentInfo()).toEqual({ + environment: Environment.STAGING, + domainName: DomainName.BIOMAGE_STAGING, + }); + }); + + it('Works in development', () => { + process.env = { NODE_ENV: Environment.DEVELOPMENT }; + + expect(ssrGetDeploymentInfo()).toEqual({ + environment: Environment.DEVELOPMENT, + domainName: DomainName.BIOMAGE, + }); + }); + }); +}); diff --git a/src/__test__/utils/work/fetchWork.test.js b/src/__test__/utils/work/fetchWork.test.js index 1b4cab5745..63b04e58da 100644 --- a/src/__test__/utils/work/fetchWork.test.js +++ b/src/__test__/utils/work/fetchWork.test.js @@ -1,6 +1,6 @@ /* eslint-disable global-require */ import { fetchWork } from 'utils/work/fetchWork'; -import Environment from 'utils/environment'; +import { Environment } from 'utils/deploymentInfo'; const { mockGeneExpressionData, diff --git a/src/components/ContentWrapper.jsx b/src/components/ContentWrapper.jsx index fbdd09ff09..76dd869997 100644 --- a/src/components/ContentWrapper.jsx +++ b/src/components/ContentWrapper.jsx @@ -17,8 +17,6 @@ import { } from 'antd'; import { modules } from 'utils/constants'; -import Auth from '@aws-amplify/auth'; - import { useAppRouter } from 'utils/AppRouteProvider'; import calculateGem2sRerunStatus from 'utils/data-management/calculateGem2sRerunStatus'; @@ -29,22 +27,22 @@ import PreloadContent from 'components/PreloadContent'; import experimentUpdatesHandler from 'utils/experimentUpdatesHandler'; import { getBackendStatus } from 'redux/selectors'; import { loadBackendStatus } from 'redux/actions/backendStatus'; -import { isBrowser } from 'utils/environment'; +import { isBrowser, privacyPolicyIsNotAccepted } from 'utils/deploymentInfo'; import Error from 'pages/_error'; import integrationTestConstants from 'utils/integrationTestConstants'; import pipelineStatus from 'utils/pipelineStatusValues'; import BrowserAlert from 'components/BrowserAlert'; +import { loadUser } from 'redux/actions/user'; +import PrivacyPolicyIntercept from './data-management/PrivacyPolicyIntercept'; -const { Sider, Footer } = Layout; - -const { Paragraph, Text } = Typography; +const { Sider } = Layout; +const { Text } = Typography; const ContentWrapper = (props) => { const dispatch = useDispatch(); - const [isAuth, setIsAuth] = useState(false); const [collapsed, setCollapsed] = useState(false); const { routeExperimentId, experimentData, children } = props; @@ -54,6 +52,9 @@ const ContentWrapper = (props) => { const activeExperimentId = useSelector((state) => state?.experiments?.meta?.activeExperimentId); const activeExperiment = useSelector((state) => state.experiments[activeExperimentId]); + const domainName = useSelector((state) => state.networkResources.domainName); + const user = useSelector((state) => state.user.current); + const samples = useSelector((state) => state.samples); useEffect(() => { @@ -138,15 +139,10 @@ const ContentWrapper = (props) => { }, [gem2sBackendStatus, activeExperiment, samples, experiment]); useEffect(() => { - Auth.currentAuthenticatedUser() - .then(() => setIsAuth(true)) - .catch(() => { - setIsAuth(false); - Auth.federatedSignIn(); - }); + dispatch(loadUser()); }, []); - if (!isAuth) return <>; + if (!user) return <>; const BigLogo = () => (
{ ); }; + + if (!user) return <>; + return ( <> + {privacyPolicyIsNotAccepted(user, domainName) && ( + dispatch(loadUser())} /> + )} { const dispatch = useDispatch(); - const environment = useSelector((state) => state?.networkResources?.environment); + const { + environment = undefined, domainName = undefined, + } = useSelector((state) => state?.networkResources ?? {}); + + const user = useSelector((state) => state?.user?.current); const [exampleExperiments, setExampleExperiments] = useState([]); useEffect(() => { - if (!environment) return; + if (!environment || privacyPolicyIsNotAccepted(user, domainName)) return; fetchAPI('/v2/experiments/examples').then((experiments) => { setExampleExperiments(experiments); }).catch(() => { }); - }, [environment]); + }, [environment, user]); const cloneIntoCurrentExperiment = async (exampleExperimentId) => { const url = `/v2/experiments/${exampleExperimentId}/clone`; diff --git a/src/components/data-management/PrivacyPolicyIntercept.css b/src/components/data-management/PrivacyPolicyIntercept.css new file mode 100644 index 0000000000..5103d2fd38 --- /dev/null +++ b/src/components/data-management/PrivacyPolicyIntercept.css @@ -0,0 +1,4 @@ +.ok-to-the-right-modal .ant-modal-confirm-btns .ant-btn { + float: right; + margin-left: 10px; +} \ No newline at end of file diff --git a/src/components/data-management/PrivacyPolicyIntercept.jsx b/src/components/data-management/PrivacyPolicyIntercept.jsx new file mode 100644 index 0000000000..b1c0116434 --- /dev/null +++ b/src/components/data-management/PrivacyPolicyIntercept.jsx @@ -0,0 +1,99 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; + +import Auth from '@aws-amplify/auth'; + +import { + Modal, Space, Checkbox, Typography, +} from 'antd'; + +import 'components/data-management/PrivacyPolicyIntercept.css'; + +import pushNotificationMessage from 'utils/pushNotificationMessage'; +import endUserMessages from 'utils/endUserMessages'; + +const { Text } = Typography; + +const agreedPrivacyPolicyKey = 'custom:agreed_terms'; +const agreedEmailsKey = 'custom:agreed_emails'; + +const PrivacyPolicyIntercept = (props) => { + const { user, onOk } = props; + + const { + attributes: { + [agreedPrivacyPolicyKey]: originalAgreedPrivacyPolicy, + [agreedEmailsKey]: originalAgreedEmails, + }, + } = user; + + const [agreedPrivacyPolicy, setAgreedPrivacyPolicy] = useState(originalAgreedPrivacyPolicy); + const [agreedEmails, setAgreedEmails] = useState(originalAgreedEmails ?? 'false'); + + const privacyPolicyUrl = 'https://static1.squarespace.com/static/5f355513fc75aa471d47455c/t/62d67b8cbd2d7f3177d91f83/1658223501108/Privacy+Policy_July+2022.pdf'; + + return ( + { + await Auth.updateUserAttributes( + user, + { + [agreedPrivacyPolicyKey]: agreedPrivacyPolicy, + [agreedEmailsKey]: agreedEmails, + }, + ) + .then(() => { + pushNotificationMessage('success', endUserMessages.ACCOUNT_DETAILS_UPDATED, 3); + onOk(); + }) + .catch(() => pushNotificationMessage('error', endUserMessages.ERROR_SAVING, 3)); + }} + onCancel={async () => Auth.signOut()} + > + + + setAgreedPrivacyPolicy(e.target.checked.toString())} + /> + + * + I accept the terms of the + {' '} + Biomage privacy policy + . + + + + setAgreedEmails(e.target.checked.toString())} + style={{ marginRight: 10 }} + /> + + I agree to receive updates about new features in Cellenics, + research done with Cellenics, and Cellenics community events. (No external marketing.) + + + + + ); +}; + +PrivacyPolicyIntercept.propTypes = { + user: PropTypes.object.isRequired, + onOk: PropTypes.func.isRequired, +}; + +PrivacyPolicyIntercept.defaultProps = {}; + +export default PrivacyPolicyIntercept; diff --git a/src/components/header/UserButton.jsx b/src/components/header/UserButton.jsx index a64c307f7e..f31793940c 100644 --- a/src/components/header/UserButton.jsx +++ b/src/components/header/UserButton.jsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect } from 'react'; import { Avatar, Button, @@ -11,24 +11,23 @@ import Auth from '@aws-amplify/auth'; import endUserMessages from 'utils/endUserMessages'; import { resetTrackingId } from 'utils/tracking'; import handleError from 'utils/http/handleError'; +import { loadUser } from 'redux/actions/user'; +import { useDispatch, useSelector } from 'react-redux'; const UserButton = () => { - const [user, setUser] = useState(); + const dispatch = useDispatch(); - const getUser = () => Auth.currentAuthenticatedUser() - .then((userData) => userData) - .catch((e) => console.log('error during getuser', e)); + const user = useSelector((state) => state.user.current); useEffect(() => { Hub.listen('auth', ({ payload: { event } }) => { switch (event) { case 'signIn': case 'cognitoHostedUI': - getUser().then((userData) => setUser(userData)); + dispatch(loadUser()); break; case 'signOut': resetTrackingId(); - setUser(null); break; case 'signIn_failure': case 'cognitoHostedUI_failure': @@ -38,13 +37,11 @@ const UserButton = () => { break; } }); - - getUser().then((userData) => setUser(userData)); }, []); const content = () => ( - + Your profile diff --git a/src/pages/404.jsx b/src/pages/404.jsx index 78026e6120..d97f563544 100644 --- a/src/pages/404.jsx +++ b/src/pages/404.jsx @@ -5,7 +5,9 @@ import FeedbackButton from 'components/header/FeedbackButton'; const { Title } = Typography; -const NotFoundPage = ({ title, subTitle, hint }) => ( +const NotFoundPage = ({ + title, subTitle, hint, primaryActionText, +}) => ( {title}} icon={( @@ -38,7 +40,7 @@ const NotFoundPage = ({ title, subTitle, hint }) => ( extra={( <> @@ -50,12 +52,14 @@ NotFoundPage.defaultProps = { hint: '', title: 'Page not found', subTitle: 'We can\'t seem to find the page you\'re looking for.', + primaryActionText: 'Go home', }; NotFoundPage.propTypes = { title: PropTypes.string, subTitle: PropTypes.string, hint: PropTypes.string, + primaryActionText: PropTypes.string, }; export default NotFoundPage; diff --git a/src/pages/_app.jsx b/src/pages/_app.jsx index 9ba69a9652..ba6b013e6e 100644 --- a/src/pages/_app.jsx +++ b/src/pages/_app.jsx @@ -111,6 +111,17 @@ const WrappedApp = ({ Component, pageProps }) => { ); } + if (httpError === 424) { + return ( + + ); + } + if (httpError === 401) { return ( { const dispatch = useDispatch(); @@ -23,6 +24,8 @@ const DataManagementPage = () => { const { activeExperimentId } = useSelector((state) => state.experiments.meta); const experiments = useSelector(((state) => state.experiments)); + const user = useSelector((state) => state.user.current); + const domainName = useSelector((state) => state.networkResources?.domainName); const activeExperiment = experiments[activeExperimentId]; const { saving: experimentsSaving } = experiments.meta; @@ -31,8 +34,10 @@ const DataManagementPage = () => { const [newProjectModalVisible, setNewProjectModalVisible] = useState(false); useEffect(() => { + if (privacyPolicyIsNotAccepted(user, domainName)) return; + if (experiments.ids.length === 0) dispatch(loadExperiments()); - }, []); + }, [user]); const samplesAreLoaded = () => { const loadedSampleIds = Object.keys(samples); @@ -40,14 +45,14 @@ const DataManagementPage = () => { }; useEffect(() => { - if (!activeExperimentId) return; + if (!activeExperimentId || privacyPolicyIsNotAccepted(user, domainName)) return; dispatch(loadProcessingSettings(activeExperimentId)); if (!samplesAreLoaded()) dispatch(loadSamples(activeExperimentId)); dispatch(loadBackendStatus(activeExperimentId)); - }, [activeExperimentId]); + }, [activeExperimentId, user]); const PROJECTS_LIST = 'Projects'; const PROJECT_DETAILS = 'Project Details'; diff --git a/src/pages/settings/profile/index.jsx b/src/pages/settings/profile/index.jsx index 29fef63b96..ab2719d3c5 100644 --- a/src/pages/settings/profile/index.jsx +++ b/src/pages/settings/profile/index.jsx @@ -1,19 +1,26 @@ -import React, { useEffect, useState } from 'react'; +import React, { useState } from 'react'; import Auth from '@aws-amplify/auth'; import _ from 'lodash'; import { - Form, Input, Empty, Row, Col, Button, Space, + Form, Input, Empty, Row, Col, Button, Space, Checkbox, Typography, } from 'antd'; import { useRouter } from 'next/router'; import Header from 'components/Header'; import endUserMessages from 'utils/endUserMessages'; import pushNotificationMessage from 'utils/pushNotificationMessage'; import handleError from 'utils/http/handleError'; +import { useSelector, useDispatch } from 'react-redux'; +import { loadUser } from 'redux/actions/user'; + +const { Text } = Typography; const ProfileSettings = () => { const router = useRouter(); - const [user, setUser] = useState(); + const dispatch = useDispatch(); + + const user = useSelector((state) => state.user.current); + const [oldPasswordError, setOldPasswordError] = useState(null); const [newPasswordError, setNewPasswordError] = useState(null); const [emailError, setEmailError] = useState(null); @@ -31,24 +38,20 @@ const ProfileSettings = () => { setNewAttributes(newChanges); }; - const currentUser = () => Auth.currentAuthenticatedUser() - .then((userData) => setUser(userData)) - .catch((e) => console.log('error during getuser', e)); - - useEffect(() => { - currentUser(); - }, []); + const agreedEmailsKey = 'custom:agreed_emails'; const updateDetails = async () => { const { name, email } = changedUserAttributes; const { oldPassword, newPassword, confirmNewPassword } = changedPasswordAttributes; const invalidPasswordErrors = ['InvalidPasswordException', 'InvalidParameterException', 'NotAuthorizedException']; - if (name || email) { + if (name || email || changedUserAttributes[agreedEmailsKey]) { setEmailError(false); await Auth.updateUserAttributes(user, changedUserAttributes) .then(() => pushNotificationMessage('success', endUserMessages.ACCOUNT_DETAILS_UPDATED, 3)) - .catch(() => setEmailError(true)); + .catch(() => { + setEmailError(true); + }); } if (oldPassword || newPassword || confirmNewPassword) { setOldPasswordError(false); @@ -75,7 +78,8 @@ const ProfileSettings = () => { }); } } - currentUser(); + + dispatch(loadUser()); setChanges(initialState); }; @@ -119,6 +123,21 @@ const ProfileSettings = () => { + + + setChanges({ + changedUserAttributes: { [agreedEmailsKey]: e.target.checked.toString() }, + })} + /> + + I agree to receive updates about new features in Cellenics, research done with Cellenics, and Cellenics community events. (No external marketing.) + + +

Password settings:

(dispatch) => { + dispatch({ + type: NETWORK_RESOURCES_DEPLOYMENT_INFO_LOADED, + payload: { + environment, + domainName, + }, + }); +}; + +export default loadDeploymentInfo; diff --git a/src/redux/actions/networkResources/loadEnvironment.js b/src/redux/actions/networkResources/loadEnvironment.js deleted file mode 100644 index 702c70aafd..0000000000 --- a/src/redux/actions/networkResources/loadEnvironment.js +++ /dev/null @@ -1,12 +0,0 @@ -import { NETWORK_RESOURCES_LOAD_ENVIRONMENT } from '../../actionTypes/networkResources'; - -const loadEnvironment = (environment) => (dispatch) => { - dispatch({ - type: NETWORK_RESOURCES_LOAD_ENVIRONMENT, - payload: { - environment, - }, - }); -}; - -export default loadEnvironment; diff --git a/src/redux/actions/user/index.js b/src/redux/actions/user/index.js new file mode 100644 index 0000000000..1fa37f482b --- /dev/null +++ b/src/redux/actions/user/index.js @@ -0,0 +1,6 @@ +import loadUser from './loadUser'; + +export { + // eslint-disable-next-line import/prefer-default-export + loadUser, +}; diff --git a/src/redux/actions/user/loadUser.js b/src/redux/actions/user/loadUser.js new file mode 100644 index 0000000000..f7847b3318 --- /dev/null +++ b/src/redux/actions/user/loadUser.js @@ -0,0 +1,17 @@ +import Auth from '@aws-amplify/auth'; +import { USER_LOADED } from 'redux/actionTypes/user'; + +const loadUser = () => async (dispatch) => { + try { + const user = await Auth.currentAuthenticatedUser(); + + dispatch({ + type: USER_LOADED, + payload: { user }, + }); + } catch (e) { + Auth.federatedSignIn(); + } +}; + +export default loadUser; diff --git a/src/redux/reducers/index.js b/src/redux/reducers/index.js index 40fed75593..5833023222 100644 --- a/src/redux/reducers/index.js +++ b/src/redux/reducers/index.js @@ -11,8 +11,9 @@ import experimentSettingsReducer from './experimentSettings'; import genesReducer from './genes'; import layoutReducer from './layout'; import sampleReducer from './samples'; -import networkResourcesReducer from './networkResources'; import backendStatusReducer from './backendStatus'; +import networkResourcesReducer from './networkResources'; +import userReducer from './user'; import { EXPERIMENTS_SWITCH } from '../actionTypes/experiments'; const appReducers = combineReducers({ @@ -29,6 +30,7 @@ const appReducers = combineReducers({ layout: layoutReducer, samples: sampleReducer, networkResources: networkResourcesReducer, + user: userReducer, }); const rootReducer = (state, action) => { @@ -36,13 +38,14 @@ const rootReducer = (state, action) => { if (action.type === EXPERIMENTS_SWITCH) { // we need to keep the old state for these parts of the store newState = { - networkResources: state.networkResources, samples: state.samples, - projects: state.projects, backendStatus: state.backendStatus, experiments: state.experiments, + networkResources: state.networkResources, + user: state.user, }; } + return appReducers(newState, action); }; diff --git a/src/redux/reducers/networkResources/deploymentInfoLoaded.js b/src/redux/reducers/networkResources/deploymentInfoLoaded.js new file mode 100644 index 0000000000..a3a15e20ae --- /dev/null +++ b/src/redux/reducers/networkResources/deploymentInfoLoaded.js @@ -0,0 +1,11 @@ +/* eslint-disable no-param-reassign */ +import produce from 'immer'; + +const deploymentInfoLoaded = produce((draft, action) => { + const { environment, domainName } = action.payload; + + draft.environment = environment; + draft.domainName = domainName; +}); + +export default deploymentInfoLoaded; diff --git a/src/redux/reducers/networkResources/environmentHydrate.js b/src/redux/reducers/networkResources/environmentHydrate.js index a3f5a0788d..76f676ac0b 100644 --- a/src/redux/reducers/networkResources/environmentHydrate.js +++ b/src/redux/reducers/networkResources/environmentHydrate.js @@ -3,8 +3,9 @@ import produce from 'immer'; import initialState from './initialState'; const environmentHydrate = produce((draft, action) => { - const { environment } = action.payload.networkResources; + const { environment, domainName } = action.payload.networkResources; draft.environment = environment; + draft.domainName = domainName; }, initialState); export default environmentHydrate; diff --git a/src/redux/reducers/networkResources/index.js b/src/redux/reducers/networkResources/index.js index ac35169890..5087923248 100644 --- a/src/redux/reducers/networkResources/index.js +++ b/src/redux/reducers/networkResources/index.js @@ -1,16 +1,16 @@ import { HYDRATE } from 'next-redux-wrapper'; import { - NETWORK_RESOURCES_LOAD_ENVIRONMENT, + NETWORK_RESOURCES_DEPLOYMENT_INFO_LOADED, } from '../../actionTypes/networkResources'; import initialState from './initialState'; -import loadEnvironment from './loadEnvironment'; +import deploymentInfoLoaded from './deploymentInfoLoaded'; import environmentHydrate from './environmentHydrate'; const networkResourcesReducer = (state = initialState, action) => { switch (action.type) { - case NETWORK_RESOURCES_LOAD_ENVIRONMENT: { - return loadEnvironment(state, action); + case NETWORK_RESOURCES_DEPLOYMENT_INFO_LOADED: { + return deploymentInfoLoaded(state, action); } case HYDRATE: { diff --git a/src/redux/reducers/networkResources/initialState.js b/src/redux/reducers/networkResources/initialState.js index 7ad5eca813..749905099b 100644 --- a/src/redux/reducers/networkResources/initialState.js +++ b/src/redux/reducers/networkResources/initialState.js @@ -1,5 +1,6 @@ const initialState = { environment: undefined, + domainName: undefined, }; export default initialState; diff --git a/src/redux/reducers/networkResources/loadEnvironment.js b/src/redux/reducers/networkResources/loadEnvironment.js deleted file mode 100644 index e64a719964..0000000000 --- a/src/redux/reducers/networkResources/loadEnvironment.js +++ /dev/null @@ -1,9 +0,0 @@ -/* eslint-disable no-param-reassign */ -import produce from 'immer'; - -const loadEnvironment = produce((draft, action) => { - const { environment } = action.payload; - draft.environment = environment; -}); - -export default loadEnvironment; diff --git a/src/redux/reducers/user/index.js b/src/redux/reducers/user/index.js new file mode 100644 index 0000000000..3ee7a8952d --- /dev/null +++ b/src/redux/reducers/user/index.js @@ -0,0 +1,20 @@ +import { + USER_LOADED, +} from 'redux/actionTypes/user'; + +import initialState from 'redux/reducers/user/initialState'; +import userLoaded from 'redux/reducers/user/userLoaded'; + +const userReducer = (state = initialState, action) => { + switch (action.type) { + case USER_LOADED: { + return userLoaded(state, action); + } + + default: { + return state; + } + } +}; + +export default userReducer; diff --git a/src/redux/reducers/user/initialState.js b/src/redux/reducers/user/initialState.js new file mode 100644 index 0000000000..4ca54b3c68 --- /dev/null +++ b/src/redux/reducers/user/initialState.js @@ -0,0 +1,5 @@ +const initialState = { + current: null, +}; + +export default initialState; diff --git a/src/redux/reducers/user/userLoaded.js b/src/redux/reducers/user/userLoaded.js new file mode 100644 index 0000000000..584aac5198 --- /dev/null +++ b/src/redux/reducers/user/userLoaded.js @@ -0,0 +1,9 @@ +/* eslint-disable no-param-reassign */ +import produce from 'immer'; + +const userLoaded = produce((draft, action) => { + const { user } = action.payload; + draft.current = user; +}); + +export default userLoaded; diff --git a/src/utils/cache.js b/src/utils/cache.js index 4c4e28ae37..35d0748f6d 100644 --- a/src/utils/cache.js +++ b/src/utils/cache.js @@ -2,7 +2,7 @@ /* eslint-disable no-param-reassign */ /* eslint-disable max-classes-per-file */ import * as localForage from 'localforage'; -import { isBrowser } from './environment'; +import { isBrowser } from './deploymentInfo'; const currentCacheVersion = 'biomage_0.0.1'; const previousCacheVersions = ['biomage']; diff --git a/src/utils/deploymentInfo.js b/src/utils/deploymentInfo.js new file mode 100644 index 0000000000..96761e20bc --- /dev/null +++ b/src/utils/deploymentInfo.js @@ -0,0 +1,51 @@ +const isBrowser = typeof window !== 'undefined' && typeof window.document !== 'undefined'; + +const privacyPolicyIsNotAccepted = (user, domainName) => ( + user?.attributes['custom:agreed_terms'] !== 'true' + && (domainName === DomainName.BIOMAGE || domainName === DomainName.BIOMAGE_STAGING) +); + +const Environment = { + DEVELOPMENT: 'development', + STAGING: 'staging', + PRODUCTION: 'production', +}; + +const DomainName = { + BIOMAGE: 'scp.biomage.net', + BIOMAGE_STAGING: 'scp-staging.biomage.net', +}; + +const ssrGetDeploymentInfo = () => { + let currentEnvironment = null; + + if (!process.env) { + throw new Error('ssrGetDeploymentInfo must be called on the server side. Refer to `store.networkResources.environment` for the actual environment.'); + } + + if (process.env.NODE_ENV === 'test') { + return { environment: Environment.DEVELOPMENT, domainName: DomainName.BIOMAGE }; + } + + switch (process.env.K8S_ENV) { + case 'production': + currentEnvironment = Environment.PRODUCTION; + break; + case 'staging': + currentEnvironment = Environment.STAGING; + break; + default: + currentEnvironment = Environment.DEVELOPMENT; + break; + } + + const domainName = currentEnvironment !== Environment.DEVELOPMENT + ? process.env.DOMAIN_NAME + : DomainName.BIOMAGE; + + return { environment: currentEnvironment, domainName }; +}; + +export { + isBrowser, ssrGetDeploymentInfo, DomainName, Environment, privacyPolicyIsNotAccepted, +}; diff --git a/src/utils/environment.js b/src/utils/environment.js deleted file mode 100644 index 7472035510..0000000000 --- a/src/utils/environment.js +++ /dev/null @@ -1,36 +0,0 @@ -const isBrowser = typeof window !== 'undefined' && typeof window.document !== 'undefined'; - -const Environment = { - DEVELOPMENT: 'development', - STAGING: 'staging', - PRODUCTION: 'production', -}; - -const ssrGetCurrentEnvironment = () => { - let currentEnvironment = null; - - if (process.env.NODE_ENV === 'test') { - return Environment.DEVELOPMENT; - } - - if (!process.env) { - throw new Error('ssrGetCurrentEnvironment must be called on the server side. Refer to `store.networkResources.environment` for the actual environment.'); - } - - switch (process.env.K8S_ENV) { - case 'production': - currentEnvironment = Environment.PRODUCTION; - break; - case 'staging': - currentEnvironment = Environment.STAGING; - break; - default: - currentEnvironment = Environment.DEVELOPMENT; - break; - } - - return currentEnvironment; -}; - -export { isBrowser, ssrGetCurrentEnvironment }; -export default Environment; diff --git a/src/utils/http/handleError.js b/src/utils/http/handleError.js index 40de6891e1..47df2903f9 100644 --- a/src/utils/http/handleError.js +++ b/src/utils/http/handleError.js @@ -1,11 +1,11 @@ -import Environment, { ssrGetCurrentEnvironment } from 'utils/environment'; +import { Environment, ssrGetDeploymentInfo } from 'utils/deploymentInfo'; import postErrorToSlack from 'utils/postErrorToSlack'; import pushNotificationMessage from 'utils/pushNotificationMessage'; import endUserMessages from 'utils/endUserMessages'; import httpStatusCodes from 'utils/http/httpStatusCodes'; -const env = ssrGetCurrentEnvironment(); +const { environment } = ssrGetDeploymentInfo(); const handleCodedErrors = (error, message, notifyUser) => { let errorMessage = message; @@ -32,11 +32,11 @@ const handleGenericErrors = (error, message, notifyUser) => { pushNotificationMessage('error', `${message}`); } - if (env === Environment.PRODUCTION) { - // add the intended user message to the error to now where - // the error comes from + if (environment === Environment.PRODUCTION) { + // add the intended user message to the error to know where + // the error comes from if (message) { - // eslint-disable-next-line no-param-reassign + // eslint-disable-next-line no-param-reassign error.message += message; } postErrorToSlack(error); diff --git a/src/utils/socketConnection.js b/src/utils/socketConnection.js index d19d03e082..dca3d34007 100644 --- a/src/utils/socketConnection.js +++ b/src/utils/socketConnection.js @@ -1,6 +1,6 @@ import socketIOClient from 'socket.io-client'; import getApiEndpoint from './apiEndpoint'; -import { isBrowser } from './environment'; +import { isBrowser } from './deploymentInfo'; const connectionPromise = new Promise((resolve, reject) => { /** diff --git a/src/utils/ssr/getEnvironmentInfo.js b/src/utils/ssr/getEnvironmentInfo.js index 2fdedfdf25..2cc0c9b049 100644 --- a/src/utils/ssr/getEnvironmentInfo.js +++ b/src/utils/ssr/getEnvironmentInfo.js @@ -1,18 +1,15 @@ -import loadEnvironment from 'redux/actions/networkResources/loadEnvironment'; -import { ssrGetCurrentEnvironment } from 'utils/environment'; +import loadDeploymentInfo from 'redux/actions/networkResources/loadDeploymentInfo'; +import { ssrGetDeploymentInfo } from 'utils/deploymentInfo'; -const getAuthenticationInfo = async (context, store) => { - if ( - store.getState().networkResources.environment - ) { - return; - } +const getEnvironmentInfo = async (context, store) => { + const { networkResources } = store.getState(); + if (networkResources.environment && networkResources.domainName) return; - const env = ssrGetCurrentEnvironment(); + const { environment, domainName } = ssrGetDeploymentInfo(); - store.dispatch(loadEnvironment(env)); + store.dispatch(loadDeploymentInfo({ environment, domainName })); return {}; }; -export default getAuthenticationInfo; +export default getEnvironmentInfo; diff --git a/src/utils/tracking.js b/src/utils/tracking.js index 1001c6a639..79ab939d5e 100644 --- a/src/utils/tracking.js +++ b/src/utils/tracking.js @@ -1,6 +1,6 @@ import { init, push } from '@socialgouv/matomo-next'; import Auth from '@aws-amplify/auth'; -import Env from './environment'; +import { Environment } from './deploymentInfo'; const MATOMO_URL = 'https://biomage.matomo.cloud'; @@ -9,24 +9,24 @@ const MATOMO_URL = 'https://biomage.matomo.cloud'; // To test locally, just enable the development environemnt. // The Site Ids are defined in biomage.matomo.cloud const trackingInfo = { - [Env.PRODUCTION]: { + [Environment.PRODUCTION]: { enabled: true, siteId: 1, containerId: 'lkIodjnO', }, - [Env.STAGING]: { + [Environment.STAGING]: { enabled: false, siteId: 2, containerId: 'FX7UBNS6', }, - [Env.DEVELOPMENT]: { + [Environment.DEVELOPMENT]: { enabled: false, siteId: 3, containerId: 'lS8ZRMXJ', }, }; -let env = Env.DEVELOPMENT; +let env = Environment.DEVELOPMENT; const getTrackingDetails = (e) => trackingInfo[e]; diff --git a/src/utils/work/fetchWork.js b/src/utils/work/fetchWork.js index e123628ecd..2df000d00c 100644 --- a/src/utils/work/fetchWork.js +++ b/src/utils/work/fetchWork.js @@ -1,7 +1,7 @@ /* eslint-disable no-underscore-dangle */ import { MD5 } from 'object-hash'; -import Environment, { isBrowser } from 'utils/environment'; +import { Environment, isBrowser } from 'utils/deploymentInfo'; import { calculateZScore } from 'utils/postRequestProcessing'; import { getBackendStatus } from 'redux/selectors';