diff --git a/frontend/e2e/App.spec.ts b/frontend/e2e/App.spec.ts index 9effb67c..e28a7ad3 100644 --- a/frontend/e2e/App.spec.ts +++ b/frontend/e2e/App.spec.ts @@ -3,11 +3,21 @@ import { test, expect } from "@playwright/test"; test("has STLT Name", async ({ page }) => { await page.goto("/"); + await page.evaluate(() => { + localStorage.setItem('auth_token', 'token'); + }); + await page.goto("/"); + await expect(page.getByText("Demo STLT")).toBeVisible(); }); test("has new template button", async ({ page, baseURL }) => { await page.goto("/"); + await page.evaluate(() => { + localStorage.setItem('auth_token', 'token'); + }); + await page.goto("/"); + await expect( page.getByRole("button", { name: "+ New Template" }), ).toBeVisible(); @@ -18,6 +28,9 @@ test("has new template button", async ({ page, baseURL }) => { test.describe("when templates exist", async () => { test.beforeEach(async ({ page }) => { await page.goto("/"); + await page.evaluate(() => { + localStorage.setItem('auth_token', 'token'); + }); await page.evaluate(() => { const templates = [ { diff --git a/frontend/e2e/ReviewTemplate.spec.ts b/frontend/e2e/ReviewTemplate.spec.ts index c8360403..ba283392 100644 --- a/frontend/e2e/ReviewTemplate.spec.ts +++ b/frontend/e2e/ReviewTemplate.spec.ts @@ -2,8 +2,13 @@ import { test, expect } from "@playwright/test"; test.describe("ReviewTemplate Page", () => { test.beforeEach(async ({ page }) => { + // Navigate to the ReviewTemplate page await page.goto("/extract/review"); + await page.evaluate(() => { + localStorage.setItem('auth_token', 'token'); + }); + await page.goto("/extract/review"); }); // Test the Back button functionality diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 061b107b..70de8bca 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "frontend", "version": "0.0.0", "dependencies": { + "@hookform/resolvers": "^3.10.0", "@tanstack/react-query": "^5.59.20", "@trussworks/react-uswds": "^9.0.0", "@types/hex-rgb": "^3.0.0", @@ -21,8 +22,10 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "react-error-boundary": "^5.0.0", + "react-hook-form": "^7.54.2", "react-image-label": "^1.3.4", "react-router-dom": "^6.26.0", + "yup": "^1.6.1", "zustand": "^5.0.1" }, "devDependencies": { @@ -711,6 +714,15 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@hookform/resolvers": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.10.0.tgz", + "integrity": "sha512-79Dv+3mDF7i+2ajj7SkypSKHhl1cbln1OGavqrsF7p6mbUv11xpqpacPsGDCTRvCSjEEIez2ef1NveSVL3b0Ag==", + "license": "MIT", + "peerDependencies": { + "react-hook-form": "^7.0.0" + } + }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", @@ -5376,6 +5388,12 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/property-expr": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.6.tgz", + "integrity": "sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==", + "license": "MIT" + }, "node_modules/psl": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", @@ -5458,6 +5476,22 @@ "react": ">=16.13.1" } }, + "node_modules/react-hook-form": { + "version": "7.54.2", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.54.2.tgz", + "integrity": "sha512-eHpAUgUjWbZocoQYUHposymRb4ZP6d0uwUnooL2uOybA9/3tPUvoAKqEWK1WaSiTxxOfTpffNZP7QwlnM3/gEg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, "node_modules/react-image-label": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/react-image-label/-/react-image-label-1.3.4.tgz", @@ -6701,6 +6735,12 @@ "dev": true, "license": "MIT" }, + "node_modules/tiny-case": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz", + "integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==", + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -6751,6 +6791,12 @@ "node": ">=8.0" } }, + "node_modules/toposort": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz", + "integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==", + "license": "MIT" + }, "node_modules/tough-cookie": { "version": "4.1.4", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", @@ -6815,6 +6861,18 @@ "node": ">= 0.8.0" } }, + "node_modules/type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/typed-array-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.2.tgz", @@ -7424,6 +7482,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/yup": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/yup/-/yup-1.6.1.tgz", + "integrity": "sha512-JED8pB50qbA4FOkDol0bYF/p60qSEDQqBD0/qeIrUCG1KbPBIQ776fCUNb9ldbPcSTxA69g/47XTo4TqWiuXOA==", + "license": "MIT", + "dependencies": { + "property-expr": "^2.0.5", + "tiny-case": "^1.0.3", + "toposort": "^2.0.2", + "type-fest": "^2.19.0" + } + }, "node_modules/zustand": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 8929280f..1740fed3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,6 +16,7 @@ "prettier": "npx prettier . --write" }, "dependencies": { + "@hookform/resolvers": "^3.10.0", "@tanstack/react-query": "^5.59.20", "@trussworks/react-uswds": "^9.0.0", "@types/hex-rgb": "^3.0.0", @@ -29,8 +30,10 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "react-error-boundary": "^5.0.0", + "react-hook-form": "^7.54.2", "react-image-label": "^1.3.4", "react-router-dom": "^6.26.0", + "yup": "^1.6.1", "zustand": "^5.0.1" }, "devDependencies": { diff --git a/frontend/src/components/LoginForm.test.tsx b/frontend/src/components/LoginForm.test.tsx new file mode 100644 index 00000000..8b3d47b3 --- /dev/null +++ b/frontend/src/components/LoginForm.test.tsx @@ -0,0 +1,108 @@ +import { describe, it, expect, vi } from 'vitest' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' + +import LoginForm from './LoginForm' +import RootLayout from './RootLayout' +import { MemoryRouter } from 'react-router-dom' + +describe('LoginForm', () => { + it('renders the form fields and a submit button', () => { + render( + + + + + + ) + + expect(screen.getByRole('heading', { name: /log into my account/i })).toBeInTheDocument() + + expect(screen.getByLabelText(/email address/i)).toBeInTheDocument() + + expect(screen.getByLabelText(/password/i)).toBeInTheDocument() + + expect(screen.getByRole('button', { name: /login to your account/i })).toBeInTheDocument() + }) + + it('shows validation errors when fields are empty and user submits', async () => { + render( + + + + + + ) + + // Get the button + const loginButton = screen.getByRole('button', { name: /login to your account/i }) + + // Click it without typing anything + await fireEvent.click(loginButton) + + // Wait for validation errors to appear + expect(await screen.findByText(/email is required/i)).toBeInTheDocument() + expect(await screen.findByText(/Password must be at least 8 characters/i)).toBeInTheDocument() + }) + + it('shows email format error if email is invalid', async () => { + render( + + + + + + ) + // Type an invalid email + await fireEvent.change(screen.getByLabelText(/email address/i), 'not-an-email') + // Type a password so that password doesn’t fail the “required” check + await fireEvent.change(screen.getByLabelText(/password/i), 'ValidPass123') + + // Submit + const loginButton = screen.getByRole('button', { name: /login to your account/i }) + await fireEvent.click(loginButton) + + // Should show the "Please enter a valid email address" error + expect(await screen.findByText(/Email is required/i)).toBeInTheDocument() + }) + + it('shows password format errors if password does not meet requirements', async () => { + render( + + + + + + ) + // For instance, a password that's too short and missing uppercase + fireEvent.change(screen.getByTestId('email'), 'test@example.com') + fireEvent.change(screen.getByTestId('password'), 'abcabcabc1') + + + const loginButton = screen.getByRole('button', { name: /login to your account/i }) + + await waitFor(() => { + fireEvent.change(screen.getByTestId('email'), 'test@example.com') + fireEvent.change(screen.getByTestId('password'), 'abcabcabc1') + fireEvent.click(loginButton) + }) + + expect(screen.getByText(/password must be at least 8 characters/i)).toBeInTheDocument() + }) + + it('submits successfully when valid data is entered', async () => { + render( + + + + + + ) + // Provide valid inputs + await fireEvent.change(screen.getByLabelText(/email address/i), 'validUser@example.com') + await fireEvent.change(screen.getByLabelText(/password/i), 'ValidPass123') + + // Click the login button + const loginButton = screen.getByRole('button', { name: /login to your account/i }) + await fireEvent.click(loginButton) + }) +}) diff --git a/frontend/src/components/LoginForm.tsx b/frontend/src/components/LoginForm.tsx new file mode 100644 index 00000000..2db2a348 --- /dev/null +++ b/frontend/src/components/LoginForm.tsx @@ -0,0 +1,144 @@ +import { Alert, Button, Label } from '@trussworks/react-uswds'; +import { useForm } from 'react-hook-form'; +import { yupResolver } from "@hookform/resolvers/yup"; +import * as yup from 'yup'; +import { mockLogin, useAuth } from '../contexts/AuthContext'; +import { useNavigate } from 'react-router-dom'; + +type LoginFormData = { + email: string; + password: string; +}; + +const loginSchema = yup.object().shape({ + email: yup + .string() + .email('Please enter a valid email address') + .required('Email is required'), + password: yup + .string() + .min(8, 'Password must be at least 8 characters') + .matches(/[a-z]/, 'Password must contain at least one lowercase letter') + .matches(/[A-Z]/, 'Password must contain at least one uppercase letter') + .matches(/[0-9]/, 'Password must contain at least one number') + .required('Password is required'), +}); + +const LoginForm = () => { + const { + register, + handleSubmit, + formState: { + errors, + isSubmitting, + isSubmitted, // Add this to track submission state + }, + setError, + } = useForm({ + resolver: yupResolver(loginSchema), + mode: 'onSubmit', + criteriaMode: 'all', + defaultValues: { + email: '', + password: '' + } + }); + const { login } = useAuth(); + const navigate = useNavigate() + + + const onSubmit = async (data: LoginFormData) => { + try { + // Prevent default form behavior + const token = await mockLogin(data.email, data.password); + console.log(token) + login(token.token) + navigate('/'); + } catch (error) { + console.log(error) + setError('root', { + type: 'manual', + message: 'Login failed. Please try again.', + }); + } + }; + + return ( +
+

Log into my account

+ +
+ {isSubmitted && errors.email?.message && ( + + {errors.email.message} + + )} + {isSubmitted && errors.password?.message && ( + + {errors.password?.message} + + )} + + {isSubmitted && errors.root?.message && ( + + {errors.root?.message} + + )} + + + + + + + + + Forgot password? + + + +
+ +
+ Don't have an account? + + Create account + +
+
+ ); +}; + +export default LoginForm; \ No newline at end of file diff --git a/frontend/src/components/ProtectedRoute.tsx b/frontend/src/components/ProtectedRoute.tsx new file mode 100644 index 00000000..b0e1f507 --- /dev/null +++ b/frontend/src/components/ProtectedRoute.tsx @@ -0,0 +1,16 @@ +import { ReactNode, useEffect } from "react"; +import { useNavigate } from "react-router-dom"; +import { useAuth } from "../contexts/AuthContext"; + +export const ProtectedRoute = ({ children }: { children: ReactNode }) => { + const { isAuthenticated } = useAuth(); + const navigate = useNavigate(); + + useEffect(() => { + if (!isAuthenticated) { + navigate('/login'); + } + }, [isAuthenticated, navigate]); + + return isAuthenticated ? <>{children} : null; + }; \ No newline at end of file diff --git a/frontend/src/components/RootLayout.tsx b/frontend/src/components/RootLayout.tsx new file mode 100644 index 00000000..cbadf0e7 --- /dev/null +++ b/frontend/src/components/RootLayout.tsx @@ -0,0 +1,22 @@ +import { ReactNode } from 'react'; +import { AuthProvider } from '../contexts/AuthContext'; +import { AnnotationProvider } from '../contexts/AnnotationContext'; +import { FilesProvider } from '../contexts/FilesContext'; + +interface RootLayoutProps { + children: ReactNode; +} + +const RootLayout = ({ children }: RootLayoutProps) => { + return ( + + + + {children} + + + + ); +}; + +export default RootLayout; \ No newline at end of file diff --git a/frontend/src/contexts/AuthContext.test.tsx b/frontend/src/contexts/AuthContext.test.tsx new file mode 100644 index 00000000..c8d74792 --- /dev/null +++ b/frontend/src/contexts/AuthContext.test.tsx @@ -0,0 +1,108 @@ +import { FC } from 'react'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render, screen, act } from '@testing-library/react'; +import { MemoryRouter, useNavigate } from 'react-router-dom'; +import { useAuth, AuthProvider } from './AuthContext'; + + +// --- Mocking react-router-dom (Vitest style) --- +vi.mock('react-router-dom', async () => { + const actual: any = await vi.importActual('react-router-dom'); + return { + ...actual, + useNavigate: vi.fn(), + }; +}); + +// A simple component that consumes AuthContext +const TestComponent: FC = () => { + const { token, login, logout, isAuthenticated } = useAuth(); + return ( +
+

{token}

+

{isAuthenticated.toString()}

+ + +
+ ); +}; + +describe('AuthProvider', () => { + let mockNavigate: ReturnType; + + beforeEach(() => { + mockNavigate = (useNavigate as vi.Mock).mockReturnValue(vi.fn()); + localStorage.clear(); // Clear localStorage before each test + }); + + it('should redirect to /login if no token is found on mount', () => { + // Render AuthProvider with MemoryRouter + render( + + + + + + ); + + // Since there's no token in localStorage, it should navigate to /login + }); + + it('should not redirect if a token is found in localStorage', () => { + localStorage.setItem('auth_token', 'existing-token'); + + render( + + + + + + ); + + // Because a token exists, we don't navigate + expect(screen.getByTestId('token').textContent).toBe('existing-token'); + expect(screen.getByTestId('authenticated').textContent).toBe('true'); + }); + + it('should login and set the token in localStorage', () => { + render( + + + + + + ); + + act(() => { + screen.getByTestId('login').click(); + }); + + expect(screen.getByTestId('token').textContent).toBe('test-token'); + expect(screen.getByTestId('authenticated').textContent).toBe('true'); + expect(localStorage.getItem('auth_token')).toBe('test-token'); + }); + + it('should logout and remove the token from localStorage, then redirect', () => { + localStorage.setItem('auth_token', 'existing-token'); + + render( + + + + + + ); + + act(() => { + screen.getByTestId('logout').click(); + }); + + expect(screen.getByTestId('token').textContent).toBe(''); + expect(screen.getByTestId('authenticated').textContent).toBe('false'); + expect(localStorage.getItem('auth_token')).toBeNull(); + }); +}); diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx new file mode 100644 index 00000000..2179dfb6 --- /dev/null +++ b/frontend/src/contexts/AuthContext.tsx @@ -0,0 +1,78 @@ +import { createContext, useContext, useState, useEffect, ReactNode } from 'react'; +import { useNavigate } from 'react-router-dom'; + +interface AuthContextType { + token: string | null; + login: (token: string) => void; + logout: () => void; + isAuthenticated: boolean; +} + +const AuthContext = createContext(null); + +// Mock API function to simulate authentication +// eslint-disable-next-line react-refresh/only-export-components +export const mockLogin = async (username: string, password: string) => { + // Simulate API call delay + await new Promise(resolve => setTimeout(resolve, 1000)); + console.log('login', username, password); + // Mock authentication logic + if (username === 'test@cdc.gov' && password === 'Password1') { + return { + token: 'mock-jwt-token-' + Math.random().toString(36).substring(7) + }; + } else { + return { token: 'error' } + } +}; + +export const AuthProvider = ({ children }: { children: ReactNode }) => { + const [token, setToken] = useState(() => { + return localStorage.getItem('auth_token'); + }); + const navigate = useNavigate(); + + useEffect(() => { + // Check token validity on mount + const storedToken = localStorage.getItem('auth_token'); + if (!storedToken) { + navigate('/login'); + } + }, [navigate]); + + const login = (newToken: string) => { + if (newToken === 'error') { + throw new Error('Invalid credentials'); + } + setToken(newToken); + localStorage.setItem('auth_token', newToken); + }; + + const logout = () => { + setToken(null); + localStorage.removeItem('auth_token'); + navigate('/login'); + }; + + return ( + + {children} + + ); +}; + +// eslint-disable-next-line react-refresh/only-export-components +export const useAuth = () => { + const context = useContext(AuthContext); + if (!context) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return context; +}; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 17d1038b..4bba9fc6 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,18 +1,13 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; - import "@trussworks/react-uswds/lib/uswds.css"; import "@trussworks/react-uswds/lib/index.css"; - import "./style/index.scss"; - import App from "./App.tsx"; import { createBrowserRouter, RouterProvider } from "react-router-dom"; import { UploadTemplate } from "./pages/UploadTemplate.tsx"; -import { FilesProvider } from "./contexts/FilesContext.tsx"; import AnnotateTemplate from "./pages/AnnotateTemplate.tsx"; import "./App.scss"; -import { AnnotationProvider } from "./contexts/AnnotationContext.tsx"; import ExtractUpload from "./pages/ExtractUpload.tsx"; import ExtractProcess from "./pages/ExtractProcess.tsx"; import { SaveTemplate } from "./pages/SaveTemplate.tsx"; @@ -21,44 +16,50 @@ import SubmissionTemplate from "./pages/SubmissionTemplate.tsx"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import ErrorPage from "./pages/ErrorPage.tsx"; import { ErrorProvider } from "./contexts/ErrorContext.tsx"; - +import LoginPage from "./pages/LoginPage.tsx"; +import RootLayout from "./components/RootLayout"; +import { ProtectedRoute } from "./components/ProtectedRoute.tsx"; const router = createBrowserRouter([ { path: "/", - element: , + element: , }, { path: "/new-template/upload", - element: , + element: , }, { path: "/new-template/annotate", - element: , + element: , }, { path: "/extract/upload", - element: , + element: , }, { path: "/extract/process", - element: , + element: , }, { path: "/new-template/save", - element: , + element: , }, { path: "/extract/review", - element: , + element: , }, { path: "/extract/submit", - element: , + element: , + }, + { + path: "/login", + element: , }, { path: "*", - element: , + element: , }, ]); @@ -67,13 +68,9 @@ const queryClient = new QueryClient(); createRoot(document.getElementById("root")!).render( - - - - - - - + + + - , -); + +); \ No newline at end of file diff --git a/frontend/src/pages/LoginPage.scss b/frontend/src/pages/LoginPage.scss new file mode 100644 index 00000000..2bffa23a --- /dev/null +++ b/frontend/src/pages/LoginPage.scss @@ -0,0 +1,58 @@ +.login-container { + display: flex; + align-items: center; + flex-direction: column; + background-color: #f5fbff; +} + +.header { + height: "50px"; + padding: "8px"; +} + +.login-form-container { + margin-top: 64px; + border: 1px solid #F0F0F0; + background: #FFF; + display: flex; + width: 526px; + padding: 64px 42px; + flex-direction: column; + gap: 10px; + flex-shrink: 0; +} + +.login-link { + color: #005EA2; + font-size: 16px; + font-style: normal; + font-weight: 600; + line-height: normal; + text-decoration: none; + margin-top: 16px; + margin-bottom: 16px; +} + +.login-button { + margin-bottom: 24px; +} + +.login-submit-link { + color: #005EA2; + font-size: 16px; + font-style: normal; + font-weight: 600; + line-height: normal; + text-decoration: none; +} + +.login-input { + display: flex; + min-height: 35px; + padding-bottom: 1px; + flex-direction: column; + justify-content: center; + align-items: flex-start; + gap: 9px; + align-self: stretch; +} \ No newline at end of file diff --git a/frontend/src/pages/LoginPage.test.tsx b/frontend/src/pages/LoginPage.test.tsx new file mode 100644 index 00000000..e267a700 --- /dev/null +++ b/frontend/src/pages/LoginPage.test.tsx @@ -0,0 +1,76 @@ +// LoginPage.test.tsx +import { describe, it, expect } from 'vitest' +import { vi } from 'vitest' // Add this line to import the 'vi' namespace +import { fireEvent, render, screen } from '@testing-library/react' +import { MemoryRouter, useNavigate } from 'react-router-dom' + +// IMPORTANT: We mock react-router-dom so we can observe `useNavigate`. +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom') + return { + ...actual, + useNavigate: vi.fn(), // This is crucial + } + }) + +import LoginPage from './LoginPage' +import RootLayout from '../components/RootLayout' + +describe('LoginPage', () => { + it('renders the header with the correct text', () => { + const navigateMock = vi.fn(); + (useNavigate as unknown as vi.Mock).mockReturnValue(navigateMock) + + render( + + + + + + ) + + // Check if the header text "ReportVision" is on the screen + expect(screen.getByText(/ReportVision/i)).toBeInTheDocument() + + // Check if the image with alt="IDWA" is on the screen + expect(screen.getByAltText('IDWA')).toBeInTheDocument() + }) + + it('navigates to /login when the logo button is clicked', async () => { + // We’ll capture the mock from our mock factory + const navigateMock = vi.fn(); + (useNavigate as unknown as vi.Mock).mockReturnValue(navigateMock) + + render( + + + + + + ) + + // The button that wraps the logo + const logoButton = screen.getByRole('button', { name: /idwa/i }) + await fireEvent.click(logoButton) + + // Expect useNavigate to have been called with "/login" + expect(navigateMock).toHaveBeenCalledTimes(2) + expect(navigateMock).toHaveBeenCalledWith('/login') + }) + + it('renders the login form', () => { + render( + + + + + + ) + + // The LoginForm includes a "Log into my account" heading + expect(screen.getByRole('heading', { name: /log into my account/i })).toBeInTheDocument() + + // The "Login" button + expect(screen.getByRole('button', { name: /login to your account/i })).toBeInTheDocument() + }) +}) diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx new file mode 100644 index 00000000..6450a5c4 --- /dev/null +++ b/frontend/src/pages/LoginPage.tsx @@ -0,0 +1,32 @@ +import { Header, Button } from '@trussworks/react-uswds'; +import reportVisionLogo from "../assets/datalink_placeholder_logo.svg"; +import { useNavigate } from 'react-router-dom'; + +import './LoginPage.scss' +import LoginForm from '../components/LoginForm'; + +const LoginPage = () => { + const navigate = useNavigate(); + return ( +
+
+
+ +
+
+ ReportVision +
+
+
+ +
+ ); +}; + +export default LoginPage; \ No newline at end of file