From 61c664e805023fd9c60a56a52c82e12030f5d2af Mon Sep 17 00:00:00 2001
From: Leander Tolksdorf <leander.tolksdorf@fu-berlin.de>
Date: Mon, 31 Jan 2022 17:57:57 +0100
Subject: [PATCH] add login and logout feature

---
 client/src/api/auth.ts                     |  16 ++
 client/src/api/user.ts                     |   9 +
 client/src/components/StaffLayout.tsx      | 111 ++++++++----
 client/src/pages/staff/StaffLogin.tsx      | 191 ++++++++++++---------
 server/src/controllers/auth.controllers.ts |  19 +-
 server/src/routes/auth.routes.ts           |   1 +
 6 files changed, 229 insertions(+), 118 deletions(-)
 create mode 100644 client/src/api/auth.ts
 create mode 100644 client/src/api/user.ts

diff --git a/client/src/api/auth.ts b/client/src/api/auth.ts
new file mode 100644
index 0000000..f57da51
--- /dev/null
+++ b/client/src/api/auth.ts
@@ -0,0 +1,16 @@
+type StaffLoginParameters = {
+  email: string;
+  password: string;
+};
+
+export async function staffLogin({ email, password }: StaffLoginParameters) {
+  const response = await fetch("/api/login", {
+    method: "POST",
+    body: JSON.stringify({ email: email, password: password }),
+    headers: {
+      "Content-Type": "application/json",
+    },
+  });
+
+  return response.json();
+}
diff --git a/client/src/api/user.ts b/client/src/api/user.ts
new file mode 100644
index 0000000..aa33194
--- /dev/null
+++ b/client/src/api/user.ts
@@ -0,0 +1,9 @@
+export async function getCurrentUser() {
+  const response = await fetch("/api/user", {
+    method: "GET",
+    headers: {
+      "Content-Type": "application/json",
+    },
+  });
+  return response;
+}
diff --git a/client/src/components/StaffLayout.tsx b/client/src/components/StaffLayout.tsx
index f54b1d8..d943594 100644
--- a/client/src/components/StaffLayout.tsx
+++ b/client/src/components/StaffLayout.tsx
@@ -1,42 +1,87 @@
 import { Container, Nav, Navbar } from "react-bootstrap";
 import { useTranslation } from "react-i18next";
-import { Outlet, useLocation, NavLink } from "react-router-dom";
+import { Outlet, useLocation, NavLink, useNavigate } from "react-router-dom";
 import { Helmet } from "react-helmet-async";
+import { useEffect } from "react";
+import { getCurrentUser } from "../api/user";
 
 function StaffLayout() {
-	const { t } = useTranslation();
-	const {pathname} = useLocation();
-	return (
-		<div className="h-100 w-100">
-			<Helmet>
-				<title>{t(`routes.${pathname}`)}</title>
-			</Helmet>
-			<Navbar sticky="top" className="bg-secondary" variant="dark" expand="lg">
-				<Container>
-					<Navbar.Brand className="fw-bold" style={{minWidth: "200px"}}>{t(`routes.${pathname}`)}</Navbar.Brand>
-					<Navbar.Toggle />
-					<Navbar.Collapse>
-						<Nav className="mr-auto d-flex justify-content-between w-100">
-							<div className="d-flex">
-								<Nav activeKey={pathname}>
-								<Nav.Link as={NavLink} to="/staff/overview">{t("staffNav.buttonBoatOverview")}</Nav.Link>
-								<Nav.Link as={NavLink} to="/staff/manage">{t("staffNav.buttonManageBoats")}</Nav.Link>
-								<Nav.Link as={NavLink} to="/staff/boattypes">{t("staffNav.buttonBoatTypes")}</Nav.Link>
-								<Nav.Link as={NavLink} to="/staff/sports">{t("staffNav.buttonSports")}</Nav.Link>
-								<Nav.Link as={NavLink} to="/staff/statistics">{t("staffNav.buttonStatistics")}</Nav.Link>
-								</Nav>
-							</div>
-							<div>
-								<Nav.Link>{t("staffNav.buttonLogout")}</Nav.Link>
+  const { t } = useTranslation();
+  const { pathname } = useLocation();
+  const navigate = useNavigate();
 
-							</div>
-						</Nav>
-					</Navbar.Collapse>
-				</Container>
-			</Navbar>
-			<Outlet />
-		</div>
-	);
+  async function validateCurrentUser() {
+    try {
+      const response = await getCurrentUser();
+      if (response.status !== 200) {
+        navigate("/login");
+      }
+    } catch (e) {}
+  }
+
+  async function logout() {
+    try {
+      const response = await fetch("/api/logout", {
+        method: "POST",
+      });
+      navigate("/");
+    } catch (e) {
+      console.log(e);
+    }
+  }
+
+  useEffect(() => {
+    validateCurrentUser();
+  }, []);
+
+  return (
+    <div className="h-100 w-100">
+      <Helmet>
+        <title>{t(`routes.${pathname}`)}</title>
+      </Helmet>
+      <Navbar sticky="top" className="bg-secondary" variant="dark" expand="lg">
+        <Container>
+          <Navbar.Brand className="fw-bold" style={{ minWidth: "200px" }}>
+            {t(`routes.${pathname}`)}
+          </Navbar.Brand>
+          <Navbar.Toggle />
+          <Navbar.Collapse>
+            <Nav className="mr-auto d-flex justify-content-between w-100">
+              <div className="d-flex">
+                <Nav activeKey={pathname}>
+                  <Nav.Link as={NavLink} to="/staff/overview">
+                    {t("staffNav.buttonBoatOverview")}
+                  </Nav.Link>
+                  <Nav.Link as={NavLink} to="/staff/manage">
+                    {t("staffNav.buttonManageBoats")}
+                  </Nav.Link>
+                  <Nav.Link as={NavLink} to="/staff/boattypes">
+                    {t("staffNav.buttonBoatTypes")}
+                  </Nav.Link>
+                  <Nav.Link as={NavLink} to="/staff/sports">
+                    {t("staffNav.buttonSports")}
+                  </Nav.Link>
+                  <Nav.Link as={NavLink} to="/staff/statistics">
+                    {t("staffNav.buttonStatistics")}
+                  </Nav.Link>
+                </Nav>
+              </div>
+              <div>
+                <Nav.Link
+                  onClick={() => {
+                    logout();
+                  }}
+                >
+                  {t("staffNav.buttonLogout")}
+                </Nav.Link>
+              </div>
+            </Nav>
+          </Navbar.Collapse>
+        </Container>
+      </Navbar>
+      <Outlet />
+    </div>
+  );
 }
 
 export default StaffLayout;
diff --git a/client/src/pages/staff/StaffLogin.tsx b/client/src/pages/staff/StaffLogin.tsx
index dae8a92..de86ed9 100644
--- a/client/src/pages/staff/StaffLogin.tsx
+++ b/client/src/pages/staff/StaffLogin.tsx
@@ -1,7 +1,11 @@
+import { useEffect } from "react";
 import { Button, Col, Container, Form, Row } from "react-bootstrap";
 import { Controller, useForm } from "react-hook-form";
 import { useTranslation } from "react-i18next";
+import { useNavigate } from "react-router-dom";
 import validator from "validator";
+import { staffLogin } from "../../api/auth";
+import { getCurrentUser } from "../../api/user";
 import Divider from "../../components/layout/Divider";
 import Modal from "../../components/Modal";
 
@@ -11,94 +15,113 @@ type FormData = {
 };
 
 function Login() {
-    const {
-      control,
-      handleSubmit,
-      formState: { errors },
-    }= useForm<FormData>({
-      mode: "onBlur"});
-    const { t } = useTranslation();
+  const {
+    control,
+    handleSubmit,
+    formState: { errors },
+  } = useForm<FormData>({
+    mode: "onBlur",
+  });
+  const { t } = useTranslation();
+  const navigate = useNavigate();
 
-    const onSubmit = (data: FormData) => {
-      try {
-        /**
-         * Logic to handle authentication. Should be separated into a service in another file.
-         * await login(data);
-         *
-         *
-         * navigate("/boatManager");
-         */
-      } catch (e) {
-        alert("error");
+  const onSubmit = async (data: FormData) => {
+    try {
+      const response = await staffLogin(data);
+      if (response.success) {
+        navigate("/staff", { replace: true });
       }
-    };
+    } catch (e) {
+      // TODO: Error handling
+    }
+  };
 
-    return (
-        <Container className="position-absolute top-50 start-50 translate-middle">
-            <Row>
-            <Col xs={{ span: 10, offset: 1 }}>
-              <Modal>
-                <h1 className="text-center p-1">{t("staffLogin.title")}</h1>
-                <p className="text-center">{t("staffLogin.subtitle")}</p>
-                <Divider />
-                <Form onSubmit={handleSubmit(onSubmit)}>
-                <Controller
-                    name="email"
-                    control={control}
-                    defaultValue=""
-                    render={({ field }) => (
-                      <div className="mb-2">
-                        <Form.Label>{t("staffLogin.labelEmail")}</Form.Label>
-                        <Form.Control type="email" {...field} isInvalid={!!errors.email}/>
-                        <Form.Control.Feedback type="invalid">
-                          {errors.email?.message}
-                        </Form.Control.Feedback>
-                      </div>
-                    )}
-                    rules={{
-                      required: {
-                        value: true,
-                        message: t("staffLogin.messages.required", {
-                          val: t("staffLogin.labelEmail"),
-                        }),
-                      },
-                      validate: (value: string) =>
-                        validator.isEmail(value) ||
-                        t("staffLogin.messages.invalidEmail").toString(),
-                    }}
-                  />
-                  <Controller
-                    name="password"
-                    control={control}
-                    defaultValue=""
-                    render={({ field }) => (
-                      <div className="mb-2">
-                        <Form.Label>{t("staffLogin.labelPassword")}</Form.Label>
-                        <Form.Control type="password" {...field} isInvalid={!!errors.password}/>
-                        <Form.Control.Feedback type="invalid">
-                          {errors.password?.message}
-                        </Form.Control.Feedback>
-                      </div>
-                    )}
-                    rules={{
-                      required: {
-                        value: true,
-                        message: t("staffLogin.messages.required", {
-                          val: t("staffLogin.labelPassword"),
-                        }),
-                      }
-                    }}
-                  />
-                  <Button type="submit" variant="secondary" className="mt-2 w-100">
-                    {t("staffLogin.buttonSignIn")}
-                  </Button>
-                </Form>
-              </Modal>
-            </Col>
-            </Row>
-        </Container>
-    );
+  async function validateCurrentUser() {
+    try {
+      const response = await getCurrentUser();
+      if (response.status == 200) {
+        navigate("/staff");
+      }
+    } catch (e) {}
+  }
+
+  useEffect(() => {
+    validateCurrentUser();
+  }, []);
 
+  return (
+    <Container className="position-absolute top-50 start-50 translate-middle">
+      <Row>
+        <Col xs={{ span: 10, offset: 1 }}>
+          <Modal>
+            <h1 className="text-center p-1">{t("staffLogin.title")}</h1>
+            <p className="text-center">{t("staffLogin.subtitle")}</p>
+            <Divider />
+            <Form onSubmit={handleSubmit(onSubmit)}>
+              <Controller
+                name="email"
+                control={control}
+                defaultValue=""
+                render={({ field }) => (
+                  <div className="mb-2">
+                    <Form.Label>{t("staffLogin.labelEmail")}</Form.Label>
+                    <Form.Control
+                      type="email"
+                      {...field}
+                      isInvalid={!!errors.email}
+                    />
+                    <Form.Control.Feedback type="invalid">
+                      {errors.email?.message}
+                    </Form.Control.Feedback>
+                  </div>
+                )}
+                rules={{
+                  required: {
+                    value: true,
+                    message: t("staffLogin.messages.required", {
+                      val: t("staffLogin.labelEmail"),
+                    }),
+                  },
+                  validate: (value: string) =>
+                    validator.isEmail(value) ||
+                    t("staffLogin.messages.invalidEmail").toString(),
+                }}
+              />
+              <Controller
+                name="password"
+                control={control}
+                defaultValue=""
+                render={({ field }) => (
+                  <div className="mb-2">
+                    <Form.Label>{t("staffLogin.labelPassword")}</Form.Label>
+                    <Form.Control
+                      type="password"
+                      {...field}
+                      isInvalid={!!errors.password}
+                    />
+                    <Form.Control.Feedback type="invalid">
+                      {errors.password?.message}
+                    </Form.Control.Feedback>
+                  </div>
+                )}
+                rules={{
+                  required: {
+                    value: true,
+                    message: t("staffLogin.messages.required", {
+                      val: t("staffLogin.labelPassword"),
+                    }),
+                  },
+                }}
+              />
+              <Button type="submit" variant="secondary" className="mt-2 w-100">
+                {t("staffLogin.buttonSignIn")}
+              </Button>
+            </Form>
+          </Modal>
+        </Col>
+      </Row>
+    </Container>
+  );
 }
 
 export default Login;
diff --git a/server/src/controllers/auth.controllers.ts b/server/src/controllers/auth.controllers.ts
index b17e199..baadebb 100644
--- a/server/src/controllers/auth.controllers.ts
+++ b/server/src/controllers/auth.controllers.ts
@@ -30,7 +30,13 @@ const authLoginController = async (req: Request, res: Response) => {
 
       const token = jwt.sign(payload, envVars.JWT_SECRET);
 
-      return res.cookie("token", token).json({ success: true });
+      return res
+        .cookie("token", token, {
+          httpOnly: true,
+          secure: process.env.NOVE_ENV === "production",
+          path: "/",
+        })
+        .json({ success: true });
     } else {
       return res.status(401).json({ success: false, error: "invalidPassword" });
     }
@@ -39,9 +45,20 @@ const authLoginController = async (req: Request, res: Response) => {
     return res.status(500).json({ success: false, error: "serverError" });
   }
 };
+const authLogOutController = async (req: Request, res: Response) => {
+  return res
+    .cookie("token", null, {
+      httpOnly: true,
+      secure: process.env.NOVE_ENV === "production",
+      path: "/",
+      expires: new Date(0),
+    })
+    .json({ success: true });
+};
 
 const authControllers = {
   authLoginController,
+  authLogOutController,
 };
 
 export default authControllers;
diff --git a/server/src/routes/auth.routes.ts b/server/src/routes/auth.routes.ts
index f6601bc..c8c1ca5 100644
--- a/server/src/routes/auth.routes.ts
+++ b/server/src/routes/auth.routes.ts
@@ -13,5 +13,6 @@ authRouter.post(
   handleValidationResult,
   authControllers.authLoginController
 );
+authRouter.post("/api/logout/", authControllers.authLogOutController);
 
 export default authRouter;
-- 
GitLab