diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b3596232a963a1884028d87bfce098609bc9b3db..282316afdaa2e86f3fc39453112b568e6cad798f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -16300,6 +16300,11 @@ "react-side-effect": "^1.1.0" } }, + "react-hook-form": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-5.0.1.tgz", + "integrity": "sha512-5Q6RvyN9vNm4wGard4Sw+xQz7rGIL+tS+UBVKFlTWcKhjsn87sKw2Uge6StxtcFUdUQyf+mNALNz3QRdHN3Pug==" + }, "react-hot-loader": { "version": "4.12.19", "resolved": "https://registry.npmjs.org/react-hot-loader/-/react-hot-loader-4.12.19.tgz", diff --git a/frontend/package.json b/frontend/package.json index 1e07b49f85bbdf3500f8e5bc7c96304c063bc901..2f643840d2c347ff79ae7b9d291f257f5b87d030 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -33,6 +33,7 @@ "react-dom": "^16.12.0", "react-google-login": "^5.1.1", "react-helmet": "^5.2.1", + "react-hook-form": "^5.0.1", "redux": "^4.0.5" }, "devDependencies": { diff --git a/frontend/src/api.js b/frontend/src/api.js index 989a4b194bad917af3d551b99c961f1d28f6665b..199d6f774843843e4afc49c41702d3f8f7607b22 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -3,3 +3,26 @@ import { BASE_API_URL } from "./config" export const getListJadwalDonor = date => axios.get(`${BASE_API_URL}/donor/jadwal/?date=${date}`) + +export const postUserLogin = (email, password) => + Promise.resolve({ + data: { + access: "initokenyangsecure", + }, + }) + +export const postUserProfile = token => + Promise.resolve({ + data: { + email: "fairuzi@informatika.com", + nama: "Muhammad Fairuzi Teguh", + golongan_darah: "AB", + }, + }) + +export const postUserLogout = () => + Promise.resolve({ + data: { + detail: "SUCCESS", + }, + }) diff --git a/frontend/src/components/layout.js b/frontend/src/components/layout.js index 8129ae81dd42ba4d074d229ed57b5b970bc798ac..e6c424f3ccf253924f1bcfb8ecb69b028f537c0d 100644 --- a/frontend/src/components/layout.js +++ b/frontend/src/components/layout.js @@ -7,17 +7,20 @@ import { Container } from "react-bootstrap" import Footer from "./footer" import Header from "./header" import Navbar from "./navbar" +import { AuthProvider } from "../hooks/authenticate" const Layout = ({ children, navbar }) => ( <> - - {navbar && } - - - {children} - - - + + + {navbar && } + + + {children} + + + + > ) diff --git a/frontend/src/components/modal-login.js b/frontend/src/components/modal-login.js new file mode 100644 index 0000000000000000000000000000000000000000..492366e9661190a8fb6abac3ceeb5196eae62bf4 --- /dev/null +++ b/frontend/src/components/modal-login.js @@ -0,0 +1,130 @@ +import React, { useState } from "react" +import { Button, Form, Modal } from "react-bootstrap" +import { GoogleLogin } from "react-google-login" +import { useForm } from "react-hook-form" +import { useAuth } from "../hooks/authenticate" +import { faCircleNotch } from "@fortawesome/free-solid-svg-icons" +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" + +const ModalLogin = ({ show, handleClose }) => { + const { register, handleSubmit, errors } = useForm() + const { login } = useAuth() + const [isLoadingLogin, setIsLoadingLogin] = useState(false) + const [loginError, setLoginError] = useState(null) + const onSubmit = async data => { + setLoginError(null) + setIsLoadingLogin(true) + try { + await login(data.username, data.password) + handleClose() + } catch (error) { + const message = error.response.data.detail + setLoginError(message) + } + setIsLoadingLogin(false) + } + return ( + + + + + + Masuk + + + setLoginError(null)} + > + + + {errors.email && ( + + {errors.email.message} + + )} + + + + {errors.password && errors.password.type === "required" && ( + + {errors.password.message} + + )} + + {loginError && ( + + Email atau password salah. + + )} + + {isLoadingLogin && ( + + )} + Masuk + + + + atau dengan + + + + + Login with Google + + + + + + Belum punya akun? + + + Buat di sini + + + + + + Lupa password? + + + + + + ) +} + +export default ModalLogin diff --git a/frontend/src/components/modalLogin.js b/frontend/src/components/modalLogin.js deleted file mode 100644 index a336f7518d08872fd1a9a9ce4facae9e263e6b8f..0000000000000000000000000000000000000000 --- a/frontend/src/components/modalLogin.js +++ /dev/null @@ -1,69 +0,0 @@ -import React from "react" -import { Modal, Button, Form } from "react-bootstrap" -import { GoogleLogin } from "react-google-login" - -const ModalLogin = ({ show, handleClose }) => { - return ( - - - - - - Masuk - - - - - - - - - - - Masuk - - - - atau dengan - - - - - Login with Google - - - - - - Belum punya akun? - - - Buat di sini - - - - - - Lupa password? - - - - - - ) -} - -export default ModalLogin diff --git a/frontend/src/components/navbar.js b/frontend/src/components/navbar.js index 817be5197f62fb2a408ac5679d1c3ac4c23c34dc..7584c08afe7affd8f5c4637ef6f9666afa39761c 100644 --- a/frontend/src/components/navbar.js +++ b/frontend/src/components/navbar.js @@ -1,15 +1,17 @@ import React, { useState } from "react" import { Navbar as BNavbar, Nav, Button } from "react-bootstrap" import { Link as GatsbyLink } from "gatsby" -import ModalLogin from "./modalLogin" +import ModalLogin from "./modal-login" import "./navbar.scss" +import { useAuth } from "../hooks/authenticate" -const NavLink = ({ className, ...props }) => ( +const NavLink = ({ className = "", ...props }) => ( ) const Navbar = () => { + const { user, logout } = useAuth() const [showModalLogin, setShowModalLogin] = useState(false) return ( @@ -29,10 +31,19 @@ const Navbar = () => { Ajukan Acara Donor Profil - - setShowModalLogin(true)}>Masuk - Daftar - + {user ? ( + + {}}>Ubah Profil + + Keluar + + + ) : ( + + setShowModalLogin(true)}>Masuk + Daftar + + )} ({ + __esModule: true, + postUserLogin: jest.fn(), + postUserProfile: jest.fn(), + postUserLogout: jest.fn(), +})) +jest.mock("../hooks/authenticate.js", () => ({ + __esModule: true, + useAuth: jest.fn(), + AuthProvider: jest.fn(), +})) +useAuth.mockReturnValue({ + user: null, + login: () => {}, + register: () => {}, + logout: () => {}, +}) + describe(`Navbar`, () => { - it(`show all the options`, () => { + it(`shows all the options`, () => { const { container } = render() expect(container).toHaveTextContent("Home") expect(container).toHaveTextContent("Jadwal Donor") }) }) -test("click Masuk", async () => { - const { getByText, getByPlaceholderText } = render() +const doSuccessfulLogin = async () => { + postUserLogin.mockResolvedValueOnce({ + data: { + access: "initokenyangsecure", + }, + }) + postUserProfile.mockResolvedValueOnce({ + data: { + email: "fairuzi@informatika.com", + nama: "Muhammad Fairuzi Teguh", + }, + }) + useAuth.mockImplementation( + require.requireActual("../hooks/authenticate").useAuth + ) + AuthProvider.mockImplementation( + require.requireActual("../hooks/authenticate").AuthProvider + ) + const { getByText, getByPlaceholderText } = render( + + + + ) + + fireEvent.click(getByText(/Masuk/i)) + fireEvent.change(getByPlaceholderText(/Email/i), { + target: { value: "muhammad.fairuzi@ui.ac.id" }, + }) + fireEvent.change(getByPlaceholderText(/Password/i), { + target: { value: "passwordinirahasia" }, + }) + fireEvent.click(screen.getByTestId("button-login")) + await waitForElementToBeRemoved(() => screen.getByText(/Belum punya akun?/i)) +} + +describe(`Login`, () => { + it(`shows "Masuk" button that can be clicked`, async () => { + const { getByText, getByPlaceholderText } = render() + + fireEvent.click(getByText("Masuk")) + expect(getByPlaceholderText("Email")).toBeInTheDocument() + expect(getByPlaceholderText("Password")).toBeInTheDocument() + + fireEvent.click(getByText("×")) + await waitForElementToBeRemoved(() => getByPlaceholderText("Email")) + }) + + it(`shows feedback when submit required field but empty`, async () => { + const { findByText, getByText, getByTestId } = render() + fireEvent.click(getByText(/Masuk/i)) + fireEvent.click(getByTestId("button-login")) + expect(await findByText(/Masukkan email/i)).toBeInTheDocument() + expect(await findByText(/Masukkan Password/i)).toBeInTheDocument() + }) + + it(`shows error message when can't login`, async () => { + postUserLogin.mockRejectedValueOnce({ + response: { + data: { + detail: "passwordnyasalah", + }, + }, + }) + useAuth.mockImplementation( + require.requireActual("../hooks/authenticate").useAuth + ) + AuthProvider.mockImplementation( + require.requireActual("../hooks/authenticate").AuthProvider + ) + + const { getByText, getByPlaceholderText, findByText, getByTestId } = render( + + + + ) + + fireEvent.click(getByText("Masuk")) + fireEvent.change(getByPlaceholderText(/Email/i), { + target: { value: "muhammad.fairuzi@ui.ac.id" }, + }) + fireEvent.change(getByPlaceholderText(/Password/i), { + target: { value: "passwordyangsalah" }, + }) + fireEvent.click(getByTestId("button-login")) + expect(await findByText(/Email atau password salah./i)).toBeInTheDocument() + }) - fireEvent.click(getByText("Masuk")) - expect(getByPlaceholderText("Username/Email")).toBeInTheDocument() - expect(getByPlaceholderText("Password")).toBeInTheDocument() + it(`shows "Ubah Profil" to replace "Masuk" after login`, async () => { + await doSuccessfulLogin() + expect(await screen.findByText(/Ubah Profil/i)).toBeInTheDocument() + }) - fireEvent.click(getByText("×")) - await waitForElementToBeRemoved(() => getByPlaceholderText("Username/Email")) + it(`shows "Masuk" again after logout`, async () => { + await doSuccessfulLogin() + postUserLogout.mockResolvedValueOnce({ data: { detail: "SUCCESS" } }) + fireEvent.click(await screen.findByText(/Keluar/i)) + expect(await screen.findByText(/Masuk/i)).toBeInTheDocument() + }) }) diff --git a/frontend/src/hooks/authenticate.js b/frontend/src/hooks/authenticate.js new file mode 100644 index 0000000000000000000000000000000000000000..93d462ce83c2e7e8dc4a17bf4402fdda506b8c5c --- /dev/null +++ b/frontend/src/hooks/authenticate.js @@ -0,0 +1,57 @@ +import React, { useEffect, useState } from "react" +import { postUserLogin, postUserProfile, postUserLogout } from "../api" + +const LOCAL_STORAGE_TOKEN_KEY = "token" + +const AuthContext = React.createContext() + +const AuthProvider = props => { + const [user, setUser] = useState(null) + + const postAndSetUserProfile = async token => { + try { + const { data: user } = await postUserProfile(token) + setUser(user) + } catch (error) { + // do nothing if the token is invalid + } + } + useEffect(() => { + const existentToken = window.localStorage.getItem(LOCAL_STORAGE_TOKEN_KEY) + if (existentToken) postAndSetUserProfile(existentToken) + }, []) + + const login = async (email, password) => { + // if login fail, it's ok to throw the error + const result = await postUserLogin(email, password) + const token = result.data.access + window.localStorage.setItem(LOCAL_STORAGE_TOKEN_KEY, token) + postAndSetUserProfile(token) + // TODO: axios set token + } + const register = () => {} + const logout = async () => { + // if not throwing error, than we are OK to proceed + await postUserLogout() + window.localStorage.removeItem(LOCAL_STORAGE_TOKEN_KEY) + setUser(null) + // TODO: axios set token + } + + return ( + + ) +} + +const useAuth = () => { + const context = React.useContext(AuthContext) + if (context === undefined) { + throw new Error("useAuth must be used within a AuthProvider") + } + return context +} + +export { AuthProvider, useAuth } diff --git a/frontend/src/hooks/authenticate.test.js b/frontend/src/hooks/authenticate.test.js new file mode 100644 index 0000000000000000000000000000000000000000..d689cde6f00e705dd4549e9f8d03892031d34bd5 --- /dev/null +++ b/frontend/src/hooks/authenticate.test.js @@ -0,0 +1,23 @@ +import React from "react" +import { useAuth } from "./authenticate" +import { render } from "@testing-library/react" + +describe(`useAuth`, () => { + it(`throws error when used outside of the provider`, () => { + // React will call console.error for this expected error + // mock to make output more concise + jest.spyOn(console, "error").mockImplementation((...args) => {}) + const OuterComponent = () => { + return ( + <> + + > + ) + } + const UsingUseAuthOutsideProvider = () => { + useAuth() + return <>> + } + expect(() => render()).toThrow() + }) +}) diff --git a/frontend/src/styles/global.scss b/frontend/src/styles/global.scss index 2e8085209a2602e14ea632cc5e1d3d43e277c8e6..d476761934bf4fd1a0763e10f5eed5e03730d393 100644 --- a/frontend/src/styles/global.scss +++ b/frontend/src/styles/global.scss @@ -216,3 +216,8 @@ body { font-size: 17px; } } + +.invalid-feedback { + color: $red; + display: block; +}
+ atau dengan +
+ + Belum punya akun? + + + Buat di sini + +
- atau dengan -
- - Belum punya akun? - - - Buat di sini - -