diff --git a/packages/auth_demo/src/App.tsx b/packages/auth_demo/src/App.tsx index f72df9675e..f75ba6af61 100644 --- a/packages/auth_demo/src/App.tsx +++ b/packages/auth_demo/src/App.tsx @@ -18,7 +18,7 @@ function App() { const [isSignedIn, setIsSignedIn] = useState(false) const [idToken, setIdToken] = useState(null) const [accessToken, setAccessToken] = useState(null) - const [trackerUserInfoData, setTrackerUserInfoData] = useState(null) + const [trackerUserInfoData, setTrackerUserInfoData] = useState(null) const [loading, setLoading] = useState(false) useEffect(() => { diff --git a/packages/cpt-ui/__tests__/AuthDemoPage.test.tsx b/packages/cpt-ui/__tests__/AuthDemoPage.test.tsx index b90a7d7c25..11619cedf9 100644 --- a/packages/cpt-ui/__tests__/AuthDemoPage.test.tsx +++ b/packages/cpt-ui/__tests__/AuthDemoPage.test.tsx @@ -74,7 +74,7 @@ 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/AuthContext"; +import { AuthContext } from "../context/AuthProvider"; import AuthPage from "../app/auth_demo/page"; describe("AuthPage", () => { diff --git a/packages/cpt-ui/__tests__/AuthProvider.test.tsx b/packages/cpt-ui/__tests__/AuthProvider.test.tsx index 5d20b06855..00577a3e0b 100644 --- a/packages/cpt-ui/__tests__/AuthProvider.test.tsx +++ b/packages/cpt-ui/__tests__/AuthProvider.test.tsx @@ -1,46 +1,46 @@ import React, { useContext } from 'react'; -import { render, waitFor, screen } from '@testing-library/react'; +import { render, waitFor, screen, act } from '@testing-library/react'; import { Buffer } from 'buffer'; import { Amplify } from 'aws-amplify'; import { Hub } from "aws-amplify/utils"; import { signInWithRedirect, signOut, getCurrentUser, fetchAuthSession } from 'aws-amplify/auth'; -import { AuthContext, AuthProvider } from "@/context/AuthContext"; +import { AuthContext, AuthProvider } from "@/context/AuthProvider"; - -// Mock environment variables if needed +// Mock environment variables to mimic the real environment process.env.NEXT_PUBLIC_userPoolId = 'testUserPoolId'; process.env.NEXT_PUBLIC_userPoolClientId = 'testUserPoolClientId'; process.env.NEXT_PUBLIC_hostedLoginDomain = 'testDomain'; process.env.NEXT_PUBLIC_redirectSignIn = 'http://localhost:3000'; process.env.NEXT_PUBLIC_redirectSignOut = 'http://localhost:3000'; -// Mock the AWS Amplify related functions and Hub +// Mock AWS Amplify functions to isolate AuthProvider logic jest.mock('aws-amplify', () => ({ Amplify: { - configure: jest.fn() + configure: jest.fn(), // Mock Amplify configuration }, })); jest.mock('aws-amplify/auth', () => ({ - signInWithRedirect: jest.fn(), - signOut: jest.fn(), - getCurrentUser: jest.fn(), - fetchAuthSession: jest.fn() + signInWithRedirect: jest.fn(), // Mock redirect sign-in + signOut: jest.fn(), // Mock sign-out + getCurrentUser: jest.fn(), // Mock current user retrieval + fetchAuthSession: jest.fn(), // Mock session fetch })); jest.mock('aws-amplify/utils', () => ({ Hub: { - listen: jest.fn(), + listen: jest.fn(), // Mock Amplify Hub for event listening }, })); -// A test component that consumes the AuthContext +// A helper component to consume the AuthContext and expose its values for testing const TestConsumer = () => { - const auth = useContext(AuthContext); - if (!auth) return null; + const auth = useContext(AuthContext); // Access the AuthContext + if (!auth) return null; // Return nothing if context is not available + // Render state values for testing return (
{auth.isSignedIn ? 'true' : 'false'}
@@ -52,84 +52,98 @@ const TestConsumer = () => { ); }; +// Test suite for AuthProvider describe('AuthProvider', () => { + // Variable to store the callback for Amplify Hub events let hubCallback: ((data: any) => void) | null = null; - const idTokenPayload = { exp: Math.floor(Date.now() / 1000) + 3600 }; - const accessTokenPayload = { exp: Math.floor(Date.now() / 1000) + 3600 }; + // Token payloads for mock sessions + const idTokenPayload = { exp: Math.floor(Date.now() / 1000) + 3600 }; // Valid token + const accessTokenPayload = { exp: Math.floor(Date.now() / 1000) + 3600 }; // Valid token + // Helper function to create mock tokens const createTokenMocks = () => ({ tokens: { idToken: { toString: () => `header.${btoa(JSON.stringify(idTokenPayload))}.signature`, - payload: idTokenPayload + payload: idTokenPayload, }, accessToken: { toString: () => `header.${btoa(JSON.stringify(accessTokenPayload))}.signature`, - payload: accessTokenPayload - } - } + payload: accessTokenPayload, + }, + }, }); - type RenderWithProviderOptions = { - sessionMock?: { tokens: Record }; - userMock?: any | null; // userMock can be `null` or `any` - TestComponent?: JSX.Element; - }; - + // Helper function to render the provider and optionally inject mock session and user const renderWithProvider = async ({ sessionMock = { tokens: {} }, - userMock = null, + userMock = null as { username: string } | null, // Allow userMock to be null or an object with username TestComponent = , - }: RenderWithProviderOptions = {}) => { + } = {}) => { + // Mock session and user fetch (fetchAuthSession as jest.Mock).mockResolvedValue(sessionMock); (getCurrentUser as jest.Mock).mockResolvedValue(userMock); - render( - - {TestComponent} - - ); + // Render the AuthProvider with the specified TestComponent + await act(async () => { + render( + + {TestComponent} + + ); + }); + + // Ensure Amplify.configure was called await waitFor(() => { expect(Amplify.configure).toHaveBeenCalled(); }); }; + // Global setup for encoding functions beforeAll(() => { global.atob = (str) => Buffer.from(str, 'base64').toString('binary'); global.btoa = (str) => Buffer.from(str, 'binary').toString('base64'); }); + // Reset mocks before each test beforeEach(() => { - jest.restoreAllMocks(); + jest.restoreAllMocks(); // Restore all mock implementations (Hub.listen as jest.Mock).mockImplementation((channel, callback) => { if (channel === 'auth') { - hubCallback = callback; + hubCallback = callback; // Store the Hub callback } - return () => {}; // unsubscribe mock + return () => {}; // Mock unsubscribe function }); }); + // Initialization and Configuration it('should configure Amplify on mount', async () => { + // Verify Amplify.configure is called when the provider mounts await renderWithProvider(); - // Just by rendering, we verify configuration was called + expect(Amplify.configure).toHaveBeenCalled(); }); + // Session Handling it('should set isSignedIn to false if no valid tokens are returned', async () => { + // Render without valid tokens await renderWithProvider(); await waitFor(() => { + // Check that the signed-in state is false and user is null expect(screen.getByTestId('isSignedIn').textContent).toBe('false'); expect(screen.getByTestId('user').textContent).toBe(''); }); }); it('should set isSignedIn to true and user when valid tokens are returned', async () => { + // Render with valid tokens and a mock user await renderWithProvider({ sessionMock: createTokenMocks(), - userMock: { username: 'testuser' } + userMock: { username: 'testuser' }, }); await waitFor(() => { + // Check that the signed-in state is true and user is present expect(screen.getByTestId('isSignedIn').textContent).toBe('true'); expect(screen.getByTestId('user').textContent).toBe('UserPresent'); expect(screen.getByTestId('idToken').textContent).toBe('IdTokenPresent'); @@ -137,78 +151,279 @@ describe('AuthProvider', () => { }); }); - it('should handle Hub event signInWithRedirect and call getUser', async () => { - // Initially no tokens - const { rerender } = render( - - - - ); + it('should handle missing tokens during session fetch', async () => { + // Simulate a session fetch with missing tokens + const incompleteSession = { tokens: {} }; - (fetchAuthSession as jest.Mock).mockResolvedValueOnce({ tokens: {} }); + await renderWithProvider({ sessionMock: incompleteSession }); await waitFor(() => { + // Assert that the user is not signed in due to missing tokens expect(screen.getByTestId('isSignedIn').textContent).toBe('false'); + expect(screen.getByTestId('user').textContent).toBe(''); }); + }); - // On signInWithRedirect event, simulate tokens and user - (fetchAuthSession as jest.Mock).mockResolvedValue(createTokenMocks()); - (getCurrentUser as jest.Mock).mockResolvedValue({ username: 'testuser' }); + // Error Handling + it('should handle fetchAuthSession failure', async () => { + // Mock fetchAuthSession to throw an error + (fetchAuthSession as jest.Mock).mockRejectedValue(new Error('Session fetch failed')); - // Trigger Hub event - if (hubCallback) { - hubCallback({ payload: { event: 'signInWithRedirect' } }); - } + await renderWithProvider(); - rerender( - - - - ); + await waitFor(() => { + // Assert that the user is not signed in due to session fetch failure + expect(screen.getByTestId('isSignedIn').textContent).toBe('false'); + expect(screen.getByTestId('user').textContent).toBe(''); + }); + }); + + it('should handle getCurrentUser failure gracefully', async () => { + // Mock getCurrentUser to throw an error + (getCurrentUser as jest.Mock).mockRejectedValue(new Error('User fetch failed')); + + await renderWithProvider({ + sessionMock: createTokenMocks(), + }); await waitFor(() => { + // Assert that valid tokens do not automatically result in user data due to user fetch failure expect(screen.getByTestId('isSignedIn').textContent).toBe('true'); - expect(screen.getByTestId('user').textContent).toBe('UserPresent'); + expect(screen.getByTestId('user').textContent).toBe(''); }); }); - it('should handle Hub event signInWithRedirect_failure', async () => { + it('should log an error and reset state when fetching user session fails', async () => { + // Mock console.error to track calls + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + + // Mock fetchAuthSession to throw an error + const sessionError = new Error('Session fetch failed'); + (fetchAuthSession as jest.Mock).mockRejectedValueOnce(sessionError); + + // Render the provider await renderWithProvider(); - if (hubCallback) { - hubCallback({ payload: { event: 'signInWithRedirect_failure' } }); + + // Wait for the state to be reset + await waitFor(() => { + // Verify that the state is reset correctly + expect(screen.getByTestId('isSignedIn').textContent).toBe('false'); + expect(screen.getByTestId('user').textContent).toBe(''); + }); + + // Verify that the error was logged + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Error fetching user session:', + sessionError + ); + + // Restore the original console.error implementation + consoleErrorSpy.mockRestore(); + }); + + it('should log an error if signOut fails', async () => { + // Mock console.error to track calls + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + + // Mock signOut to throw an error + const signOutError = new Error('Sign out failed'); + (signOut as jest.Mock).mockRejectedValue(signOutError); + + let contextValue: any; + + const TestComponent = () => { + contextValue = useContext(AuthContext); + return null; + }; + + // Render the provider + await act(async () => { + render( + + + + ); + }); + + // Attempt to sign out and verify the logged error + await act(async () => { + await contextValue.cognitoSignOut(); + }); + + expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to sign out:', signOutError); // Error logged + + // Restore the original console.error implementation + consoleErrorSpy.mockRestore(); + }); + + // Token Handling + it('should log a warning and reset state when the ID token is expired', async () => { + // Mock console.warn to track calls + const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); + + // Create mock tokens with an expired ID token + const expiredIdToken = { + tokens: { + idToken: { + toString: () => `header.${btoa(JSON.stringify({ exp: Math.floor(Date.now() / 1000) - 3600 }))}.signature`, + payload: { exp: Math.floor(Date.now() / 1000) - 3600 }, + }, + accessToken: { + toString: () => `header.${btoa(JSON.stringify({ exp: Math.floor(Date.now() / 1000) + 3600 }))}.signature`, + payload: { exp: Math.floor(Date.now() / 1000) + 3600 }, + }, + }, + }; + + // Render the provider with the expired ID token + await renderWithProvider({ sessionMock: expiredIdToken }); + + // Wait for the state to be reset and verify the warning + await waitFor(() => { + expect(screen.getByTestId('isSignedIn').textContent).toBe('false'); // State reset + expect(screen.getByTestId('user').textContent).toBe(''); + expect(consoleWarnSpy).toHaveBeenCalledWith( + 'ID token is expired. Consider refreshing the token.' + ); // Warning logged + }); + + // Restore the original console.warn implementation + consoleWarnSpy.mockRestore(); + }); + + it('should handle expired tokens', async () => { + // Create mock expired tokens + const expiredTokens = { + tokens: { + idToken: { + toString: () => `header.${btoa(JSON.stringify({ exp: Math.floor(Date.now() / 1000) - 3600 }))}.signature`, + payload: { exp: Math.floor(Date.now() / 1000) - 3600 }, + }, + accessToken: { + toString: () => `header.${btoa(JSON.stringify({ exp: Math.floor(Date.now() / 1000) - 3600 }))}.signature`, + payload: { exp: Math.floor(Date.now() / 1000) - 3600 }, + }, + }, + }; + + // Render with expired tokens + await renderWithProvider({ sessionMock: expiredTokens }); + + await waitFor(() => { + // Verify signed-in state is false and user is cleared + expect(screen.getByTestId('isSignedIn').textContent).toBe('false'); + expect(screen.getByTestId('user').textContent).toBe(''); + }); + }); + + // Hub Events + it('should handle Hub event signInWithRedirect', async () => { + // Mock session and user for a successful signInWithRedirect Hub event + const mockSession = createTokenMocks(); // Create valid mock tokens + const mockUser = { username: 'testuser' }; // Create a mock user object + + // Mock Amplify functions to return the mocked session and user + (fetchAuthSession as jest.Mock).mockResolvedValue(mockSession); + (getCurrentUser as jest.Mock).mockResolvedValue(mockUser); + + // Render the AuthProvider with a TestConsumer to observe context changes + await act(async () => { + render( + + + + ); + }); + + // Ensure the Hub event listener (hubCallback) is initialized + if (!hubCallback) { + throw new Error('hubCallback is not initialized'); } + + // Simulate the Hub event "signInWithRedirect" + act(() => { + // Simulate a successful Hub event for signInWithRedirect + hubCallback!({ payload: { event: 'signInWithRedirect' } }); + }); + + // Wait for the context state to update and verify changes + await waitFor(() => { + // Assert that the user is signed in after the Hub event + expect(screen.getByTestId('isSignedIn').textContent).toBe('true'); // User is signed in + expect(screen.getByTestId('user').textContent).toBe('UserPresent'); // User object is present + }); + }); + it('should handle Hub event signInWithRedirect_failure', async () => { + // Render the AuthProvider with a TestConsumer to observe context changes + await renderWithProvider(); + + // Ensure the Hub event listener (hubCallback) is initialized + if (!hubCallback) { + throw new Error('hubCallback is not initialized'); + } + + // Simulate the Hub event "signInWithRedirect_failure" + act(() => { + hubCallback!({ payload: { event: 'signInWithRedirect_failure' } }); + }); + + // Wait for the context state to update and verify changes await waitFor(() => { - expect(screen.getByTestId('error').textContent).toBe('An error has occurred during the OAuth flow.'); + // Assert that an error is set after the Hub event failure + expect(screen.getByTestId('error').textContent).toBe( + 'An error has occurred during the OAuth flow.' // Error state is updated + ); }); }); - it('should handle customOAuthState event', async () => { + it('should handle Hub event signedOut', async () => { + // Simulate a Hub event for user sign-out await renderWithProvider(); - if (hubCallback) { - hubCallback({ payload: { event: 'customOAuthState', data: 'my-custom-state' } }); + + if (!hubCallback) { + throw new Error('hubCallback is not initialized'); } + + act(() => { + hubCallback!({ payload: { event: 'signedOut' } }); + }); + + await waitFor(() => { + // Assert that the user is signed out and state is cleared + expect(screen.getByTestId('isSignedIn').textContent).toBe('false'); + expect(screen.getByTestId('user').textContent).toBe(''); + }); }); + // Auth Functions it('should provide cognitoSignIn and cognitoSignOut functions', async () => { let contextValue: any; - const TestConsumerWithFunctions = () => { + + // Create a test consumer to access the context + const TestComponent = () => { contextValue = useContext(AuthContext); return null; }; - await renderWithProvider({ TestComponent: }); - - await waitFor(() => { - expect(contextValue).toBeTruthy(); - expect(typeof contextValue.cognitoSignIn).toBe('function'); - expect(typeof contextValue.cognitoSignOut).toBe('function'); + await act(async () => { + render( + + + + ); }); - await contextValue.cognitoSignIn(); + // Verify that cognitoSignIn calls signInWithRedirect + await act(async () => { + await contextValue.cognitoSignIn(); + }); expect(signInWithRedirect).toHaveBeenCalled(); - await contextValue.cognitoSignOut(); + // Verify that cognitoSignOut calls signOut + await act(async () => { + await contextValue.cognitoSignOut(); + }); expect(signOut).toHaveBeenCalled(); }); }); diff --git a/packages/cpt-ui/__tests__/SelectYourRolePage.test.jsx b/packages/cpt-ui/__tests__/SelectYourRolePage.test.jsx index 0ee89fdfba..ea9eef293e 100644 --- a/packages/cpt-ui/__tests__/SelectYourRolePage.test.jsx +++ b/packages/cpt-ui/__tests__/SelectYourRolePage.test.jsx @@ -1,8 +1,8 @@ -import "@testing-library/jest-dom"; -import { render, screen } from "@testing-library/react"; -import Page from "../app/selectyourrole/page"; -import EpsHeader from "../components/EpsHeader"; import React from "react"; +import "@testing-library/jest-dom"; +import { render, screen, waitFor } from "@testing-library/react"; +import SelectYourRolePage from "@/app/selectyourrole/page"; +import { AuthContext } from "@/context/AuthProvider"; // Mock `next/navigation` globally jest.mock("next/navigation", () => ({ @@ -10,31 +10,179 @@ jest.mock("next/navigation", () => ({ useRouter: jest.fn(), })); -import { usePathname } from "next/navigation"; +// Define a global fetch mock +const mockFetch = jest.fn(); +global.fetch = mockFetch; -describe("SelectYourRole Page", () => { - it("renders a heading", () => { - render(); +describe("SelectYourRolePage", () => { + const mockAuthContextValue = { + isSignedIn: false, + idToken: "", + }; - const heading = screen.getByRole("heading", { level: 1 }); + // Helper: Renders component with optional custom AuthContext value + const renderWithAuth = (authValue = mockAuthContextValue) => { + return render( + + + + ); + }; - expect(heading).toBeInTheDocument(); - expect(heading).toHaveTextContent("Select your role"); + beforeEach(() => { + jest.clearAllMocks(); }); - it("renders the caption", () => { - render(); + it("renders a heading by default (not signed in) but eventually shows an error", async () => { + // Not signed in + renderWithAuth({ + isSignedIn: false, + idToken: "", + }); - const caption = screen.getByText(/Select the role you wish to use to access the service/i); + // Wait for error summary to appear + await waitFor(() => { + const errorHeading = screen.getByRole("heading", { name: /Error during role selection/i }); + expect(errorHeading).toBeInTheDocument(); + const errorText = screen.getByText("No login session found"); + expect(errorText).toBeInTheDocument(); + }); + }); - expect(caption).toBeInTheDocument(); + it("renders loading state when signed in but fetch hasn't resolved yet", async () => { + // Mock fetch to never resolve + mockFetch.mockImplementation(() => new Promise(() => {})); + + renderWithAuth({ + isSignedIn: true, + idToken: "fake-token", + }); + + // Should show "Loading..." text + const loadingText = screen.getByText(/loading.../i); + expect(loadingText).toBeInTheDocument(); }); - it("renders the main container", () => { - render(); + it("renders error summary if fetch returns non-200 status", async () => { + // Mock fetch to return 500 status + mockFetch.mockResolvedValue({ + status: 500, + }); - const container = screen.getByRole("contentinfo"); + renderWithAuth({ + isSignedIn: true, + idToken: "fake-token", + }); - expect(container).toBeInTheDocument(); + // Expect error summary to appear + await waitFor(() => { + const errorHeading = screen.getByRole("heading", { name: /Error during role selection/i }); + expect(errorHeading).toBeInTheDocument(); + const errorItem = screen.getByText("Failed to fetch CPT user info"); + expect(errorItem).toBeInTheDocument(); + }); }); + + it("renders error summary if fetch returns 200 but the JSON body has no userInfo key", async () => { + // Mock a 200 response without `userInfo` + mockFetch.mockResolvedValue({ + status: 200, + json: async () => ({}), // Missing userInfo + }); + + renderWithAuth({ + isSignedIn: true, + idToken: "fake-token", + }); + + // Expect error summary to appear + await waitFor(() => { + const errorHeading = screen.getByRole("heading", { name: /Error during role selection/i }); + expect(errorHeading).toBeInTheDocument(); + const errorItem = screen.getByText("Failed to fetch CPT user info"); + expect(errorItem).toBeInTheDocument(); + }); + }); + + it("renders the page content when valid userInfo is returned", async () => { + const mockUserInfo = { + roles_with_access: [ + { + role_name: "Pharmacist", + role_id: "pharm1", + org_code: "ORG123", + org_name: "Test Pharmacy Org", + site_name: "Pharmacy Site", + site_address: "1 Fake Street", + }, + ], + roles_without_access: [ + { + role_name: "Technician", + role_id: "tech1", + org_code: "ORG456", + org_name: "Tech Org", + site_name: "Technician Site", + site_address: "2 Fake Street", + }, + ], + }; + + // Mock a successful 200 response + mockFetch.mockResolvedValue({ + status: 200, + json: async () => ({ + userInfo: mockUserInfo, + }), + }); + + renderWithAuth({ + isSignedIn: true, + idToken: "fake-token", + }); + + // Wait for normal state to appear (no errors) + await waitFor(() => { + // Title + const heading = screen.getByRole("heading", { level: 1 }); + expect(heading).toHaveTextContent("Select your role"); // from SELECT_ROLE_PAGE_TEXT + }); + + // Check the caption + const caption = screen.getByText(/Select the role you wish to use to access the service/i); + expect(caption).toBeInTheDocument(); + + // Check that the "contentinfo" container is rendered + const container = screen.getByRole("contentinfo"); + expect(container).toBeInTheDocument(); + + // Check for confirm button text + const confirmButton = screen.getByRole("button", { name: /Confirm and continue/i }); + expect(confirmButton).toBeInTheDocument(); + + // Roles Without Access details expander + const expander = screen.getByText("Roles without access"); + expect(expander).toBeInTheDocument(); + + // Confirm the card is rendered + const cardHeading = await screen.findByRole("heading", { name: /Tech Org \(ODS: ORG456\)/i }); + expect(cardHeading).toBeInTheDocument(); + + // Confirm the card for "Technician" is rendered + const cardRole = screen.getByText("Technician", { + selector: ".eps-card__roleName", // Target the class for role names inside the card + }); + expect(cardRole).toBeInTheDocument(); + + // Confirm the table cell is rendered + const tableCell = await screen.findByText(/Tech Org \(ODS: ORG456\)/i, { selector: "td" }); + expect(tableCell).toBeInTheDocument(); + + // Confirm the table cell for "Technician" is rendered + const tableRoleCell = screen.getByText("Technician", { + selector: "td", // Target the table cell + }); + + expect(tableRoleCell).toBeInTheDocument(); + }); }); diff --git a/packages/cpt-ui/__tests__/page.test.jsx b/packages/cpt-ui/__tests__/page.test.jsx index dbf23a6a55..cd83c0ed11 100644 --- a/packages/cpt-ui/__tests__/page.test.jsx +++ b/packages/cpt-ui/__tests__/page.test.jsx @@ -28,7 +28,7 @@ jest.mock("../context/configureAmplify", () => ({ })); // Mock the AuthContext with a valid value -jest.mock("../context/AuthContext", () => { +jest.mock("../context/AuthProvider", () => { const mockAuthContext = { signInWithRedirect: jest.fn(), signOut: jest.fn(), diff --git a/packages/cpt-ui/app/auth_demo/page.tsx b/packages/cpt-ui/app/auth_demo/page.tsx index d664d4cb26..232c73fcce 100644 --- a/packages/cpt-ui/app/auth_demo/page.tsx +++ b/packages/cpt-ui/app/auth_demo/page.tsx @@ -2,7 +2,7 @@ import React, {useContext, useEffect} from "react"; import { Container, Col, Row, Button } from "nhsuk-react-components"; -import { AuthContext } from "@/context/AuthContext"; +import { AuthContext } from "@/context/AuthProvider"; export default function AuthPage() { const auth = useContext(AuthContext); diff --git a/packages/cpt-ui/app/layout.tsx b/packages/cpt-ui/app/layout.tsx index 054f1bf27a..29f08f85af 100644 --- a/packages/cpt-ui/app/layout.tsx +++ b/packages/cpt-ui/app/layout.tsx @@ -4,7 +4,7 @@ import React from "react"; import 'nhsuk-frontend/dist/nhsuk.css'; import EpsHeader from '@/components/EpsHeader' import EpsFooter from '@/components/EpsFooter' -import { AuthProvider } from '@/context/AuthContext' +import { AuthProvider } from '@/context/AuthProvider' export default function RootLayout({ children, diff --git a/packages/cpt-ui/app/selectyourrole/page.tsx b/packages/cpt-ui/app/selectyourrole/page.tsx index 70dfc05791..2453f89d86 100644 --- a/packages/cpt-ui/app/selectyourrole/page.tsx +++ b/packages/cpt-ui/app/selectyourrole/page.tsx @@ -1,26 +1,202 @@ 'use client' -import React from "react" +import React, {useState, useEffect, useContext, useCallback } from "react" +import { Container, Col, Row, Details, Table, ErrorSummary, Button, InsetText } from "nhsuk-react-components" +import { AuthContext } from "@/context/AuthProvider"; +import EpsCard from "@/components/EpsCard"; +import {SELECT_ROLE_PAGE_TEXT} from "@/constants/ui-strings/CardStrings"; + +export type RoleDetails = { + role_name?: string; + role_id?: string; + org_code?: string; + org_name?: string; + site_name?: string; + site_address?: string; +}; + +export type TrackerUserInfo = { + roles_with_access: Array; + roles_without_access: Array; + currently_selected_role?: RoleDetails; +}; + +const trackerUserInfoEndpoint = "/api/tracker-user-info" + +export default function SelectYourRolePage() { + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [trackerUserInfoData, setTrackerUserInfoData] = useState(null) + + const auth = useContext(AuthContext); + + const fetchTrackerUserInfo = useCallback(async () => { + setLoading(true) + setTrackerUserInfoData(null) + setError(null) + + if (!auth?.isSignedIn || !auth) { + setLoading(false) + setError("Not signed in") + return; + } + + try { + const response = await fetch(trackerUserInfoEndpoint, { + headers: { + Authorization: `Bearer ${auth?.idToken}`, + 'NHSD-Session-URID': '555254242106' + } + }) + + if (response.status !== 200) { + throw new Error("Server did not return CPT user info") + } + + const data = await response.json() + + if (!data.userInfo) { + throw new Error("Server response did not contain data") + } + + setTrackerUserInfoData(data.userInfo) + } catch (err) { + setError("Failed to fetch CPT user info") + console.error("error fetching tracker user info:", err) + } finally { + setLoading(false) + } + }, [auth]) + + useEffect(() => { + if (auth?.isSignedIn === undefined) { + return + } + + if (auth?.isSignedIn) { + fetchTrackerUserInfo() + } else { + setError("No login session found") + } + }, [auth?.isSignedIn, fetchTrackerUserInfo]) + + // If the data is being fetched, replace the content with a spinner + if (loading) { + return ( +
+ + + +

Loading...

+ +
+
+
+ ); + } + + // If the process encounters an error, replace the content with an error summary + if (error) { + return ( +
+ + + + +

Error during role selection

+
+ + + {error} + + +
+
+
+
+ ); + } + + const { title, caption, insetText, confirmButton, alternativeMessage, organisation, role } = + SELECT_ROLE_PAGE_TEXT; -import {Container, Col, Row} from "nhsuk-react-components" -export default function Page() { return ( -
- +
+ + {/* Title Section */} - - - -

- Select your role - - - - Select the role you wish to use to access the service. -

- -
-
+ +

+ + {title} + + - + {caption} + + +

+ {/* Inset Text Section */} +
+ + {insetText.visuallyHidden} +

{insetText.message}

+
+ {/* Confirm Button */} + +

{alternativeMessage}

+
+ + + {/* Roles with access Section */} + +
+ {trackerUserInfoData?.roles_without_access?.map((role, index) => ( + + ))} +
+ + + {/* Roles without access Section */} + +
+ + Roles without access + + + + + + {organisation} + {role} + + + + {trackerUserInfoData?.roles_without_access?.map((role, index) => ( + + + {role.org_name ? role.org_name : "NO ORG NAME"} (ODS: {role.org_code}) + + + {role.role_name} + + + ))} + +
+
+
+
- ) + ); } diff --git a/packages/cpt-ui/assets/styles/card.scss b/packages/cpt-ui/assets/styles/card.scss new file mode 100644 index 0000000000..5554592f50 --- /dev/null +++ b/packages/cpt-ui/assets/styles/card.scss @@ -0,0 +1,58 @@ +.eps-card { + margin-bottom: 1.5rem; + border: 1px solid #d8dde0; + border-radius: 4px; + padding: 1rem; + background-color: #ffffff; + + &__content { + display: flex; + align-items: flex-start; + } + + &__orgName { + color: #005eb8; + font-size: 19px; + font-weight: 600; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } + + &__roleName { + margin-top: 0.5rem; + margin-bottom: 24px; + font-weight: normal; + font-size: 16px; + line-height: 1.5; + color: #4c6272; + } + + &__siteAddress { + text-align: right; + color: #4c6272; + margin-top: 0.5rem; + font-size: 1rem; + line-height: 1.5; + &-line { + display: block; + } + } + + &__link { + background-color: #ffffff; + border: 1px solid transparent; + box-shadow: 0 4px 0 0 #d8dde0; + display: block; + height: 100%; + position: relative; + text-decoration: none; +} + + .nhsuk-grid-column-one-half { + padding-left: 1rem; + padding-right: 1rem; + } +} diff --git a/packages/cpt-ui/components/EpsCard.tsx b/packages/cpt-ui/components/EpsCard.tsx new file mode 100644 index 0000000000..4806589cb6 --- /dev/null +++ b/packages/cpt-ui/components/EpsCard.tsx @@ -0,0 +1,56 @@ +import React from "react"; +import { Card, Col, Row } from "nhsuk-react-components"; +import "../assets/styles/card.scss"; + +interface EpsCardProps { + orgName: string; + odsCode: string; + siteAddress: string | null; + roleName: string; + link: string; +} + +export default function EpsCard({ + orgName, + odsCode, + siteAddress, + roleName, + link, +}: EpsCardProps) { + return ( + + + + + {/* Left Column: org_name and role_name */} + + + + {orgName} (ODS: {odsCode}) + + + + {roleName} + + + + {/* Right Column: siteAddress */} + + + {siteAddress && + siteAddress.split("\n").map((line: string, index: number) => ( + + {line} +
+
+ ))} +
+ +
+
+
+ ); +} diff --git a/packages/cpt-ui/constants/ui-strings/CardStrings.ts b/packages/cpt-ui/constants/ui-strings/CardStrings.ts new file mode 100644 index 0000000000..49c550a947 --- /dev/null +++ b/packages/cpt-ui/constants/ui-strings/CardStrings.ts @@ -0,0 +1,16 @@ +export const SELECT_ROLE_PAGE_TEXT = { + title: "Select your role", + caption: "Select the role you wish to use to access the service.", + insetText: { + visuallyHidden: "Information: ", + message: + "You are currently logged in at GREENE'S PHARMACY (ODS: FG419) with Health Professional Access Role." + }, + confirmButton: { + text: "Confirm and continue to find a prescription", + link: "tracker-presc-no" + }, + alternativeMessage: "Alternatively, you can choose a new role below.", + organisation: "Organisation", + role: "Role" +} diff --git a/packages/cpt-ui/context/AuthContext.tsx b/packages/cpt-ui/context/AuthProvider.tsx similarity index 95% rename from packages/cpt-ui/context/AuthContext.tsx rename to packages/cpt-ui/context/AuthProvider.tsx index f8a13806e7..29cb3eda31 100644 --- a/packages/cpt-ui/context/AuthContext.tsx +++ b/packages/cpt-ui/context/AuthProvider.tsx @@ -92,13 +92,22 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => { case "signedIn": console.log("User %s logged in", payload.data.username); setError(null); + break; case "tokenRefresh": console.log("Refreshing token"); + setError(null); + break; case "signInWithRedirect": setError(null); break; case "tokenRefresh_failure": + setError("An error has occurred during the OAuth flow."); + setIsSignedIn(false); + setUser(null); + setIdToken(null); + setAccessToken(null); + break; case "signInWithRedirect_failure": setError("An error has occurred during the OAuth flow."); setIsSignedIn(false); diff --git a/packages/cpt-ui/jest.config.ts b/packages/cpt-ui/jest.config.ts index 72080ab22c..21f31ad367 100644 --- a/packages/cpt-ui/jest.config.ts +++ b/packages/cpt-ui/jest.config.ts @@ -7,6 +7,13 @@ const customJestConfig = { coverageDirectory: "coverage", coverageProvider: "v8", moduleDirectories: ["node_modules", "/"], - testEnvironment: "jest-environment-jsdom" + testEnvironment: "jest-environment-jsdom", + moduleNameMapper: { + "^@/context/(.*)$": "/context/$1", + "^@/app/(.*)$": "/app/$1", + "^@/constants/(.*)$": "/constants/$1", + "^@/assets/(.*)$": "/assets/$1", + "^@/components/(.*)$": "/components/$1" + } } module.exports = createJestConfig(customJestConfig) diff --git a/packages/cpt-ui/next.config.js b/packages/cpt-ui/next.config.js index 3a7371ce2d..e69e90425f 100644 --- a/packages/cpt-ui/next.config.js +++ b/packages/cpt-ui/next.config.js @@ -3,15 +3,23 @@ */ const nextConfig = { output: process.env.NEXT_OUTPUT_MODE || undefined, - basePath: process.env.BASE_PATH || "" - // Optional: Change links `/me` -> `/me/` and emit `/me.html` -> `/me/index.html` - // trailingSlash: true, + basePath: process.env.BASE_PATH || "", - // Optional: Prevent automatic `/me` -> `/me/`, instead preserve `href` - // skipTrailingSlashRedirect: true, + // If we're using a local development server, we want to be able + // to call out to an actual API for testing. + // This rewrites all requests to the /api/ path. + async rewrites() { + if (process.env.LOCAL_DEV) { + return [ + { + source: "/api/:path*", + destination: `${process.env.API_DOMAIN_OVERRIDE}/api/:path*` + } + ] + } - // Optional: Change the output directory `out` -> `dist` - // distDir: 'dist', + return [] + } } module.exports = nextConfig diff --git a/packages/cpt-ui/tsconfig.json b/packages/cpt-ui/tsconfig.json index 028fbf3f8f..095fcbcc42 100644 --- a/packages/cpt-ui/tsconfig.json +++ b/packages/cpt-ui/tsconfig.json @@ -12,6 +12,7 @@ "baseUrl": ".", "paths": { "@/context/*": ["context/*"], + "@/app/*": ["app/*"], "@/constants/*": ["constants/*"], "@/assets/*": ["assets/*"], "@/components/*": ["components/*"] diff --git a/scripts/run_regression_tests.py b/scripts/run_regression_tests.py index 363d4203df..8ab609ecee 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.0.8" +REGRESSION_TESTS_REPO_TAG = "AEA-4651-roles-with-access" 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" diff --git a/sonar-project.properties b/sonar-project.properties index 30091ed93c..ede26607ad 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -21,4 +21,5 @@ sonar.javascript.lcov.reportPaths=packages/cpt-ui/coverage/lcov.info, \ sonar.cpd.exclusions=\ packages/cloudfrontFunctions/tests/*, \ packages/auth_demo/**, \ - **/mock* + **/mock*, \ + **/*.test.ts*