diff --git a/docker-compose.yml b/docker-compose.yml index 600412c7..a7a27429 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -29,6 +29,16 @@ services: ports: - "8080:8080" + webapp: + container_name: webapp-${teamname:-defaultASW} + image: ghcr.io/arquisoft/wiq_en2a/webapp:latest + profiles: [ "dev", "prod" ] + build: ./webapp + networks: + - mynetwork + ports: + - "3000:3000" + volumes: postgres_data: diff --git a/sonar-project.properties b/sonar-project.properties index 1634853f..afef61b3 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -11,7 +11,7 @@ sonar.language=js,java sonar.projectName=wiq_en2b sonar.coverage.exclusions=**/*.test.js,**/*.test.jsx -sonar.sources=webapp/src/components,api/src/main/java +sonar.sources=webapp/src/components,api/src/main/java,webapp/src/pages/ sonar.sourceEncoding=UTF-8 sonar.exclusions=node_modules/**,**/quizapi/commons/utils/**,**/quizapi/commons/exceptions/**,**/quizapi/auth/jwt/**,**/quizapi/**/dtos/** sonar.javascript.lcov.reportPaths=**/coverage/lcov.info diff --git a/webapp/.env b/webapp/.env deleted file mode 100644 index c810bde7..00000000 --- a/webapp/.env +++ /dev/null @@ -1 +0,0 @@ -REACT_APP_API_ENDPOINT=http://localhost:8000 \ No newline at end of file diff --git a/webapp/.gitignore b/webapp/.gitignore index 4d29575d..8692cf66 100644 --- a/webapp/.gitignore +++ b/webapp/.gitignore @@ -13,6 +13,7 @@ # misc .DS_Store +.env .env.local .env.development.local .env.test.local diff --git a/webapp/src/components/auth/AuthUtils.js b/webapp/src/components/auth/AuthUtils.js new file mode 100644 index 00000000..296fb582 --- /dev/null +++ b/webapp/src/components/auth/AuthUtils.js @@ -0,0 +1,35 @@ +import axios, { HttpStatusCode } from "axios"; + +export function isUserLogged() { + return getLoginData().jwtToken !== null; +} + +export function saveToken(requestAnswer) { + axios.defaults.headers.common["Authorization"] = "Bearer " + requestAnswer.data.token; + sessionStorage.setItem("jwtToken", requestAnswer.data.token); + sessionStorage.setItem("jwtRefreshToken", requestAnswer.data.refresh_Token); + sessionStorage.setItem("jwtReceptionMillis", Date.now().toString()); +} + +export function getLoginData() { + return { + "jwtToken": sessionStorage.getItem("jwtToken"), + "jwtRefreshToken": sessionStorage.getItem("jwtRefreshToken"), + "jwtReceptionDate": new Date(sessionStorage.getItem("jwtReceptionMillis")) + }; +} + +export async function login(loginData, onSuccess, onError) { + try { + let requestAnswer = await axios.post(process.env.REACT_APP_API_ENDPOINT + + process.env.REACT_APP_LOGIN_ENDPOINT, loginData); + if (HttpStatusCode.Ok === requestAnswer.status) { + saveToken(requestAnswer); + onSuccess(); + } else { + onError(); + } + } catch { + onError(); + } +} \ No newline at end of file diff --git a/webapp/src/i18n.js b/webapp/src/i18n.js index a57b1175..a9a6fdb1 100644 --- a/webapp/src/i18n.js +++ b/webapp/src/i18n.js @@ -9,5 +9,5 @@ export default i18n.use(Backend) .use(initReactI18next) .init({ fallbackLng: "en", - debug: true + debug: false }) \ No newline at end of file diff --git a/webapp/src/index.js b/webapp/src/index.js index 2a34dfff..fd2d1dc6 100644 --- a/webapp/src/index.js +++ b/webapp/src/index.js @@ -5,11 +5,12 @@ import reportWebVitals from './reportWebVitals'; import {createBrowserRouter, RouterProvider} from 'react-router-dom'; import router from 'components/Router'; import { ChakraProvider } from '@chakra-ui/react'; - import "./i18n"; +import axios from "axios"; const root = ReactDOM.createRoot(document.querySelector("body")); const browserRouter = createBrowserRouter(router); +axios.defaults.headers.post["Content-Type"] = "application/json"; root.render( diff --git a/webapp/src/pages/Login.jsx b/webapp/src/pages/Login.jsx index 5ae7e1c1..7c2f224d 100644 --- a/webapp/src/pages/Login.jsx +++ b/webapp/src/pages/Login.jsx @@ -1,72 +1,77 @@ -import { Center } from "@chakra-ui/layout"; -import { Heading, Input, InputGroup, Stack, InputLeftElement, chakra, Box, Avatar, FormControl, InputRightElement, Text, IconButton } from "@chakra-ui/react"; -import { ViewIcon, ViewOffIcon } from '@chakra-ui/icons' -import axios, { HttpStatusCode } from "axios"; -import React, { useState } from "react"; -import { useTranslation } from "react-i18next"; -import { useNavigate } from "react-router-dom"; -import { FaLock, FaAddressCard } from "react-icons/fa"; -import ButtonEf from '../components/ButtonEf'; -import '../styles/AppView.css'; - -export default function Login() { - - const [hasError, setHasError] = useState(false); - const navigate = useNavigate(); - const { t } = useTranslation(); - - const [showPassword, setShowPassword] = useState(false); - const changeShowP = () => setShowPassword(!showPassword); - - const ChakraFaCardAlt = chakra(FaAddressCard); - const ChakraFaLock = chakra(FaLock); - - const sendLogin = async () => { - let data = {}; - let response = await axios.post(process.env.API_URL, data); - if (response.status === HttpStatusCode.Accepted) { - navigate("/home"); - } else { - setHasError(true); - } - } - - return ( -
- - - { t("common.login")} - { - !hasError ? - <> : -
- {t("error.login")} -
- } - - - - - }/> - - - - - - }/> - - - : } data-testid="togglePasswordButton"/> - - - - - - -
-
- ); +import { Center } from "@chakra-ui/layout"; +import { Heading, Input, InputGroup, Stack, InputLeftElement, chakra, Box, Avatar, FormControl, InputRightElement, Text, IconButton } from "@chakra-ui/react"; +import { ViewIcon, ViewOffIcon } from '@chakra-ui/icons' +import React, {useEffect, useState} from "react"; +import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom"; +import { FaLock, FaAddressCard } from "react-icons/fa"; +import ButtonEf from '../components/ButtonEf'; +import '../styles/AppView.css'; +import {isUserLogged, login} from "../components/auth/AuthUtils"; + +export default function Login() { + + const navigate = useNavigate(); + const navigateToDashboard = () => { + if (isUserLogged()) { + navigate("/dashboard"); + } + } + + useEffect(navigateToDashboard); + const [hasError, setHasError] = useState(false); + const { t } = useTranslation(); + + const [showPassword, setShowPassword] = useState(false); + const changeShowP = () => setShowPassword(!showPassword); + + const ChakraFaCardAlt = chakra(FaAddressCard); + const ChakraFaLock = chakra(FaLock); + + const sendLogin = async () => { + const loginData = { + "email": document.getElementById("user").value, + "password": document.getElementById("password").value + }; + await login(loginData, navigateToDashboard, () => setHasError(true)); + } + + return ( +
+ + + { t("common.login")} + { + !hasError ? + <> : +
+ {t("error.login")} +
+ } + + + + + }/> + + + + + + }/> + + + : } data-testid="togglePasswordButton"/> + + + + + + +
+
+ ); } \ No newline at end of file diff --git a/webapp/src/pages/Root.jsx b/webapp/src/pages/Root.jsx index df44626d..5459fef1 100644 --- a/webapp/src/pages/Root.jsx +++ b/webapp/src/pages/Root.jsx @@ -8,6 +8,9 @@ import ButtonEf from '../components/ButtonEf'; export default function Root() { const navigate = useNavigate(); const { t } = useTranslation(); + const signup = () => { + navigate("/signup"); + } return (
{t("session.welcome")}

navigate("/login")}/> -

navigate("/signup")} style={{ cursor: 'pointer' }}>{t("session.account")}

+

{t("session.account")}

); diff --git a/webapp/src/tests/AuthUtils.test.js b/webapp/src/tests/AuthUtils.test.js new file mode 100644 index 00000000..e98e5309 --- /dev/null +++ b/webapp/src/tests/AuthUtils.test.js @@ -0,0 +1,76 @@ +import MockAdapter from "axios-mock-adapter"; +import axios, { HttpStatusCode } from "axios"; +import {isUserLogged, login, saveToken} from "components/auth/AuthUtils"; + +const mockAxios = new MockAdapter(axios); + +describe("Auth Utils tests", () => { + describe("when the user is not authenticated", () => { + + beforeEach(() => { + sessionStorage.clear(); + mockAxios.reset(); + }); + + it("does not have a stored token", () => { + expect(isUserLogged()).not.toBe(true); + }); + + it("can log in", async () => { + + // Mock axios and the onSuccess and onError functions + mockAxios.onPost().replyOnce(HttpStatusCode.Ok, { + "token": "token", + "refresh_Token": "refreshToken" + }); + const mockOnSucess = jest.fn(); + const mockOnError = jest.fn(); + + // Test + const loginData = { + "email": "test@email.com", + "password": "test" + }; + + await login(loginData, mockOnSucess, mockOnError); + + //Check the user is now logged in + expect(isUserLogged()).toBe(true); + }); + }); + + describe("when the user is authenticated", () => { + + beforeAll(() => { + sessionStorage.setItem("jwtToken", "token"); + }) + + afterEach(() => { + sessionStorage.clear(); + }) + + it("has a stored token", () => { + expect(isUserLogged()).toBe(true); + }); + }); + + describe("saving the token", () => { + beforeEach(() => { + sessionStorage.clear(); + }); + + it ("is saved", () => { + let mockResponse = { + "data": { + "token": "token", + "refresh_Token": "refreshToken" + } + }; + saveToken(mockResponse); + expect(sessionStorage.getItem("jwtToken")).toBe(mockResponse.data.token); + expect(sessionStorage.getItem("jwtRefreshToken")).toBe(mockResponse.data.refresh_Token); + }); + }); +}); + + diff --git a/webapp/src/tests/Login.test.js b/webapp/src/tests/Login.test.js index 1a20e7fc..0be238e5 100644 --- a/webapp/src/tests/Login.test.js +++ b/webapp/src/tests/Login.test.js @@ -1,64 +1,56 @@ import React from 'react'; -import { render, fireEvent, waitFor, getByTestId, getAllByTestId } from '@testing-library/react'; -import axios from 'axios'; +import { render, fireEvent, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom/extend-expect'; import { MemoryRouter } from 'react-router'; -import Signup from '../pages/Signup'; +import Login from '../pages/Login'; +import { login as mockLogin } from '../components/auth/AuthUtils'; -describe('Signup Component', () => { +jest.mock('../components/auth/AuthUtils', () => ({ + isUserLogged: jest.fn(), + login: jest.fn(), +})); +describe('Login Component', () => { it('renders form elements correctly', () => { - const { getByPlaceholderText } = render(); + const { getByPlaceholderText, getByTestId } = render(); expect(getByPlaceholderText('session.email')).toBeInTheDocument(); - expect(getByPlaceholderText('session.username')).toBeInTheDocument(); expect(getByPlaceholderText('session.password')).toBeInTheDocument(); - expect(getByPlaceholderText('session.confirm_password')).toBeInTheDocument(); - expect(getByTestId(document.body, 'Sign up')).toBeInTheDocument(); + expect(getByTestId('Login')).toBeInTheDocument(); }); - it('toggles password visibility', () => { - const { getByPlaceholderText } = render(); + it('toggles password visibility', () => { + const { getByLabelText, getByPlaceholderText } = render(); + + // Initially password should be hidden const passwordInput = getByPlaceholderText('session.password'); - const confirmPasswordInput = getByPlaceholderText('session.confirm_password'); - const showPasswordButtons = getAllByTestId(document.body, 'show-confirm-password-button'); - - fireEvent.click(showPasswordButtons[0]); - fireEvent.click(showPasswordButtons[1]); - - expect(passwordInput.getAttribute('type')).toBe('text'); - expect(confirmPasswordInput.getAttribute('type')).toBe('text'); + expect(passwordInput).toHaveAttribute('type', 'password'); + + // Click on the toggle password button + const toggleButton = getByLabelText('Shows or hides the password'); + fireEvent.click(toggleButton); + + // Password should now be visible + expect(passwordInput).toHaveAttribute('type', 'text'); }); - it('submits form data correctly', async () => { - const axiosMock = jest.spyOn(axios, 'post'); - axiosMock.mockResolvedValueOnce({ status: 202 }); // Accepted status code - - // Render the Signup component - const { getByPlaceholderText } = render(); - - // Get form elements and submit button by their text and placeholder values + it('calls login function with correct credentials on submit', async () => { + const { getByPlaceholderText, getByTestId } = render(, { wrapper: MemoryRouter }); const emailInput = getByPlaceholderText('session.email'); - const usernameInput = getByPlaceholderText('session.username'); const passwordInput = getByPlaceholderText('session.password'); - const signUpButton = getByTestId(document.body, 'Sign up'); + const loginButton = getByTestId('Login'); - // Fill out the form with valid data and submit it fireEvent.change(emailInput, { target: { value: 'test@example.com' } }); - fireEvent.change(usernameInput, { target: { value: 'testuser' } }); - fireEvent.change(passwordInput, { target: { value: 'password' } }); - fireEvent.click(signUpButton); + fireEvent.change(passwordInput, { target: { value: 'password123' } }); + fireEvent.click(loginButton); - // Check if the form data was sent correctly await waitFor(() => { - expect(axiosMock).toHaveBeenCalledWith(process.env.API_URL, { - email: 'test@example.com', - username: 'testuser', - password: 'password' - }); - expect(axiosMock).toHaveBeenCalledTimes(1); + expect(mockLogin).toHaveBeenCalledWith( + { email: 'test@example.com', password: 'password123' }, + expect.any(Function), + expect.any(Function) + ); }); - - axiosMock.mockRestore(); }); }); \ No newline at end of file