diff --git a/docker-compose.yml b/docker-compose.yml index e634128f..56f0e13e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,6 +23,11 @@ services: frontend: container_name: frontend + environment: + - VITE_SHOW_CFPB_HEADER + - VITE_ADMIN_EMAIL + - VITE_DOWNLOAD_ACKNOWLEDGMENT_TEXT + - VITE_PII_WARNING_TEXT build: context: ./front-end target: dev diff --git a/front-end/src/config.ts b/front-end/.env similarity index 57% rename from front-end/src/config.ts rename to front-end/.env index ea2ceae0..59df4d75 100644 --- a/front-end/src/config.ts +++ b/front-end/.env @@ -1,10 +1,9 @@ -export const ADMIN_EMAIL = '' +VITE_ADMIN_EMAIL = '' -export const DOWNLOAD_ACKNOWLEDGMENT_TEXT = +VITE_DOWNLOAD_ACKNOWLEDGMENT_TEXT = 'I understand that by downloading data from this system, I will be accessing Personally Identifiable Information (PII) and Confidential Information (CI).' -export const DOWNLOAD_ACKNOWLEDGMENT_LABEL = - 'I confirm that I am knowingly downloading PII or CI and understand that I am responsible for safeguarding this data.' - -export const PII_WARNING_TEXT = +VITE_PII_WARNING_TEXT = 'I understand that by using this system, I will be accessing Personally Identifiable Information (PII) and Confidential Information (CI) and I understand my responsibilities to safeguard all this information.' + +VITE_SHOW_CFPB_HEADER = false \ No newline at end of file diff --git a/front-end/.yarn/cache/dotenv-npm-17.4.2-46ee0c966e-ca1b6f54d5.zip b/front-end/.yarn/cache/dotenv-npm-17.4.2-46ee0c966e-ca1b6f54d5.zip new file mode 100644 index 00000000..dec27906 Binary files /dev/null and b/front-end/.yarn/cache/dotenv-npm-17.4.2-46ee0c966e-ca1b6f54d5.zip differ diff --git a/front-end/cypress.config.ts b/front-end/cypress.config.ts index be3f3f56..6208d4b9 100644 --- a/front-end/cypress.config.ts +++ b/front-end/cypress.config.ts @@ -1,4 +1,14 @@ import { defineConfig } from 'cypress' +import dotenv from 'dotenv' +import path from 'path' +import { fileURLToPath } from 'url' +import { loadEnv } from 'vite' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +// Get any env variable overrides from parent of parent directory +dotenv.config({ path: path.resolve(__dirname, '../../.env') }) export default defineConfig({ // Set to false to disable deprecated Cypress.env() @@ -8,9 +18,29 @@ export default defineConfig({ e2e: { baseUrl: 'http://localhost:3000/', - specPattern: 'cypress/e2e/**/*.ts' - }, + specPattern: 'cypress/e2e/**/*.ts', + setupNodeEvents(on, config) { + // Capture the current mode (defaults to 'development' if not set) + const mode = (config.env.mode as string) || 'development' + + // Load env variables from the project root based on the mode + const viteEnv = loadEnv(mode, process.cwd()) + // Assign the variables to config.env so Cypress can access them + // Check for override values first + config.env.VITE_ADMIN_EMAIL = + process.env.VITE_ADMIN_EMAIL ?? viteEnv.VITE_ADMIN_EMAIL + config.env.VITE_DOWNLOAD_ACKNOWLEDGMENT_TEXT = + process.env.VITE_DOWNLOAD_ACKNOWLEDGMENT_TEXT ?? + viteEnv.VITE_DOWNLOAD_ACKNOWLEDGMENT_TEXT + config.env.VITE_PII_WARNING_TEXT = + process.env.VITE_PII_WARNING_TEXT ?? viteEnv.VITE_PII_WARNING_TEXT + config.env.VITE_SHOW_CFPB_HEADER = + process.env.VITE_SHOW_CFPB_HEADER ?? viteEnv.VITE_SHOW_CFPB_HEADER + + return config + } + }, component: { devServer: { framework: 'react', diff --git a/front-end/cypress/e2e/general.cy.ts b/front-end/cypress/e2e/general.cy.ts new file mode 100644 index 00000000..0325cfec --- /dev/null +++ b/front-end/cypress/e2e/general.cy.ts @@ -0,0 +1,95 @@ +import { Metro2Modal } from '@cypress/helpers/modalHelpers' +import { stripHtmlTags } from '@cypress/helpers/utils' +import { PII_COOKIE_NAME } from '@src/constants/settings' +const modal = new Metro2Modal() + +describe('General page content', () => { + describe('Warning modal', () => { + it('Should show a warning modal when PII cookie is not set', () => { + cy.intercept('GET', '/api/users/', { fixture: 'user' }).as('getUser') + cy.visit('/') + cy.wait('@getUser') + // On page load, no PII warning cookie is set + cy.getCookie(PII_COOKIE_NAME).should('not.exist') + + // Without the cookie, the warning modal is displayed + modal.getModal('warning-modal').should('exist') + + // The warning modal shows the value of the warning text env variable + cy.env(['VITE_PII_WARNING_TEXT']).then(({ VITE_PII_WARNING_TEXT }) => { + modal + .getModal('warning-modal') + .should('be.visible') + .within(() => { + cy.findByTestId('warning-text') + .should('exist') + .and('include.text', stripHtmlTags(VITE_PII_WARNING_TEXT as string)) + }) + }) + }) + + it('Should set a cookie when PII warning is accepted', () => { + cy.intercept('GET', '/api/users/', { fixture: 'user' }).as('getUser') + cy.visit('/') + cy.wait('@getUser') + + // On page load, no PII warning cookie is set + cy.getCookie(PII_COOKIE_NAME).should('not.exist') + + // The warning modal is displayed + modal + .getModal('warning-modal') + .should('exist') + .within(() => { + // Clicking the accept button adds a cookie + cy.findByTestId('accept-warning-button').click() + cy.getCookie(PII_COOKIE_NAME).should('have.property', 'value', 'true') + }) + }) + + it('Should not show a warning modal when PII cookie is set', () => { + cy.setCookie(PII_COOKIE_NAME, 'true') + cy.intercept('GET', '/api/users/', { fixture: 'user' }).as('getUser') + cy.visit('/') + cy.wait('@getUser') + + // with the cookie, the warning modal is not displayed + modal.getModal('warning-modal').should('not.exist') + }) + }) + + describe('Page header', () => { + it('Should show a page header based on env variable', () => { + cy.setCookie(PII_COOKIE_NAME, 'true') + cy.intercept('GET', '/api/users/', { fixture: 'user' }).as('getUser') + cy.visit('/') + cy.wait('@getUser') + + cy.env(['VITE_SHOW_CFPB_HEADER']).then(({ VITE_SHOW_CFPB_HEADER }) => { + if (VITE_SHOW_CFPB_HEADER === 'true') { + // CFPB header should show + cy.get('.o-header') + .should('be.visible') + .within(() => { + cy.get('.o-header__logo').should('exist') + cy.get('.nav-items') + .should('have.length', 1) + .first() + .should('have.text', 'User guide') + }) + } else { + // Default header should show + cy.get('header[data-testid="metro2-header"]') + .should('be.visible') + .within(() => { + cy.get('h1').should('have.text', 'Metro2 Evaluator Tool') + cy.get('.links') + .should('have.length', 1) + .first() + .should('have.text', 'Need help? See the user guide') + }) + } + }) + }) + }) +}) diff --git a/front-end/cypress/e2e/general_errors.cy.ts b/front-end/cypress/e2e/general_errors.cy.ts index 1ed66d09..3c78104a 100644 --- a/front-end/cypress/e2e/general_errors.cy.ts +++ b/front-end/cypress/e2e/general_errors.cy.ts @@ -77,5 +77,26 @@ describe('Errors', () => { `The account doesn’t exist.` ) }) + + it('Should show a mailto link if admin email env variable is set', () => { + cy.intercept('GET', '/api/events/1/', { fixture: 'event_1' }).as('getEvent') + cy.intercept('GET', '/api/events/1/account/not-an-account/', { + statusCode: 404 + }).as('nonexistentAccount') + + cy.visit('/events/1/accounts/not-an-account') + + cy.wait(['@getEvent', '@nonexistentAccount']) + cy.env(['VITE_ADMIN_EMAIL']).then(({ VITE_ADMIN_EMAIL }) => { + if (VITE_ADMIN_EMAIL) { + cy.findByTestId('contact-link') + .should('exist') + .and('have.attr', 'href') + .and('include', `mailto:${VITE_ADMIN_EMAIL}`) + } else { + cy.findByTestId('contact-link').should('not.exist') + } + }) + }) }) }) diff --git a/front-end/cypress/e2e/page_Account.cy.ts b/front-end/cypress/e2e/page_Account.cy.ts index 07a281c3..8fc80776 100644 --- a/front-end/cypress/e2e/page_Account.cy.ts +++ b/front-end/cypress/e2e/page_Account.cy.ts @@ -1,4 +1,5 @@ import { getInputByLabel, Metro2Modal } from '@cypress/helpers/modalHelpers' +import { stripHtmlTags } from '@cypress/helpers/utils' import { PII_COOKIE_NAME } from '@src/constants/settings' import { accountTableFields } from '@src/pages/Account/utils/accountTableFields' @@ -127,6 +128,24 @@ describe('Account data download', () => { modal.closeModal() modal.getModal().should('not.be.visible') }) + + it('Should show a download acknowledgment message from env variable', () => { + modal.openModal('Save account data') + cy.env(['VITE_DOWNLOAD_ACKNOWLEDGMENT_TEXT']).then( + ({ VITE_DOWNLOAD_ACKNOWLEDGMENT_TEXT }) => { + modal + .getModal() + .should('be.visible') + .within(() => { + cy.findByTestId('download-acknowledgment-text').should( + 'include.text', + stripHtmlTags(VITE_DOWNLOAD_ACKNOWLEDGMENT_TEXT as string) + ) + }) + } + ) + }) + it('Should not allow downloading unless privacy notice is accepted', () => { modal.openModal('Save account data') modal.verifyPrivacyCheckboxRequired() diff --git a/front-end/cypress/e2e/page_Evaluator_Downloads.cy.ts b/front-end/cypress/e2e/page_Evaluator_Downloads.cy.ts index 35a940ff..8ddef288 100644 --- a/front-end/cypress/e2e/page_Evaluator_Downloads.cy.ts +++ b/front-end/cypress/e2e/page_Evaluator_Downloads.cy.ts @@ -1,5 +1,6 @@ import { EvaluatorPage } from '@cypress/helpers/evaluatorPageHelpers' import { Metro2Modal } from '@cypress/helpers/modalHelpers' +import { stripHtmlTags } from '@cypress/helpers/utils' // Instantiate helpers const modal = new Metro2Modal() @@ -34,6 +35,23 @@ describe('Evaluator page', () => { modal.getModal().should('not.be.visible') }) + it('Should show a download acknowledgment message from env variable', () => { + modal.openModal('Save results') + cy.env(['VITE_DOWNLOAD_ACKNOWLEDGMENT_TEXT']).then( + ({ VITE_DOWNLOAD_ACKNOWLEDGMENT_TEXT }) => { + modal + .getModal() + .should('be.visible') + .within(() => { + cy.findByTestId('download-acknowledgment-text').should( + 'include.text', + stripHtmlTags(VITE_DOWNLOAD_ACKNOWLEDGMENT_TEXT as string) + ) + }) + } + ) + }) + it('Should not allow downloading unless privacy notice is accepted', () => { modal.openModal('Save results') modal.verifyPrivacyCheckboxRequired() diff --git a/front-end/cypress/helpers/modalHelpers.ts b/front-end/cypress/helpers/modalHelpers.ts index 03b41a43..799a4d3c 100644 --- a/front-end/cypress/helpers/modalHelpers.ts +++ b/front-end/cypress/helpers/modalHelpers.ts @@ -1,7 +1,8 @@ -import { - DOWNLOAD_ACKNOWLEDGMENT_LABEL, - DOWNLOAD_ACKNOWLEDGMENT_TEXT -} from '@src/config' +const DOWNLOAD_ACKNOWLEDGMENT_TEXT = + 'I understand that by downloading data from this system, I will be accessing Personally Identifiable Information (PII) and Confidential Information (CI).' + +const DOWNLOAD_ACKNOWLEDGMENT_LABEL = + 'I confirm that I am knowingly downloading PII or CI and understand that I am responsible for safeguarding this data.' export const getInputByLabel = (label: string) => { return cy @@ -13,8 +14,8 @@ export const getInputByLabel = (label: string) => { } export class Metro2Modal { - getModal() { - return cy.get('.modal') + getModal(testid: string | null = null) { + return testid ? cy.get(`[data-testid="${testid}"].modal`) : cy.get('.modal') } openModal(buttonText: string): Cypress.Chainable { diff --git a/front-end/cypress/helpers/utils.ts b/front-end/cypress/helpers/utils.ts new file mode 100644 index 00000000..40e04c4c --- /dev/null +++ b/front-end/cypress/helpers/utils.ts @@ -0,0 +1,5 @@ +export const stripHtmlTags = (htmlString: string) => { + const div = document.createElement('div') + div.innerHTML = htmlString + return div.textContent +} diff --git a/front-end/package.json b/front-end/package.json index 59184523..845d4d06 100644 --- a/front-end/package.json +++ b/front-end/package.json @@ -69,6 +69,7 @@ "csstype": "^3.2.3", "cypress": "^15.11.0", "cypress-real-events": "^1.15.0", + "dotenv": "^17.4.2", "eslint": "^10.0.2", "eslint-config-prettier": "10.1.8", "eslint-import-resolver-typescript": "^4.4.4", diff --git a/front-end/src/components/Error/ErrorMessage.tsx b/front-end/src/components/Error/ErrorMessage.tsx index 734cb939..7d79c5d4 100644 --- a/front-end/src/components/Error/ErrorMessage.tsx +++ b/front-end/src/components/Error/ErrorMessage.tsx @@ -1,5 +1,4 @@ import { ButtonGroup, Link as CFPBLink } from '@cfpb/design-system-react' -import { ADMIN_EMAIL } from '@src/config' import { Link } from '@src/utils/DSRLink' import { useRouterState } from '@tanstack/react-router' import type { ReactElement } from 'react' @@ -18,6 +17,7 @@ export default function ErrorMessage({ }: ErrorMessageProperties): ReactElement { const router = useRouterState() const currentPath = router.location.pathname + const adminEmail = import.meta.env.VITE_ADMIN_EMAIL return (
@@ -30,12 +30,11 @@ export default function ErrorMessage({ Back to Metro 2 home page - {ADMIN_EMAIL ? ( + {adminEmail ? ( Contact an administrator diff --git a/front-end/src/components/Modal/DownloadModal.tsx b/front-end/src/components/Modal/DownloadModal.tsx index 94e804eb..905e97ed 100644 --- a/front-end/src/components/Modal/DownloadModal.tsx +++ b/front-end/src/components/Modal/DownloadModal.tsx @@ -1,8 +1,4 @@ import { Button, ButtonGroup, Checkbox } from '@cfpb/design-system-react' -import { - DOWNLOAD_ACKNOWLEDGMENT_LABEL, - DOWNLOAD_ACKNOWLEDGMENT_TEXT -} from '@src/config' import DOMPurify from 'dompurify' import type { ReactElement } from 'react' import { useState } from 'react' @@ -21,7 +17,9 @@ interface DownloadModalProperties { buttonText?: string } -const downloadText = DOMPurify.sanitize(DOWNLOAD_ACKNOWLEDGMENT_TEXT) +const downloadText = DOMPurify.sanitize( + import.meta.env.VITE_DOWNLOAD_ACKNOWLEDGMENT_TEXT +) /** * DownloadModal() @@ -84,16 +82,18 @@ export default function DownloadModal({ {requirePrivacyAcknowledgment && (
+ data-testid='download-acknowledgment'> {privacyHeader} -

+

diff --git a/front-end/src/components/Modal/Modal.tsx b/front-end/src/components/Modal/Modal.tsx index b192d3b9..23f406eb 100644 --- a/front-end/src/components/Modal/Modal.tsx +++ b/front-end/src/components/Modal/Modal.tsx @@ -14,8 +14,9 @@ export function Modal({ children, interactionRequired = false, open, - onClose -}: ModalProperties & React.HTMLAttributes): ReactElement | null { + onClose, + ...rest +}: ModalProperties & React.HTMLAttributes): ReactElement | null { const dialogRef = useRef(null) const openModal = (): void => { dialogRef.current?.showModal() @@ -51,7 +52,7 @@ export function Modal({ }) return ( - +
{interactionRequired ? ( '' diff --git a/front-end/src/components/Modal/WarningModal.tsx b/front-end/src/components/Modal/WarningModal.tsx index 7074b9af..21671291 100644 --- a/front-end/src/components/Modal/WarningModal.tsx +++ b/front-end/src/components/Modal/WarningModal.tsx @@ -1,12 +1,11 @@ import { Button } from '@cfpb/design-system-react' -import { PII_WARNING_TEXT } from '@src/config' import { acceptPIIWarning, hasAcceptedPIIWarning } from '@src/utils/cookies' import DOMPurify from 'dompurify' import type { ReactElement } from 'react' import { useState } from 'react' import { Modal, ModalFooter } from './Modal' -const warningText = DOMPurify.sanitize(PII_WARNING_TEXT) +const warningText = DOMPurify.sanitize(import.meta.env.VITE_PII_WARNING_TEXT) export default function WarningModal(): ReactElement | null { const [isOpen, setIsOpen] = useState(!hasAcceptedPIIWarning()) @@ -19,15 +18,17 @@ export default function WarningModal(): ReactElement | null { } return ( - +

Warning

-
+