From 548ee0a2810c4425e1828c10a536befac4ef44aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20W=C3=B6gerbauer?= <56993269+d-woegerbauer@users.noreply.github.com> Date: Thu, 7 Jul 2022 17:29:13 +0200 Subject: [PATCH] Implement login functions and basic navigation --- backend/.env.example | 1 + backend/src/user/user.service.ts | 3 +- frontend/components/atoms/Button/Button.tsx | 10 ++- frontend/components/atoms/Heading/Heading.tsx | 31 +++++-- frontend/components/atoms/ProtectedRoute.tsx | 34 ++++++++ frontend/components/atoms/svg/SvgLogoBig.tsx | 24 ++++++ frontend/components/atoms/svg/SvgUser.tsx | 21 +++++ .../atoms/svg/original/logo_big.svg | 4 + .../components/atoms/svg/original/user.svg | 1 + .../molecules/LoginForm/LoginForm.stories.tsx | 32 ++++++++ .../molecules/LoginForm/LoginForm.tsx | 59 +++++++++++++ .../components/organisms/Navbar/Navbar.tsx | 77 +++++++++++++++++ frontend/package-lock.json | 14 ++++ frontend/package.json | 1 + frontend/pages/_app.tsx | 10 ++- frontend/pages/index.tsx | 43 ++++++++++ frontend/pages/login.tsx | 11 +++ frontend/pages/profile.tsx | 46 +++++++++++ frontend/stitches.config.ts | 1 + frontend/utils/authHelper.ts | 82 +++++++++++++++++++ frontend/utils/requests/user.ts | 24 ++++++ frontend/utils/routes.ts | 4 + 22 files changed, 517 insertions(+), 16 deletions(-) create mode 100644 backend/.env.example create mode 100644 frontend/components/atoms/ProtectedRoute.tsx create mode 100644 frontend/components/atoms/svg/SvgLogoBig.tsx create mode 100644 frontend/components/atoms/svg/SvgUser.tsx create mode 100644 frontend/components/atoms/svg/original/logo_big.svg create mode 100644 frontend/components/atoms/svg/original/user.svg create mode 100644 frontend/components/molecules/LoginForm/LoginForm.stories.tsx create mode 100644 frontend/components/molecules/LoginForm/LoginForm.tsx create mode 100644 frontend/components/organisms/Navbar/Navbar.tsx create mode 100644 frontend/pages/login.tsx create mode 100644 frontend/pages/profile.tsx create mode 100644 frontend/utils/authHelper.ts create mode 100644 frontend/utils/requests/user.ts create mode 100644 frontend/utils/routes.ts diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..69c220c --- /dev/null +++ b/backend/.env.example @@ -0,0 +1 @@ +DATABASE_URL="mongodb+srv://name:name@name.goh49.mongodb.net/name?retryWrites=true&w=majority" \ No newline at end of file diff --git a/backend/src/user/user.service.ts b/backend/src/user/user.service.ts index 32f43f7..bfeed72 100644 --- a/backend/src/user/user.service.ts +++ b/backend/src/user/user.service.ts @@ -63,8 +63,7 @@ export class UserService { async findOneDetailed(jwt: string): Promise { const decoded = await this.jwtService.decode(jwt); //@ts-ignore - const identifier = decoded.identifier.userIdentifier.identifier - + const identifier = decoded.identifier const user = await prisma.users.findUnique({ where: { identifier: identifier, diff --git a/frontend/components/atoms/Button/Button.tsx b/frontend/components/atoms/Button/Button.tsx index 7bd89d4..4ec63d7 100644 --- a/frontend/components/atoms/Button/Button.tsx +++ b/frontend/components/atoms/Button/Button.tsx @@ -7,6 +7,7 @@ type Props = { disabled?: boolean; leftIcon?: React.ReactNode; rightIcon?: React.ReactNode; + onClick?: () => void; children: React.ReactNode; }; @@ -17,12 +18,12 @@ const StyledButton = styled("button", { borderRadius: "$border-radius-medium", color: "$neutral-100", - backgroundColor: "$brand-200", + backgroundColor: "$brand-400", cursor: "pointer", transition: "all 0.2s ease-in-out", "&:hover": { - backgroundColor: "$brand-400", + backgroundColor: "$brand-500", }, "&:disabled": { @@ -33,6 +34,7 @@ const StyledButton = styled("button", { "&:focus": { outline: "none", + backgroundColor: "$brand-200", border: "$brand-400 $borderWidths$border-width-medium solid", }, @@ -41,6 +43,7 @@ const StyledButton = styled("button", { medium: {}, small: { padding: "$spacing-1x $spacing-3x", + }, }, }, @@ -51,11 +54,12 @@ export const Button: React.FC = ({ disabled = false, leftIcon, rightIcon, + onClick, children, }) => { return ( <> - + {children} diff --git a/frontend/components/atoms/Heading/Heading.tsx b/frontend/components/atoms/Heading/Heading.tsx index 40404b8..bd73730 100644 --- a/frontend/components/atoms/Heading/Heading.tsx +++ b/frontend/components/atoms/Heading/Heading.tsx @@ -3,32 +3,47 @@ import { styled } from "../../../stitches.config"; import type * as Stitches from "@stitches/react"; type Props = { - styling: Styling; + styling?: Stitches.VariantProps["styling"]; color?: Stitches.VariantProps["color"]; - text: string; + children: React.ReactNode; + alignment?: Stitches.VariantProps["alignment"]; }; const StyledHeading = styled("h1", { width: "100%", - fontSize: "4.5rem", + fontSize: "3.5rem", color: "black", textAlign: "center", variants: { styling: {}, color: {}, + alignment: { + left: { + textAlign: "left", + }, + center: { + textAlign: "center", + }, + right: { + textAlign: "right", + }, + }, }, }); -export const Heading: React.FC = ({ styling, color, text }) => { +export const Heading: React.FC = ({ + styling, + color, + children, + alignment, +}) => { return ( <> - - {text} + + {children} ); }; - -export type Styling = Stitches.VariantProps["styling"]; diff --git a/frontend/components/atoms/ProtectedRoute.tsx b/frontend/components/atoms/ProtectedRoute.tsx new file mode 100644 index 0000000..b98afe7 --- /dev/null +++ b/frontend/components/atoms/ProtectedRoute.tsx @@ -0,0 +1,34 @@ +import { useEffect } from "react"; +import { getAccessToken } from "../../utils/authHelper"; +import { appRoutes } from "../../utils/routes"; + +//check if you are on the client (browser) or server +const isBrowser = () => typeof window !== "undefined"; + +const ProtectedRoute = ({ router, children }) => { + useEffect(() => { + checkIfUserIsLoggedIn(); + + async function checkIfUserIsLoggedIn() { + // Identify authenticated user + const accessToken = await getAccessToken(); + + let unprotectedRoutes = [ + appRoutes.LOGIN, + ]; + + /** + * @var pathIsProtected Checks if path exists in the unprotectedRoutes routes array + */ + let pathIsProtected = unprotectedRoutes.indexOf(router.pathname) === -1; + + if (isBrowser() && !accessToken && pathIsProtected) { + router.push(appRoutes.LOGIN); + } + } + }, [router]); + + return children; +}; + +export default ProtectedRoute; \ No newline at end of file diff --git a/frontend/components/atoms/svg/SvgLogoBig.tsx b/frontend/components/atoms/svg/SvgLogoBig.tsx new file mode 100644 index 0000000..506e13e --- /dev/null +++ b/frontend/components/atoms/svg/SvgLogoBig.tsx @@ -0,0 +1,24 @@ +import * as React from "react"; + +const SvgLogoBig = (props) => ( + + {props?.title || "teamo logo"} + + + +); + +export default SvgLogoBig; diff --git a/frontend/components/atoms/svg/SvgUser.tsx b/frontend/components/atoms/svg/SvgUser.tsx new file mode 100644 index 0000000..af46ddf --- /dev/null +++ b/frontend/components/atoms/svg/SvgUser.tsx @@ -0,0 +1,21 @@ +import * as React from "react" + +const SvgUser = (props) => ( + + {props?.title || 'user icon'} + + + +) + +export default SvgUser diff --git a/frontend/components/atoms/svg/original/logo_big.svg b/frontend/components/atoms/svg/original/logo_big.svg new file mode 100644 index 0000000..c16ed30 --- /dev/null +++ b/frontend/components/atoms/svg/original/logo_big.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/components/atoms/svg/original/user.svg b/frontend/components/atoms/svg/original/user.svg new file mode 100644 index 0000000..7bb5f29 --- /dev/null +++ b/frontend/components/atoms/svg/original/user.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/components/molecules/LoginForm/LoginForm.stories.tsx b/frontend/components/molecules/LoginForm/LoginForm.stories.tsx new file mode 100644 index 0000000..bcca355 --- /dev/null +++ b/frontend/components/molecules/LoginForm/LoginForm.stories.tsx @@ -0,0 +1,32 @@ +import React from "react"; +import { ComponentMeta } from "@storybook/react"; +import { disabledStorybookArgTypesFromStitches } from "../../../.storybook/helper"; +import { LoginForm } from "./LoginForm"; +import { styled } from "@stitches/react"; + +export default { + title: "Molecules/LoginForm", + component: LoginForm, + argTypes: { + size: { + options: ["medium", "small"], + control: { type: "radio" }, + }, + disabled: { + options: [true, false], + control: { type: "radio" }, + }, + ...disabledStorybookArgTypesFromStitches, + }, +} as ComponentMeta; + +const LoginFormWrapper = styled("div", { + display: "flex", + width: "30vw", +}); + +export const Default = () => ( + + + +); diff --git a/frontend/components/molecules/LoginForm/LoginForm.tsx b/frontend/components/molecules/LoginForm/LoginForm.tsx new file mode 100644 index 0000000..9d5b7ec --- /dev/null +++ b/frontend/components/molecules/LoginForm/LoginForm.tsx @@ -0,0 +1,59 @@ +import { useRouter } from "next/router"; +import React from "react"; +import { styled } from "../../../stitches.config"; +import { login } from "../../../utils/authHelper"; +import { Button } from "../../atoms/Button/Button"; +import { Checkbox } from "../../atoms/Checkbox/Checkbox"; +import { Heading } from "../../atoms/Heading/Heading"; +import { InputField } from "../../atoms/InputField/InputField"; +import { PasswordField } from "../../atoms/PasswordField/PasswordField"; + +type Props = {}; + +const LoginFormLayout = styled("div", { + display: "flex", + flexDirection: "column", + justifyContent: "center", + width: "100%", + padding: "$spacing-4x", + + backgroundColor: "$brand-300", + borderRadius: "$border-radius-medium", +}); + +export const LoginForm: React.FC = ({}) => { + const [identifier, setIdentifier] = React.useState(""); + const [password, setPassword] = React.useState(""); + const [rememberMe, setRememberMe] = React.useState(false); + + const router = useRouter(); + + return ( + <> + + Login + + + Remember me + + + + ); +}; diff --git a/frontend/components/organisms/Navbar/Navbar.tsx b/frontend/components/organisms/Navbar/Navbar.tsx new file mode 100644 index 0000000..5bcc9b4 --- /dev/null +++ b/frontend/components/organisms/Navbar/Navbar.tsx @@ -0,0 +1,77 @@ +import Link from "next/link"; +import React from "react"; +import { styled } from "../../../stitches.config"; +import SvgLogoBig from "../../atoms/svg/SvgLogoBig"; +import SvgUser from "../../atoms/svg/SvgUser"; + +type Props = {}; + +const NavbarLayout = styled("div", { + display: "flex", + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + width: "100%", + padding: "$spacing-2x", + backgroundColor: "$brand-300", +}); + +const LogoLayout = styled("div", { + display: "flex", + height: "4vh", +}); + +const StyledLink = styled("a", { + color: "$neutral-100", + textDecoration: "none", +}); + +const NavigationItems = styled("div", { + display: "flex", + flexDirection: "row", + alignItems: "center", + gap: "30px", +}); + +const UserIconLayout = styled("div", { + display: "flex", + width: "3vh", + height: "3vh", + + color: "$neutral-100", +}); + +export const Navbar: React.FC = ({}) => { + return ( + <> + + + + + + + + + + + + HOME + + + + + LOGIN + + + + + + + + + + + + + ); +}; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ff7464c..1b21a0f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,6 +9,7 @@ "@types/node": "18.0.3", "@types/react": "18.0.15", "dotenv": "16.0.1", + "js-cookie": "^3.0.1", "next": "12.2.0", "react": "18.2.0", "react-dom": "18.2.0", @@ -17191,6 +17192,14 @@ "node": ">=8" } }, + "node_modules/js-cookie": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.1.tgz", + "integrity": "sha512-+0rgsUXZu4ncpPxRL+lNEptWMOWl9etvPHc/koSRp6MPwpRYAhmk0dUG00J4bxVV3r9uUzfo24wW0knS07SKSw==", + "engines": { + "node": ">=12" + } + }, "node_modules/js-string-escape": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/js-string-escape/-/js-string-escape-1.0.1.tgz", @@ -37280,6 +37289,11 @@ } } }, + "js-cookie": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.1.tgz", + "integrity": "sha512-+0rgsUXZu4ncpPxRL+lNEptWMOWl9etvPHc/koSRp6MPwpRYAhmk0dUG00J4bxVV3r9uUzfo24wW0knS07SKSw==" + }, "js-string-escape": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/js-string-escape/-/js-string-escape-1.0.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index f740cb1..428517c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,6 +12,7 @@ "@types/node": "18.0.3", "@types/react": "18.0.15", "dotenv": "16.0.1", + "js-cookie": "^3.0.1", "next": "12.2.0", "react": "18.2.0", "react-dom": "18.2.0", diff --git a/frontend/pages/_app.tsx b/frontend/pages/_app.tsx index 2df175e..332c944 100644 --- a/frontend/pages/_app.tsx +++ b/frontend/pages/_app.tsx @@ -1,7 +1,7 @@ import { AppProps } from "next/app"; import { useRouter } from "next/router"; +import ProtectedRoute from "../components/atoms/ProtectedRoute"; import { globalCss } from "../stitches.config"; -import "../utils/skeleton.css"; const globalStyles = globalCss({ "*": { @@ -10,7 +10,7 @@ const globalStyles = globalCss({ padding: 0, }, body: { - fontFamily: "Poppins, sans-serif", + fontFamily: "comic-sans, sans-serif", fontWeight: "$regular", overflowX: "hidden", backgroundColor: "$background", @@ -23,7 +23,11 @@ function App({ Component, pageProps }: AppProps) { const router = useRouter(); - return ; + return ( + + + + ); } export default App; diff --git a/frontend/pages/index.tsx b/frontend/pages/index.tsx index e69de29..41fe48d 100644 --- a/frontend/pages/index.tsx +++ b/frontend/pages/index.tsx @@ -0,0 +1,43 @@ +import { styled } from "../stitches.config"; +import dynamic from "next/dynamic"; +import { LoginForm } from "../components/molecules/LoginForm/LoginForm"; +import { useEffect } from "@storybook/addons"; +import { getUser } from "../utils/requests/user"; +import { Navbar } from "../components/organisms/Navbar/Navbar"; +import { useState } from "react"; +import { logout } from "../utils/authHelper"; +import { useRouter } from "next/router"; +import { Button } from "../components/atoms/Button/Button"; + +export default function Home() { + const Router = useRouter(); + + const [user, setUser] = useState(null); + + if(!user) { + getUser() + .then((user) => { + console.log(user); + setUser(user); + }) + .catch(() => {}); + } + + return ( + <> + + + {user && ( + <> +

Hello {user.name}

+

You are logged in

+ + + )} + + ); +} diff --git a/frontend/pages/login.tsx b/frontend/pages/login.tsx new file mode 100644 index 0000000..15fb9de --- /dev/null +++ b/frontend/pages/login.tsx @@ -0,0 +1,11 @@ +import { styled } from "../stitches.config"; +import dynamic from "next/dynamic"; +import { LoginForm } from "../components/molecules/LoginForm/LoginForm"; + +export default function Home() { + return ( + <> + + + ); +} diff --git a/frontend/pages/profile.tsx b/frontend/pages/profile.tsx new file mode 100644 index 0000000..81e2fed --- /dev/null +++ b/frontend/pages/profile.tsx @@ -0,0 +1,46 @@ +import { styled } from "../stitches.config"; +import dynamic from "next/dynamic"; +import { LoginForm } from "../components/molecules/LoginForm/LoginForm"; +import { useEffect } from "@storybook/addons"; +import { getUser } from "../utils/requests/user"; +import { Navbar } from "../components/organisms/Navbar/Navbar"; +import { useState } from "react"; +import { logout } from "../utils/authHelper"; +import { useRouter } from "next/router"; +import { Button } from "../components/atoms/Button/Button"; + +export default function Home() { + const Router = useRouter(); + + const [user, setUser] = useState(null); + + if (!user) { + getUser() + .then((user) => { + console.log(user); + setUser(user); + }) + .catch(() => {}); + } + + return ( + <> + + + {user && ( + <> +

Hello {user.name}

+

You are at the profile page

+ + + )} + + ); +} diff --git a/frontend/stitches.config.ts b/frontend/stitches.config.ts index d8cda85..3139a4f 100644 --- a/frontend/stitches.config.ts +++ b/frontend/stitches.config.ts @@ -22,6 +22,7 @@ export const styles = { "brand-200": "#b7ccde", "brand-300": "#6f93af", "brand-400": "#245a91", + "brand-500": "#153967", }, shadows: { }, diff --git a/frontend/utils/authHelper.ts b/frontend/utils/authHelper.ts new file mode 100644 index 0000000..ecbf270 --- /dev/null +++ b/frontend/utils/authHelper.ts @@ -0,0 +1,82 @@ +import cookie from "js-cookie"; + +export async function login(identifier, password) { + const response = await fetch( + `${process.env.NEXT_PUBLIC_BACKEND_URL}/api/auth/login`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + identifier, + password, + }), + } + ); + + if (response.status !== 201) { + throw new Error("Invalid credentials"); + } + + const responseBody = await response.json(); + + if (responseBody.data.accessToken && responseBody.data.refreshToken) { + + setCookie("accessToken", responseBody.data.accessToken, 1 / 96); + setCookie( + "refreshToken", + responseBody.data.refreshToken, + 7 + ); + return true; + } + + return false; +} + +export async function logout() { + cookie.remove("accessToken"); + cookie.remove("refreshToken"); +} + +export async function getAccessToken(): Promise { + if (cookie.get("accessToken")) { + return cookie.get("accessToken"); + } else if (cookie.get("refreshToken")) { + return refreshAccessToken(cookie.get("refreshToken")); + } else { + return ""; + } +} + +async function refreshAccessToken(refreshToken: string): Promise { + const response = await fetch( + `${process.env.NEXT_PUBLIC_BACKEND_URL}/api/auth/refresh`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + token: refreshToken, + }), + } + ); + + const responseBody = await response.json(); + if (responseBody.data.accessToken) { + setCookie("accessToken", responseBody.data.accessToken, 1 / 96); + return responseBody.data.accessToken; + } + + return ""; +} + +function setCookie(name, accessToken: string, expirationTimeInDays) { + cookie.set(name, accessToken, { + expires: expirationTimeInDays, + secure: process.env.NODE_ENV === "production", + sameSite: "strict", + }); +} diff --git a/frontend/utils/requests/user.ts b/frontend/utils/requests/user.ts new file mode 100644 index 0000000..e9d40c2 --- /dev/null +++ b/frontend/utils/requests/user.ts @@ -0,0 +1,24 @@ +import { getAccessToken } from "../authHelper"; + +export async function getUser() { + const accessToken = await getAccessToken(); + console.log(accessToken); + const response = await fetch( + `${process.env.NEXT_PUBLIC_BACKEND_URL}/api/user`, + { + method: "GET", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${accessToken}`, + }, + } + ); + + if (response.status !== 200) { + throw new Error("Invalid credentials"); + } + + const responseBody = await response.json(); + + return responseBody.data; +} diff --git a/frontend/utils/routes.ts b/frontend/utils/routes.ts new file mode 100644 index 0000000..862ef1b --- /dev/null +++ b/frontend/utils/routes.ts @@ -0,0 +1,4 @@ +export const appRoutes = { + HOME: "/", + LOGIN: "/login", +};