diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 3ebed0e0e3..39a584f0fb 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -14,6 +14,9 @@ "source=${env:HOME}${env:USERPROFILE}/.gnupg,target=/home/vscode/.gnupg,type=bind", "source=${env:HOME}${env:USERPROFILE}/.npmrc,target=/home/vscode/.npmrc,type=bind" ], + "runArgs": [ + "--network=host" + ], "remoteEnv": { "LOCAL_WORKSPACE_FOLDER": "${localWorkspaceFolder}" }, "postAttachCommand": "docker build -f https://raw.githubusercontent.com/NHSDigital/eps-workflow-quality-checks/refs/tags/v4.0.4/dockerfiles/nhsd-git-secrets.dockerfile -t git-secrets . && poetry run pre-commit install --install-hooks -f", "features": { @@ -27,24 +30,25 @@ "customizations": { "vscode": { "extensions": [ - "AmazonWebServices.aws-toolkit-vscode", - "redhat.vscode-yaml", - "ms-python.python", - "ms-python.flake8", - "eamodio.gitlens", - "github.vscode-pull-request-github", - "orta.vscode-jest", - "42crunch.vscode-openapi", - "mermade.openapi-lint", - "christian-kohler.npm-intellisense", - "dbaeumer.vscode-eslint", - "lfm.vscode-makefile-term", - "GrapeCity.gc-excelviewer", - "redhat.vscode-xml", - "streetsidesoftware.code-spell-checker", - "timonwong.shellcheck", - "mkhl.direnv", - "github.vscode-github-actions" + "AmazonWebServices.aws-toolkit-vscode", + "redhat.vscode-yaml", + "ms-python.python", + "ms-python.flake8", + "eamodio.gitlens", + "github.vscode-pull-request-github", + "orta.vscode-jest", + "42crunch.vscode-openapi", + "mermade.openapi-lint", + "christian-kohler.npm-intellisense", + "dbaeumer.vscode-eslint", + "lfm.vscode-makefile-term", + "GrapeCity.gc-excelviewer", + "redhat.vscode-xml", + "streetsidesoftware.code-spell-checker", + "timonwong.shellcheck", + "mkhl.direnv", + "github.vscode-github-actions", + "Gruntfuggly.todo-tree" ], "settings": { "python.defaultInterpreterPath": "/workspaces/eps-prescription-tracker-ui/.venv/bin/python", diff --git a/.github/workflows/deploy_website_content.yml b/.github/workflows/deploy_website_content.yml index 074807b66f..5bbb0096cc 100644 --- a/.github/workflows/deploy_website_content.yml +++ b/.github/workflows/deploy_website_content.yml @@ -76,8 +76,9 @@ jobs: export NEXT_PUBLIC_userPoolClientId=${userPoolClientId} export NEXT_PUBLIC_userPoolId=${userPoolId} export NEXT_PUBLIC_redirectSignIn="https://${fullCloudfrontDomain}/site/selectyourrole.html" - export NEXT_PUBLIC_redirectSignOut="https://${fullCloudfrontDomain}/site/" + export NEXT_PUBLIC_redirectSignOut="https://${fullCloudfrontDomain}/site/logout.html" export NEXT_PUBLIC_COMMIT_ID=${{ inputs.COMMIT_ID }} + export NEXT_PUBLIC_TARGET_ENVIRONMENT=${{ inputs.TARGET_ENVIRONMENT }} cd .build make react-build diff --git a/.vscode/eps-prescription-tracker-ui.code-workspace b/.vscode/eps-prescription-tracker-ui.code-workspace index 282e7acfee..b58e0e4403 100644 --- a/.vscode/eps-prescription-tracker-ui.code-workspace +++ b/.vscode/eps-prescription-tracker-ui.code-workspace @@ -44,7 +44,6 @@ "name": "packages/trackerUserInfoLambda", "path": "../packages/trackerUserInfoLambda" } - ], "settings": { "jest.disabledWorkspaceFolders": [ @@ -144,7 +143,8 @@ }, "typescript.tsdk": "eps-prescription-tracker-ui-monorepo/node_modules/typescript/lib", "eslint.useFlatConfig": true, - "eslint.format.enable": true + "eslint.format.enable": true, + "editor.defaultFormatter": "dbaeumer.vscode-eslint" }, "extensions": { "recommendations": [ diff --git a/README.md b/README.md index 8ca7836650..9a40dbfcab 100644 --- a/README.md +++ b/README.md @@ -98,11 +98,13 @@ export NEXT_PUBLIC_userPoolId="eu-west-2_deadbeef" export LOCAL_DEV=true # DON'T TOUCH! -export API_DOMAIN_OVERRIDE=https://${SERVICE_NAME}.dev.eps.national.nhs.uk/ +export NEXT_PUBLIC_TARGET_ENVIRONMENT=dev # enables mock auth +export BASE_PATH="/site" # Hosts the site at `localhost:3000/site` +export API_DOMAIN_OVERRIDE=https://${SERVICE_NAME}.dev.eps.national.nhs.uk/ # Proxies the actual deployed backend for this PR export NEXT_PUBLIC_hostedLoginDomain=${SERVICE_NAME}.auth.eu-west-2.amazoncognito.com -export NEXT_PUBLIC_redirectSignIn=http://localhost:3000/selectyourrole/ -export NEXT_PUBLIC_redirectSignOut=http://localhost:3000/ +export NEXT_PUBLIC_redirectSignIn=http://localhost:3000/site/selectyourrole +export NEXT_PUBLIC_redirectSignOut=http://localhost:3000/site/logout export NEXT_PUBLIC_COMMIT_ID="Local Development Server" @@ -121,11 +123,12 @@ userPoolClientId=$(aws cloudformation list-exports --region eu-west-2 --query "E userPoolId=$(aws cloudformation list-exports --region eu-west-2 --query "Exports[?Name=='${SERVICE_NAME}-stateful-resources:userPool:Id'].Value" --output text) echo $userPoolClientId echo $userPoolId + ``` -For me, the aws terminal console installed in the dev container refuses to work. Another approach is to use the browser console, accessed by clicking the terminal icon next to the search bar on the AWS web dashboard. +For me, the aws terminal console installed in the dev container refuses to work without causing a headache. Another approach is to use the browser console, accessed by clicking the terminal icon next to the search bar on the AWS web dashboard. -n.b. Ensure you've properly sourced these variables! Direnv can sometimes miss changes. +n.b. Ensure you've properly sourced these variables! `direnv` can sometimes miss changes on my machine. ``` source .envrc ``` diff --git a/packages/cdk/resources/Cognito.ts b/packages/cdk/resources/Cognito.ts index 7e9fee9765..63db7943bd 100644 --- a/packages/cdk/resources/Cognito.ts +++ b/packages/cdk/resources/Cognito.ts @@ -186,24 +186,31 @@ export class Cognito extends Construct { } const callbackUrls = [ - `https://${props.fullCloudfrontDomain}/site/`, + `https://${props.fullCloudfrontDomain}/site/selectyourrole`, // FIXME: This is temporary, until we get routing fixed `https://${props.fullCloudfrontDomain}/site/selectyourrole.html`, - `https://${props.fullCloudfrontDomain}/auth_demo/`, - `https://${props.fullCloudfrontDomain}/site/selectyourrole/`, + // TODO: This is for the proof-of-concept login page, and can probably be deleted soon. + `https://${props.fullCloudfrontDomain}/auth_demo`, `https://${props.fullCloudfrontDomain}/oauth2/idpresponse` ] const logoutUrls = [ - `https://${props.fullCloudfrontDomain}/site/`, - `https://${props.fullCloudfrontDomain}/site/auth_demo.html`, - `https://${props.fullCloudfrontDomain}/auth_demo/` + `https://${props.fullCloudfrontDomain}/site/logout`, + `https://${props.fullCloudfrontDomain}/site/logout.html`, + `https://${props.fullCloudfrontDomain}/auth_demo` ] if (props.useLocalhostCallback) { + // Local, without base path set + callbackUrls.push("http://localhost:3000/selectyourrole/") + logoutUrls.push("http://localhost:3000/logout/") + // Local, with base path set to /site + logoutUrls.push("http://localhost:3000/site/logout/") + callbackUrls.push("http://localhost:3000/site/selectyourrole/") + // Auth demo stuff callbackUrls.push("http://localhost:3000/auth/") callbackUrls.push("http://localhost:3000/auth_demo/") - callbackUrls.push("http://localhost:3000/selectyourrole/") + // Root path, just in case logoutUrls.push("http://localhost:3000/") } // add the web client diff --git a/packages/cpt-ui/__tests__/EpsModal.test.tsx b/packages/cpt-ui/__tests__/EpsModal.test.tsx new file mode 100644 index 0000000000..5c002ce818 --- /dev/null +++ b/packages/cpt-ui/__tests__/EpsModal.test.tsx @@ -0,0 +1,95 @@ +import React from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import { EpsModal } from "@/components/EpsModal"; + +describe("EpsModal", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test("does not render the modal when isOpen is false", () => { + render( + +
Modal Content
+
+ ); + // The content should not be in the document + expect(screen.queryByText(/Modal Content/i)).not.toBeInTheDocument(); + }); + + test("renders the modal when isOpen is true", () => { + render( + +
Modal Content
+
+ ); + // The content should appear in the document + expect(screen.getByText(/Modal Content/i)).toBeInTheDocument(); + }); + + test("calls onClose when user clicks outside modal content", () => { + const onCloseMock = jest.fn(); + render( + +
Modal Content
+
+ ); + + const overlay = screen.getByTestId("eps-modal-overlay"); + const modalContent = screen.getByTestId("modal-content"); + + // Clicking directly on the content should NOT trigger onClose + fireEvent.click(modalContent); + expect(onCloseMock).not.toHaveBeenCalled(); + + // Clicking on the overlay (outside the content) should trigger onClose + fireEvent.click(overlay); + expect(onCloseMock).toHaveBeenCalledTimes(1); + }); + + test("calls onClose when user clicks the close button", () => { + const onCloseMock = jest.fn(); + render( + +
Modal Content
+ +
+ ); + + const closeButton = screen.getByText(/TEST CLOSE BUTTON/); + fireEvent.click(closeButton); + expect(onCloseMock).toHaveBeenCalledTimes(1); + }); + + test("calls onClose when user presses Escape", () => { + const onCloseMock = jest.fn(); + render( + +
Modal Content
+
+ ); + + // Fire 'Escape' keydown event on window + fireEvent.keyDown(window, { key: "Escape" }); + expect(onCloseMock).toHaveBeenCalledTimes(1); + }); + + test("calls onClose when user presses Enter or Space on the backdrop", () => { + const onCloseMock = jest.fn(); + render( + +
Modal Content
+
+ ); + + const overlay = screen.getByTestId("eps-modal-overlay"); + + fireEvent.keyDown(overlay, { key: "Enter" }); + expect(onCloseMock).toHaveBeenCalledTimes(1); + + // Fire again with ' ' + fireEvent.keyDown(overlay, { key: " " }); + expect(onCloseMock).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/cpt-ui/__tests__/AuthDemoPage.test.tsx b/packages/cpt-ui/__tests__/LoginPage.test.tsx similarity index 90% rename from packages/cpt-ui/__tests__/AuthDemoPage.test.tsx rename to packages/cpt-ui/__tests__/LoginPage.test.tsx index 11619cedf9..6497f9b426 100644 --- a/packages/cpt-ui/__tests__/AuthDemoPage.test.tsx +++ b/packages/cpt-ui/__tests__/LoginPage.test.tsx @@ -5,7 +5,7 @@ import userEvent from "@testing-library/user-event"; import React, { useState } from "react"; // Mock the configureAmplify module -jest.mock("../context/configureAmplify", () => ({ +jest.mock("@/context/configureAmplify", () => ({ __esModule: true, authConfig: { Auth: { @@ -74,10 +74,14 @@ const MockAuthProvider = ({ children }) => { // Since we've referenced AuthContext in the mock provider, we need to re-import it here // after the mock is set up. -import { AuthContext } from "../context/AuthProvider"; -import AuthPage from "../app/auth_demo/page"; +import { AuthContext } from "@/context/AuthProvider"; +import AuthPage from "@/app/login/page"; describe("AuthPage", () => { + beforeEach(() => { + process.env.NEXT_PUBLIC_TARGET_ENVIRONMENT = "dev"; + }); + it("renders the page and the main buttons", () => { const { container } = render( @@ -179,4 +183,17 @@ describe("AuthPage", () => { screen.getByText((content) => content.includes('"isSignedIn": false')) ).toBeInTheDocument(); }); + + it("shows a spinner when not in a mock auth environment", () => { + process.env.NEXT_PUBLIC_TARGET_ENVIRONMENT = "prod"; + + render( + + + + ); + + const spinner = screen.getByRole("heading", { name: /Redirecting to CIS2 login page.../i }); + expect(spinner).toBeInTheDocument(); + }); }); diff --git a/packages/cpt-ui/__tests__/LogoutPage.test.tsx b/packages/cpt-ui/__tests__/LogoutPage.test.tsx new file mode 100644 index 0000000000..6a2e684e2b --- /dev/null +++ b/packages/cpt-ui/__tests__/LogoutPage.test.tsx @@ -0,0 +1,169 @@ +// @ts-nocheck +import "@testing-library/jest-dom"; +import { render, screen, waitFor } from "@testing-library/react"; +import React, { useState } from "react"; + +// Mock the configureAmplify module +jest.mock("@/context/configureAmplify", () => ({ + __esModule: true, + authConfig: { + Auth: { + Cognito: { + userPoolClientId: "mockUserPoolClientId", + userPoolId: "mockUserPoolId", + loginWith: { + oauth: { + domain: "mockHostedLoginDomain", + scopes: ["openid", "email", "phone", "profile", "aws.cognito.signin.user.admin"], + redirectSignIn: ["mockRedirectSignIn"], + redirectSignOut: ["mockRedirectSignOut"], + responseType: "code", + }, + username: true, + email: false, + phone: false, + }, + }, + }, + }, +})); + +// Create a mock AuthContext provider that allows us to control the state +const mockCognitoSignIn = jest.fn(); +const mockCognitoSignOut = jest.fn(); + +interface MockAuthProviderProps { + children: React.ReactNode; + defaultIsSignedIn?: boolean; + defaultUser?: { username: string } | null; +} + +const MockAuthProvider: React.FC = ({ + children, + defaultIsSignedIn = true, + defaultUser = { username: "mockUser" }, +}) => { + // State to simulate auth changes + const [authState, setAuthState] = useState({ + isSignedIn: defaultIsSignedIn, + user: defaultUser, + error: null as string | null, + idToken: defaultIsSignedIn ? "mockIdToken" : null, + accessToken: defaultIsSignedIn ? "mockAccessToken" : null, + cognitoSignIn: async (options: { provider: { custom: any } }) => { + await new Promise((resolve) => setTimeout(resolve, 3000)); + + mockCognitoSignIn(options); + // Simulate a sign-in update + setAuthState((prev) => ({ + ...prev, + isSignedIn: true, + user: { username: options?.provider?.custom || "mockUser" }, + error: null, + idToken: "mockIdToken", + accessToken: "mockAccessToken", + })); + }, + cognitoSignOut: async () => { + await new Promise((resolve) => setTimeout(resolve, 3000)); + + mockCognitoSignOut(); + // Simulate a sign-out update + setAuthState((prev) => ({ + ...prev, + isSignedIn: false, + user: null, + error: null, + idToken: null, + accessToken: null, + })); + }, + }); + + return ( + {children} + ); +}; + +// Since we've referenced AuthContext in the mock provider, we need to re-import it here +// after the mock is set up. +import { AuthContext } from "@/context/AuthProvider"; +import LogoutPage from "@/app/logout/page"; + +describe("LogoutPage", () => { + // Use fake timers to control the setTimeout in LogoutPage + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + jest.clearAllMocks(); + }); + + it("renders 'Logout successful' immediately if the user is not signed in", () => { + render( + + + + ); + + // The user is not signed in, so we expect to see "Logout successful". + expect(screen.getByText(/Logout successful/i)).toBeInTheDocument(); + expect( + screen.getByText( + /You are now logged out of the service. To continue using the service, you must log in again/i + ) + ).toBeInTheDocument(); + + // We also expect to see the "Log in" link or button + expect(screen.getByRole("link", { name: /log in/i })).toBeInTheDocument(); + + // Because user is not signed in, we do NOT expect signOut to have been called + expect(mockCognitoSignOut).not.toHaveBeenCalled(); + }); + + it("shows a spinner and calls signOut when the user is signed in", async () => { + render( + + + + ); + + // Because the user is signed in, we expect "Logging out" and spinner + expect(screen.getByText(/Logging out/i)).toBeInTheDocument(); + expect(screen.getByRole("progressbar")).toBeInTheDocument(); + // signOut is delayed by 3s (for now). Fast forward the timers so the logout can complete. + jest.advanceTimersByTime(3000); + // Wait for re-render after signOut + await waitFor(() => { + expect(mockCognitoSignOut).toHaveBeenCalledTimes(1); + }); + + // After signOut, the user is no longer signed in, so we should see "Logout successful" + expect(screen.getByText(/Logout successful/i)).toBeInTheDocument(); + expect( + screen.getByText( + /You are now logged out of the service. To continue using the service, you must log in again/i + ) + ).toBeInTheDocument(); + }); + + it("does not call signOut if user is signed in, but we haven't advanced timers yet", () => { + render( + + + + ); + + // On initial render, user is signed in + // The call is triggered, but only after the 3s setTimeout. + // We haven't advanced timers, so the signOut shouldn't have completed yet. + expect(screen.getByText(/Logging out/i)).toBeInTheDocument(); + expect(mockCognitoSignOut).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cpt-ui/__tests__/SelectYourRolePage.test.tsx b/packages/cpt-ui/__tests__/SelectYourRolePage.test.tsx index 254abd7034..795d3887dd 100644 --- a/packages/cpt-ui/__tests__/SelectYourRolePage.test.tsx +++ b/packages/cpt-ui/__tests__/SelectYourRolePage.test.tsx @@ -5,7 +5,6 @@ import React from "react" import SelectYourRolePage from "@/app/selectyourrole/page" import {AccessProvider} from "@/context/AccessProvider" import {AuthContext} from "@/context/AuthProvider" -import {SELECT_YOUR_ROLE_PAGE_TEXT} from "@/constants/ui-strings/CardStrings" // Mock the module and directly reference the variable jest.mock("@/constants/ui-strings/CardStrings", () => { @@ -95,6 +94,9 @@ const renderWithAuthAndAccess = ( ) } +import { SELECT_YOUR_ROLE_PAGE_TEXT } from "@/constants/ui-strings/CardStrings"; +import { EpsSpinnerStrings } from "../constants/ui-strings/EpsSpinnerStrings"; + describe("SelectYourRolePage", () => { // Clear all mock calls before each test to avoid state leaks beforeEach(() => { @@ -109,11 +111,9 @@ describe("SelectYourRolePage", () => { renderWithAuthAndAccess({isSignedIn: true, idToken: "mock-id-token"}, {loading: true}) // Verify that the loading text appears - const loadingText = screen.getByText( - SELECT_YOUR_ROLE_PAGE_TEXT.loadingMessage - ) - expect(loadingText).toBeInTheDocument() - }) + const loadingText = screen.getByText(EpsSpinnerStrings.loading); + expect(loadingText).toBeInTheDocument(); + }); it("renders error summary if fetch returns non-200 status", async () => { // Mock fetch to return a 500 status code (server error) diff --git a/packages/cpt-ui/app/auth_demo/page.tsx b/packages/cpt-ui/app/login/page.tsx similarity index 64% rename from packages/cpt-ui/app/auth_demo/page.tsx rename to packages/cpt-ui/app/login/page.tsx index 232c73fcce..a25e5382ad 100644 --- a/packages/cpt-ui/app/auth_demo/page.tsx +++ b/packages/cpt-ui/app/login/page.tsx @@ -1,16 +1,26 @@ 'use client' -import React, {useContext, useEffect} from "react"; +import React, { useContext, useEffect, useCallback } from "react"; import { Container, Col, Row, Button } from "nhsuk-react-components"; import { AuthContext } from "@/context/AuthProvider"; +import EpsSpinner from "@/components/EpsSpinner"; +import { EpsLoginPageStrings } from "@/constants/ui-strings/EpsLoginPageStrings"; + +const MOCK_AUTH_ALLOWED = [ + "dev", + "dev-pr", + "int", + "qa", + // "ref", + // "prod" +] export default function AuthPage() { const auth = useContext(AuthContext); - useEffect(() => { - console.log(auth); - }, [auth]) - + // Use secure login by default + const target_environment: string = process.env.NEXT_PUBLIC_TARGET_ENVIRONMENT || "prod"; + const mockSignIn = async () => { console.log("Signing in (Mock)", auth); await auth?.cognitoSignIn({ @@ -20,14 +30,15 @@ export default function AuthPage() { }); } - const signIn = async () => { + const signIn = useCallback(async () => { console.log("Signing in (Primary)", auth); await auth?.cognitoSignIn({ provider: { custom: "Primary" } }); - } + console.log("Signed in: ", auth); + }, [auth]); const signOut = async () => { console.log("Signing out", auth); @@ -35,6 +46,39 @@ export default function AuthPage() { console.log("Signed out: ", auth); } + useEffect(() => { + console.log( + "Login page loaded. What environment are we in?", + target_environment + ); + + // Only call signIn() if user is *not* in a mock environment AND *not* signed in yet. + if (!MOCK_AUTH_ALLOWED.includes(target_environment) && !auth?.isSignedIn) { + console.log("User must sign in with Primary auth"); + signIn(); + } + }, [auth?.isSignedIn, signIn, target_environment]); + + useEffect(() => { + console.log(auth); + }, [auth]) + + if (!MOCK_AUTH_ALLOWED.includes(target_environment)) { + return ( +
+ + + +

{EpsLoginPageStrings.redirecting_msg}

+ + +
+
+
+ ) + } + + // This is a dev page, so no need to bother with language support return (
diff --git a/packages/cpt-ui/app/logout/page.tsx b/packages/cpt-ui/app/logout/page.tsx new file mode 100644 index 0000000000..9166900f07 --- /dev/null +++ b/packages/cpt-ui/app/logout/page.tsx @@ -0,0 +1,52 @@ +'use client' +import React, { useContext, useEffect } from "react"; +import { Container } from "nhsuk-react-components" +import Link from "next/link"; + +import { AuthContext } from "@/context/AuthProvider"; +import EpsSpinner from "@/components/EpsSpinner"; +import { EpsLogoutStrings } from "@/constants/ui-strings/EpsLogoutPageStrings"; + +export default function LogoutPage() { + + const auth = useContext(AuthContext); + + // Log out on page load + useEffect(() => { + const signOut = async () => { + console.log("Signing out", auth); + + await auth?.cognitoSignOut(); + console.log("Signed out: ", auth); + } + + if (auth?.isSignedIn) { + signOut(); + } else { + console.log("Cannot sign out - not signed in"); + } + }, [auth]); + + // TODO: Move strings to a constants file + return ( +
+ + {auth?.isSignedIn ? ( + <> +

{EpsLogoutStrings.loading}

+ + + ) : ( + <> +

{EpsLogoutStrings.title}

+
{EpsLogoutStrings.body}
+

+ + {EpsLogoutStrings.login_link} + + + )} + +

+ ); +} diff --git a/packages/cpt-ui/app/selectyourrole/page.tsx b/packages/cpt-ui/app/selectyourrole/page.tsx index 9aa23f044d..a1f5541aca 100644 --- a/packages/cpt-ui/app/selectyourrole/page.tsx +++ b/packages/cpt-ui/app/selectyourrole/page.tsx @@ -2,9 +2,13 @@ import React, {useState, useEffect, useContext, useCallback} from "react" import {useRouter} from 'next/navigation' import {Container, Col, Row, Details, Table, ErrorSummary, Button, InsetText} from "nhsuk-react-components" + import {AuthContext} from "@/context/AuthProvider" import {useAccess} from '@/context/AccessProvider' + import EpsCard, {EpsCardProps} from "@/components/EpsCard" +import EpsSpinner from "@/components/EpsSpinner"; + import {SELECT_YOUR_ROLE_PAGE_TEXT} from "@/constants/ui-strings/CardStrings" export type RoleDetails = { @@ -53,9 +57,8 @@ const { noODSCode, noRoleName, noAddress, - errorDuringRoleSelection, - loadingMessage -} = SELECT_YOUR_ROLE_PAGE_TEXT + errorDuringRoleSelection +} = SELECT_YOUR_ROLE_PAGE_TEXT; export default function SelectYourRolePage() { const {setNoAccess} = useAccess() @@ -76,8 +79,8 @@ export default function SelectYourRolePage() { if (!auth?.isSignedIn || !auth) { setLoading(false) - setError("Not signed in") - return + setError(null) + return; } try { @@ -178,7 +181,7 @@ export default function SelectYourRolePage() { - {loadingMessage} + diff --git a/packages/cpt-ui/assets/styles/EpsModal.scss b/packages/cpt-ui/assets/styles/EpsModal.scss new file mode 100644 index 0000000000..ab6edc2baa --- /dev/null +++ b/packages/cpt-ui/assets/styles/EpsModal.scss @@ -0,0 +1,51 @@ +.eps-modal-overlay { + position: fixed; + top: 0; + left: 0; + height: 100vh; + width: 100vw; + border: 0px; + background-color: rgba(0, 0, 0, 0.5); // darken the background + display: flex; + align-items: center; + justify-content: center; + z-index: 9999; +} + +.eps-modal-content { + background: #fff; + padding: 2rem; + border-radius: 4px; + border: 0px; + text-align: left; + position: relative; + max-width: 700px; + width: 90%; + display: block; +} + +.eps-modal-button { + width: 35%; +} + +.eps-modal-close-button { + position: absolute; + top: 0rem; + right: 1rem; + background: transparent; + border-radius: 4px; + border: 0px; + font-size: 3rem; + cursor: pointer; +} + +.eps-modal-close-button:hover { + color: red; +} + +.eps-modal-button-group { + display: flex; + justify-content: left; + gap: 5%; + margin-top: 2rem; +} diff --git a/packages/cpt-ui/components/EpsHeader.tsx b/packages/cpt-ui/components/EpsHeader.tsx index d64beacdb0..150018c876 100644 --- a/packages/cpt-ui/components/EpsHeader.tsx +++ b/packages/cpt-ui/components/EpsHeader.tsx @@ -1,66 +1,134 @@ -'use client' -import React from 'react' -import Link from 'next/link'; -import "@/assets/styles/header.scss" -import { useRouter, usePathname } from 'next/navigation'; +"use client" +import React, { useContext, useState } from "react"; +import Link from "next/link"; +import "@/assets/styles/header.scss"; +import { useRouter, usePathname } from "next/navigation"; import { Header } from "nhsuk-react-components"; import { - HEADER_SERVICE, - HEADER_CONFIRM_ROLE_BUTTON, - HEADER_CONFIRM_ROLE_TARGET, - HEADER_CHANGE_ROLE_BUTTON, - HEADER_CHANGE_ROLE_TARGET, - HEADER_SELECT_YOUR_ROLE_BUTTON, - HEADER_SELECT_YOUR_ROLE_TARGET -} from "@/constants/ui-strings/HeaderStrings" + HEADER_SERVICE, + HEADER_CONFIRM_ROLE_BUTTON, + HEADER_CONFIRM_ROLE_TARGET, + HEADER_CHANGE_ROLE_BUTTON, + HEADER_CHANGE_ROLE_TARGET, + HEADER_SELECT_YOUR_ROLE_BUTTON, + HEADER_SELECT_YOUR_ROLE_TARGET +} from "@/constants/ui-strings/HeaderStrings"; + +import { AuthContext } from "@/context/AuthProvider"; +import { EpsLogoutModal } from "@/components/EpsLogoutModal"; export default function EpsHeader() { - const router = useRouter() - const pathname = usePathname(); - console.log(router); // Query parameters - return ( -
- - + const router = useRouter(); + const pathname = usePathname(); + const auth = useContext(AuthContext); + + const [showLogoutModal, setShowLogoutModal] = useState(false); + + const handleConfirmLogout = async () => { + setShowLogoutModal(false); + router.push("/logout"); + }; + + const handleLogoutClick = (e: React.MouseEvent) => { + e.preventDefault(); + setShowLogoutModal(true); + }; + + return ( + <> +
+ + + + + {HEADER_SERVICE} + + + + + + {/* Example placeholder links */} +
  • + + Placeholder 1 + +
  • + +
  • + + Placeholder 2 + +
  • + + {/* Conditionally show "change role" or "confirm role" */} + {pathname !== "/" ? ( +
  • + + {HEADER_CHANGE_ROLE_BUTTON} + +
  • + ) : ( +
  • + + {HEADER_CONFIRM_ROLE_BUTTON} + +
  • + )} + + {/* FIXME: Only the selectyourrole and changerole links get put in the collapsible menu when on mobile */} + {pathname === "/selectyourrole" ? ( +
  • + + {HEADER_CONFIRM_ROLE_BUTTON} + +
  • + ) : ( +
  • + + {HEADER_SELECT_YOUR_ROLE_BUTTON} + +
  • + )} + + {/* FIXME: Only shows the Log out link if the user is signed in, but introduces a lag on page reload. */} + {auth?.isSignedIn && ( +
  • + + Log out + +
  • + )} + + +
    +
    - - {HEADER_SERVICE} - - -
    - -
  • - Placeholder 1 -
  • -
  • - Placeholder 2 -
  • - {pathname != '/' ? ( -
  • - {HEADER_CHANGE_ROLE_BUTTON} -
  • - ) : - ( -
  • - {HEADER_CONFIRM_ROLE_BUTTON} -
  • - ) - } - {pathname === '/selectyourrole' ? ( -
  • - {HEADER_CONFIRM_ROLE_BUTTON} -
  • - ) : ( -
  • - {HEADER_SELECT_YOUR_ROLE_BUTTON} -
  • - )} -
  • - Placeholder 3 -
  • - {/* Placeholder 3 */} - -
    -
    - ) + setShowLogoutModal(false)} + onConfirm={handleConfirmLogout} + /> + + ); } diff --git a/packages/cpt-ui/components/EpsLogoutModal.tsx b/packages/cpt-ui/components/EpsLogoutModal.tsx new file mode 100644 index 0000000000..15948ffadf --- /dev/null +++ b/packages/cpt-ui/components/EpsLogoutModal.tsx @@ -0,0 +1,46 @@ +"use client"; +import React from "react"; +import { Button, Container } from "nhsuk-react-components" + +import { EpsModal } from "@/components/EpsModal"; +import { EpsLogoutModalStrings } from "@/constants/ui-strings/EpsLogoutModalStrings"; + +interface EpsLogoutModalProps { + readonly isOpen: boolean; + readonly onClose: () => void; + readonly onConfirm: () => void; +} + +export function EpsLogoutModal({ isOpen, onClose, onConfirm }: EpsLogoutModalProps) { + + return ( + + + +

    + {EpsLogoutModalStrings.title}

    +

    {EpsLogoutModalStrings.caption}

    + +
    + + + +
    +
    +
    + ); +} diff --git a/packages/cpt-ui/components/EpsModal.tsx b/packages/cpt-ui/components/EpsModal.tsx new file mode 100644 index 0000000000..ec29d24e93 --- /dev/null +++ b/packages/cpt-ui/components/EpsModal.tsx @@ -0,0 +1,68 @@ +"use client"; +import React, { useEffect } from "react"; + +import "@/assets/styles/EpsModal.scss"; + +interface EpsModalProps { + readonly children: React.ReactNode; + readonly isOpen: boolean; + readonly onClose: () => void; +} + +export function EpsModal({ children, isOpen, onClose }: EpsModalProps) { + // Close modal on `Escape` key + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + onClose(); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [onClose]); + + // Close if user clicks outside the modal content + const handleBackdropClick = (e: React.MouseEvent) => { + if (e.target === e.currentTarget) { + onClose(); + } + }; + + // Close if user activates on the background + const handleBackdropActivate = (e: React.KeyboardEvent) => { + if (e.key === "Enter" || e.key === " ") { + onClose(); + } + }; + + // If the modal isn’t open, don’t render anything + if (!isOpen) return null; + + return ( + // This should be a button for accessibility, but we can't have buttons be descendants of buttons, + // and the modal children will have buttons in it. + // (making this a button does actually work, so this might be a FIXME to solve the hydration error) +
    + + + + {children} + + +
    + ) +} diff --git a/packages/cpt-ui/components/EpsSpinner.tsx b/packages/cpt-ui/components/EpsSpinner.tsx new file mode 100644 index 0000000000..ad22e2545d --- /dev/null +++ b/packages/cpt-ui/components/EpsSpinner.tsx @@ -0,0 +1,110 @@ +'use client'; +import React from 'react'; + +import { EpsSpinnerStrings } from '@/constants/ui-strings/EpsSpinnerStrings'; + + +function Spinner({ + radius = 100, + thickness = 12, + fraction = 0.2, // The fraction of the hoop that is green + speed = 1 // The speed (in seconds) for one full rotation +}) { + + // The portion that should appear green is defined by "fraction" + // If fraction = 0.25, then 25% of the hoop is green and 75% is grey + const circumference = 2 * Math.PI * radius; + const offset = circumference * (1 - fraction); + + return ( + // FIXME: In theory, this should be a , but doing that makes the spinner come out all funky. + // Someone better with CSS should fix that, since it would be better for accessibility. +
    +
    + + {/* Grey base circle (non-spinning) */} + + + {/* Spinning arc group */} + + + + + {/* "Loading..." text in the center */} + + {EpsSpinnerStrings.loading} + + + + {/* Inline keyframes for the spin animation */} + +
    +
    + ); +} + +export default Spinner; diff --git a/packages/cpt-ui/constants/ui-strings/CardStrings.ts b/packages/cpt-ui/constants/ui-strings/CardStrings.ts index 7f570f67fa..d9a4912991 100644 --- a/packages/cpt-ui/constants/ui-strings/CardStrings.ts +++ b/packages/cpt-ui/constants/ui-strings/CardStrings.ts @@ -24,6 +24,5 @@ export const SELECT_YOUR_ROLE_PAGE_TEXT = { noODSCode: "No ODS code", noRoleName: "No role name", noAddress: "Address not found", - errorDuringRoleSelection: "Error during role selection", - loadingMessage: "Loading..." + errorDuringRoleSelection: "Error during role selection" } diff --git a/packages/cpt-ui/constants/ui-strings/EpsLoginPageStrings.ts b/packages/cpt-ui/constants/ui-strings/EpsLoginPageStrings.ts new file mode 100644 index 0000000000..77c1894ff1 --- /dev/null +++ b/packages/cpt-ui/constants/ui-strings/EpsLoginPageStrings.ts @@ -0,0 +1,3 @@ +export const EpsLoginPageStrings = { + redirecting_msg: "Redirecting to CIS2 login page..." +} diff --git a/packages/cpt-ui/constants/ui-strings/EpsLogoutModalStrings.ts b/packages/cpt-ui/constants/ui-strings/EpsLogoutModalStrings.ts new file mode 100644 index 0000000000..189b0ec753 --- /dev/null +++ b/packages/cpt-ui/constants/ui-strings/EpsLogoutModalStrings.ts @@ -0,0 +1,6 @@ +export const EpsLogoutModalStrings = { + title: "Are you sure you want to log out?", + caption: "Logging out will end your session.", + confirmButtonText: "Log out", + cancelButtonText: "Cancel" +} diff --git a/packages/cpt-ui/constants/ui-strings/EpsLogoutPageStrings.ts b/packages/cpt-ui/constants/ui-strings/EpsLogoutPageStrings.ts new file mode 100644 index 0000000000..ca8db8c007 --- /dev/null +++ b/packages/cpt-ui/constants/ui-strings/EpsLogoutPageStrings.ts @@ -0,0 +1,6 @@ +export const EpsLogoutStrings = { + loading: "Logging out", + title: "Logout successful", + body: "You are now logged out of the service. To continue using the service, you must log in again.", + login_link: "Log in" +} diff --git a/packages/cpt-ui/constants/ui-strings/EpsSpinnerStrings.ts b/packages/cpt-ui/constants/ui-strings/EpsSpinnerStrings.ts new file mode 100644 index 0000000000..98e05ea588 --- /dev/null +++ b/packages/cpt-ui/constants/ui-strings/EpsSpinnerStrings.ts @@ -0,0 +1,3 @@ +export const EpsSpinnerStrings = { + loading: "Loading..." +} diff --git a/packages/cpt-ui/context/AuthProvider.tsx b/packages/cpt-ui/context/AuthProvider.tsx index 26323eebf6..29ba4b4bf1 100644 --- a/packages/cpt-ui/context/AuthProvider.tsx +++ b/packages/cpt-ui/context/AuthProvider.tsx @@ -155,17 +155,20 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => { */ const cognitoSignOut = async () => { console.log("Signing out..."); - // Immediately reset state to signed out. - setUser(null); - setAccessToken(null); - setIdToken(null); - setIsSignedIn(false); - setError(null) + + // TODO: Also sign out of the CPT API, so it can delete the token + // This is blocked until we have a central Dynamo interaction lambda try { await signOut({ global: true }); console.log("Signed out successfully!"); - setError(null); + + // Immediately reset state to signed out. + setUser(null); + setAccessToken(null); + setIdToken(null); + setIsSignedIn(false); + setError(null) } catch (err) { console.error("Failed to sign out:", err); setError(String(err)); diff --git a/packages/cpt-ui/jest.config.ts b/packages/cpt-ui/jest.config.ts index 505a4a0be5..3c19569205 100644 --- a/packages/cpt-ui/jest.config.ts +++ b/packages/cpt-ui/jest.config.ts @@ -12,11 +12,7 @@ const customJestConfig = { moduleDirectories: ["node_modules", "/"], testEnvironment: "jest-environment-jsdom", moduleNameMapper: { - "^@/context/(.*)$": "/context/$1", - "^@/app/(.*)$": "/app/$1", - "^@/constants/(.*)$": "/constants/$1", - "^@/assets/(.*)$": "/assets/$1", - "^@/components/(.*)$": "/components/$1" + "^@/(.*)$": "/$1" } } diff --git a/packages/cpt-ui/next.config.js b/packages/cpt-ui/next.config.js index e69e90425f..84458f223a 100644 --- a/packages/cpt-ui/next.config.js +++ b/packages/cpt-ui/next.config.js @@ -13,7 +13,8 @@ const nextConfig = { return [ { source: "/api/:path*", - destination: `${process.env.API_DOMAIN_OVERRIDE}/api/:path*` + destination: `${process.env.API_DOMAIN_OVERRIDE}/api/:path*`, + basePath: false } ] } diff --git a/packages/cpt-ui/tsconfig.json b/packages/cpt-ui/tsconfig.json index 095fcbcc42..c29f3a6819 100644 --- a/packages/cpt-ui/tsconfig.json +++ b/packages/cpt-ui/tsconfig.json @@ -11,11 +11,7 @@ "compilerOptions": { "baseUrl": ".", "paths": { - "@/context/*": ["context/*"], - "@/app/*": ["app/*"], - "@/constants/*": ["constants/*"], - "@/assets/*": ["assets/*"], - "@/components/*": ["components/*"] + "@/*": ["*"], }, "lib": [ "dom", diff --git a/scripts/run_regression_tests.py b/scripts/run_regression_tests.py index f1ff61052b..e18e85fca1 100644 --- a/scripts/run_regression_tests.py +++ b/scripts/run_regression_tests.py @@ -13,7 +13,7 @@ from requests.auth import HTTPBasicAuth # This should be set to a known good version of regression test repo -REGRESSION_TESTS_REPO_TAG = "v2.5.0" +REGRESSION_TESTS_REPO_TAG = "v2.5.1" GITHUB_API_URL = "https://api.github.com/repos/NHSDigital/electronic-prescription-service-api-regression-tests/actions" GITHUB_RUN_URL = "https://github.com/NHSDigital/electronic-prescription-service-api-regression-tests/actions/runs"