diff --git a/README.md b/README.md index dfa05e177..0f577362e 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,17 @@ # Project Auth API -Replace this readme with your own information about your project. - -Start by briefly describing the assignment in a sentence or two. Keep it short and to the point. +This fullstack project requires developing a backend to support user registration, login and an authenticated endpoint accessible only to loggen-in users. +The frontend features a startpage, a registration and login form and a page displaying authenticated content. Also sign-out button that removes the access token from local storage and brings the user back to the startpage. ## The problem -Describe how you approached to problem, and what tools and techniques you used to solve it. How did you plan? What technologies did you use? If you had more time, what would be next? +I started by using the code from the codealong in the backend, trying to figure out how to do an authentication process and use bycript properly. +Being frustrated with backend I started creating the frontend and for a bit my project was all over the place. +In the end I went back to finish and test backend and then finish a frontend that could also be used for our final project. + +If I had more time, I would add a loading animation page, because it takes forever on login and registration. ## View it live -Every project should be deployed somewhere. Be sure to include the link to the deployed project so that the viewer can click around and see what it's all about. +Backend: https://project-auth-ziup.onrender.com +Frontend: https://project-auth-eliane.netlify.app diff --git a/backend/package.json b/backend/package.json index 8de5c4ce0..17a4b9a1d 100644 --- a/backend/package.json +++ b/backend/package.json @@ -12,9 +12,13 @@ "@babel/core": "^7.17.9", "@babel/node": "^7.16.8", "@babel/preset-env": "^7.16.11", + "bcrypt": "^5.1.1", "cors": "^2.8.5", - "express": "^4.17.3", - "mongoose": "^8.0.0", + "dotenv": "^16.4.5", + "express": "^4.19.2", + "express-list-endpoints": "^7.1.0", + "mongodb": "^4.17.2", + "mongoose": "^8.4.0", "nodemon": "^3.0.1" } } diff --git a/backend/server.js b/backend/server.js index dfe86fb8e..b47efcd1d 100644 --- a/backend/server.js +++ b/backend/server.js @@ -1,27 +1,126 @@ import cors from "cors"; import express from "express"; import mongoose from "mongoose"; +import dotenv from "dotenv"; +import expressListEndpoints from "express-list-endpoints"; +import bcrypt from "bcrypt"; +import crypto from "crypto"; -const mongoUrl = process.env.MONGO_URL || "mongodb://localhost/project-mongo"; +dotenv.config(); + +const mongoUrl = process.env.MONGO_URL || "mongodb://localhost/auth"; mongoose.connect(mongoUrl); mongoose.Promise = Promise; -// Defines the port the app will run on. Defaults to 8080, but can be overridden -// when starting the server. Example command to overwrite PORT env variable value: -// PORT=9000 npm start +//create schema and model +const { Schema, model } = mongoose; +const userSchema = new Schema({ + name: { + type: String, + unique: true, + }, + email: { + type: String, + unique: true, + }, + password: { + type: String, + required: true, + }, + accessToken: { + type: String, + default: () => crypto.randomBytes(128).toString("hex"), + }, +}); +const User = model("User", userSchema); + +//defines the port the app will run on const port = process.env.PORT || 8080; const app = express(); -// Add middlewares to enable cors and json body parsing +//add middlewares to enable cors and json body parsing app.use(cors()); app.use(express.json()); -// Start defining your routes here +//middleware to check if database is available +app.use((req, res, next) => { + if (mongoose.connection.readyState === 1) { + next(); + } else { + res.status(503).json({ error: "service unavailable" }); + } +}); + +//middleware to authenticate user +const authenticateUser = async (req, res, next) => { + const user = await User.findOne({ accessToken: req.header("Authorization") }); + if (user) { + req.user = user; + next(); + } else { + res.status(401).json({ + loggedOut: true, + message: "You have to log in to get access", + }); + } +}; + +//registration endpoint +app.post("/register", async (req, res) => { + try { + const { name, email, password } = req.body; + const salt = bcrypt.genSaltSync(); + const user = new User({ + name, + email, + password: bcrypt.hashSync(password, salt), + }); + await user.save(); + res.status(201).json({ + success: true, + message: "User created", + id: user._id, + accessToken: user.accessToken, + }); + } catch (error) { + res.status(400).json({ + success: false, + message: "Could not create user", + errors: error, + }); + } +}); + +//authenticated endpoint *super secret endpoint* +app.get("/dashboard", authenticateUser, (req, res) => { + res.json({ + secret: "This is the secret dashboard, only visible to logged-in users!", + }); +}); + +//login endpoint +app.post("/login", async (req, res) => { + //find user by name + const user = await User.findOne({ name: req.body.name }); + //check if password is correct + if (user && bcrypt.compareSync(req.body.password, user.password)) { + //success + res.status(200).json({ userId: user._id, accessToken: user.accessToken }); + } else { + //failure + res + .status(401) + .json({ notFound: true, message: "Invalid name or password" }); + } +}); + +//route to list all endpoints app.get("/", (req, res) => { - res.send("Hello Technigo!"); + const endpoints = expressListEndpoints(app); + res.json(endpoints); }); -// Start the server +//start the server app.listen(port, () => { console.log(`Server running on http://localhost:${port}`); }); diff --git a/netlify.toml b/frontend/netlify.toml similarity index 68% rename from netlify.toml rename to frontend/netlify.toml index 95443a1f3..558042533 100644 --- a/netlify.toml +++ b/frontend/netlify.toml @@ -2,5 +2,11 @@ # how it should build the JavaScript assets to deploy from. [build] base = "frontend/" - publish = "build/" + publish = "dist" command = "npm run build" + +[[redirects]] + from = "/*" + to = "/index.html" + status = 200 + diff --git a/frontend/package.json b/frontend/package.json index e9c95b79f..955b69152 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,13 +10,16 @@ "preview": "vite preview" }, "dependencies": { - "react": "^18.2.0", - "react-dom": "^18.2.0" + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.23.1", + "styled-components": "^6.1.11" }, "devDependencies": { "@types/react": "^18.2.15", "@types/react-dom": "^18.2.7", "@vitejs/plugin-react": "^4.0.3", + "babel-plugin-styled-components": "^2.1.4", "eslint": "^8.45.0", "eslint-plugin-react": "^7.32.2", "eslint-plugin-react-hooks": "^4.6.0", diff --git a/frontend/public/globe.png b/frontend/public/globe.png new file mode 100644 index 000000000..1802a8e03 Binary files /dev/null and b/frontend/public/globe.png differ diff --git a/frontend/public/header.jpg b/frontend/public/header.jpg new file mode 100644 index 000000000..ea0da6e35 Binary files /dev/null and b/frontend/public/header.jpg differ diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 1091d4310..45af87ccc 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,3 +1,18 @@ +import { BrowserRouter, Routes, Route } from "react-router-dom"; +import { Login } from "./components/Login"; +import { Registration } from "./components/Registration"; +import { Dashboard } from "./components/Dashboard"; +import { Homepage } from "./components/Homepage"; + export const App = () => { - return
Find me in src/app.jsx!
; + return ( + + + } /> + } /> + } /> + } /> + + + ); }; diff --git a/frontend/src/components/Dashboard.jsx b/frontend/src/components/Dashboard.jsx new file mode 100644 index 000000000..33c73904d --- /dev/null +++ b/frontend/src/components/Dashboard.jsx @@ -0,0 +1,90 @@ +//imports +import styled from "styled-components"; +import { Button } from "../reusables/Button"; +import Globe from "../../public/globe.png"; +import HeaderPicture from "../../public/header.jpg"; +import { useNavigate } from "react-router-dom"; + +//styling +const DashboardSection = styled.section` + display: flex; + flex-direction: column; + + @media all and (min-width: 1024px) { + flex-direction: row; + } +`; + +const DashboardHeader = styled.div` + display: flex; + background-image: url(${HeaderPicture}); + background-size: cover; + background-position: center; + justify-content: space-between; + align-items: center; + + @media all and (min-width: 1024px) { + flex-direction: column-reverse; + justify-content: flex-end; + width: 30%; + height: 100vh; + } +`; + +const DashboardGlobe = styled.img` + height: 70px; + width: 70px; + margin: 20px; + @media all and (min-width: 1024px) { + height: 250px; + width: 250px; + } +`; + +const ContentWrapper = styled.div` + background: var(--grey); + border-radius: 20px; + box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3); + margin: 50px; + padding: 20px; + + @media all and (min-width: 1024px) { + width: 70%; + } +`; + +const StyledButton = styled(Button)` + width: 250px; +`; + +//component +export const Dashboard = () => { + const navigate = useNavigate(); + + const handleLogout = () => { + localStorage.removeItem("accessToken"); + localStorage.removeItem("username"); + //navigate to homepage + navigate("/"); + }; + + return ( + + + + Log out + + +

Welcome

+

๐Ÿคซ This is only shown to logged in users ๐Ÿคซ

+

๐Ÿ‘‡

+

๐Ÿ‘‡

+

๐Ÿ‘‡

+

+ Here we will show the dashboard with all your travel data, points and + distances. +

+
+
+ ); +}; diff --git a/frontend/src/components/Homepage.jsx b/frontend/src/components/Homepage.jsx new file mode 100644 index 000000000..b5b64f52d --- /dev/null +++ b/frontend/src/components/Homepage.jsx @@ -0,0 +1,78 @@ +//import +import { Link } from "react-router-dom"; +import styled from "styled-components"; +import HeaderPicture from "../../public/header.jpg"; +import Globe from "../../public/globe.png"; +import { Button } from "../reusables/Button"; + +//styling +const StyledSection = styled.section` + display: flex; + flex-direction: column; + align-items: center; + background-image: url(${HeaderPicture}); + background-size: cover; + background-position: center; + height: 100vh; + padding: 20px 0; +`; + +const HeaderGlobeContainer = styled.div` + display: flex; + justify-content: center; + align-items: center; + margin: 20px 0; +`; + +const HeaderGlobe = styled.img` + width: 280px; + height: 280px; + + @media all and (min-width: 744px) { + width: 400px; + height: 400px; + } +`; + +const BottomForm = styled.div` + background: var(--yellow); + position: fixed; + z-index: 0; + bottom: 0; + width: 280px; + height: 140px; + border-radius: 140px 140px 0 0; + display: flex; + justify-content: center; + align-items: center; + text-align: center; + + @media all and (min-width: 744px) { + width: 400px; + height: 200px; + border-radius: 200px 200px 0 0; + } +`; + +const ActionButton = styled(Button)` + position: relative; + z-index: 1; +`; + +//component +export const Homepage = () => { + return ( + + + + + + + + + Take action + + + + ); +}; diff --git a/frontend/src/components/Login.jsx b/frontend/src/components/Login.jsx new file mode 100644 index 000000000..87e08150e --- /dev/null +++ b/frontend/src/components/Login.jsx @@ -0,0 +1,157 @@ +//imports +import styled from "styled-components"; +import { useState } from "react"; +import { Link, useNavigate } from "react-router-dom"; +import { Header } from "../reusables/Header"; +import { Button } from "../reusables/Button"; +import { Loading } from "../reusables/Loading"; + +// const apiEnv = "http://localhost:8080/"; + +//styling +const LoginSection = styled.section` + display: flex; + flex-direction: column; + align-items: center; + height: 100vh; + overflow: hidden; + + @media all and (min-width: 1024px) { + flex-direction: row; + } +`; + +const FormWrapper = styled.div` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + @media all and (min-width: 1024px) { + width: 50%; + } +`; + +const StyledForm = styled.form` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding-top: 20px; +`; + +const StyledInput = styled.input` + width: 280px; + background: var(--grey); + border: none; + border-radius: 30px; + padding: 20px; + height: 50px; + margin: 10px; + font-size: 1.1em; + box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3); + + @media all and (min-width: 744px) { + width: 400px; + } +`; + +const FormText = styled.p` + text-align: center; + padding: 0 20px; +`; + +//component +export const Login = () => { + const navigate = useNavigate(); + const [message, setMessage] = useState(""); + const [loading, setLoading] = useState(false); + + const [formData, setFormData] = useState({ + name: "", + password: "", + }); + + const apiEnv = import.meta.env.VITE_API_KEY; + + const handleChange = (e) => { + const { name, value } = e.target; + setFormData({ + ...formData, + [name]: value, + }); + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + setLoading(true); + try { + const response = await fetch(`${apiEnv}login`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${localStorage.getItem("accessToken")}`, + }, + body: JSON.stringify(formData), + }); + if (response.status === 200) { + const data = await response.json(); + const accessToken = data.accessToken; + setMessage("Sign-in successful!"); + localStorage.setItem("accessToken", accessToken); + navigate("/dashboard"); + } else { + const errorData = await response.json(); + setMessage(`Sign-in failed: ${errorData.message}`); + } + } catch (error) { + console.error("Login failed:", error); + setMessage("An error occurred. Please try again later."); + } finally { + setLoading(false); + } + }; + + return ( + +
+ + + + + + {/* show error message */} + {message &&

{message}

} +
+ {/* Conditionally render loading spinner or login button */} + {loading ? : } + + If you don’t have an account yet, create yours below.๐Ÿ‘‡{" "} + + +

Register

+ + +

Home

+ +
+ + ); +}; diff --git a/frontend/src/components/Registration.jsx b/frontend/src/components/Registration.jsx new file mode 100644 index 000000000..70d8e8f52 --- /dev/null +++ b/frontend/src/components/Registration.jsx @@ -0,0 +1,202 @@ +//imports +import styled from "styled-components"; +import { useState } from "react"; +import { Link, useNavigate } from "react-router-dom"; +import { Header } from "../reusables/Header"; +import { Button } from "../reusables/Button"; + +// const apiEnv = "http://localhost:8080/"; + +//styling +const RegistrationSection = styled.section` + display: flex; + flex-direction: column; + align-items: center; + height: 100vh; + overflow: hidden; + + @media all and (min-width: 1024px) { + flex-direction: row; + } +`; + +const FormWrapper = styled.div` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + @media all and (min-width: 1024px) { + width: 50%; + } +`; + +const StyledForm = styled.form` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding-top: 20px; +`; + +const StyledInput = styled.input` + width: 280px; + background: var(--grey); + border: none; + border-radius: 30px; + padding: 20px; + height: 50px; + margin: 10px; + font-size: 1.1em; + box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3); + + @media all and (min-width: 744px) { + width: 400px; + } +`; + +const FormText = styled.p` + text-align: center; + padding: 0 20px; +`; + +const ErrorText = styled.p` + color: var(--lightgreen); + font-size: 0.9em; + margin-top: -10px; +`; + +//component +export const Registration = () => { + const [error, setError] = useState({}); + const navigate = useNavigate(); + const [formData, setFormData] = useState({ + name: "", + email: "", + password: "", + }); + + const apiEnv = import.meta.env.VITE_API_KEY; + + const handleChange = (e) => { + const { name, value } = e.target; + setFormData({ + ...formData, + [name]: value, + }); + setError({ + ...error, + [name]: "", + general: "", + }); + }; + + const validateEmail = (email) => { + const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return emailPattern.test(email); + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + + const newErrors = {}; + if (!formData.name) { + newErrors.name = "Username is required"; + } + + if (!formData.email) { + newErrors.email = "Email is required"; + } else if (!validateEmail(formData.email)) { + newErrors.email = "Invalid email format"; + } + + if (!formData.password) { + newErrors.password = "Password is required"; + } + + if (Object.keys(newErrors).length > 0) { + setError(newErrors); + return; + } + + try { + const response = await fetch(`${apiEnv}register`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(formData), + }); + if (response.ok) { + const data = await response.json(); + const accessToken = data.accessToken; + localStorage.setItem("accessToken", accessToken); + + navigate("/dashboard"); + } else { + const errorData = await response.json(); + setError(errorData.error); + } + } catch (error) { + console.error("Error registering:", error); + setError("Something went wrong"); + } + }; + + return ( + +
+ + + + {/* show error message */} + {error.name && {error.name}} + + + {/* show error message */} + {error.email && {error.email}} + + + {/* show error message */} + {error.password && {error.password}} + {error.general && {error.general}} + + + If you already have an account๐Ÿ‘‡ + + +

Login here

+ + +

Home

+ +
+ + ); +}; diff --git a/frontend/src/index.css b/frontend/src/index.css index 3e560a674..a8a436ac6 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,13 +1,44 @@ +/* basic css */ + +/* import fonts Abril Fatface and Open Sans from Google Fonts*/ +@import url("https://fonts.googleapis.com/css2?family=Abril+Fatface&family=Open+Sans:ital,wght@0,300..800;1,300..800&display=swap"); + +/* create variables for colours */ :root { + --grey: #e6ddcc; + --yellow: #eed391; + --lightgreen: #85a27a; + --darkgreen: #436957; + --black: #000; + --white: #ffffff; +} + +/* basics */ + +* { + padding: 0; margin: 0; - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", - "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", - sans-serif; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; + box-sizing: border-box; + font-family: Open Sans, sans-serif; + font-weight: 300; + color: var(--primary-blk); } -code { - font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", - monospace; -} \ No newline at end of file +body { + height: 100vh; +} + +h1 { + color: var(--darkgreen); + font-family: "Abril Fatface", serif; + font-weight: 400; + font-size: 1.75em; + align-self: center; +} + +h2 { + color: var(--black); + font-size: 1.25em; + padding-top: 15px; + align-self: center; +} diff --git a/frontend/src/reusables/Button.jsx b/frontend/src/reusables/Button.jsx new file mode 100644 index 000000000..daa91ff4b --- /dev/null +++ b/frontend/src/reusables/Button.jsx @@ -0,0 +1,33 @@ +/* eslint-disable react/prop-types */ +//import +import styled from "styled-components"; + +//styling +const StyledButton = styled.button` + width: 280px; + background: var(--darkgreen); + border: none; + border-radius: 30px; + padding: 10px 50px; + height: 50px; + margin: 20px; + color: var(--grey); + font-family: "Abril Fatface", serif; + font-weight: 400; + font-size: 1.25em; + box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3); + + &:hover { + cursor: pointer; + background: var(--lightgreen); + } + + @media all and (min-width: 744px) { + width: 400px; + } +`; + +//component +export const Button = ({ children, ...props }) => { + return {children}; +}; diff --git a/frontend/src/reusables/Header.jsx b/frontend/src/reusables/Header.jsx new file mode 100644 index 000000000..94ca3ea19 --- /dev/null +++ b/frontend/src/reusables/Header.jsx @@ -0,0 +1,57 @@ +//imports +import styled from "styled-components"; +import HeaderPicture from "../../public/header.jpg"; +import Globe from "../../public/globe.png"; + +//styles +const HeaderContainer = styled.section` + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-end; + background-image: url(${HeaderPicture}); + background-size: cover; + background-position: center; + width: 100%; + height: 300px; + + @media all and (min-width: 744px) { + height: 400px; + } + @media all and (min-width: 1024px) { + flex-direction: row; + width: 50%; + height: 100vh; + } +`; + +const HeaderGlobe = styled.img` + position: absolute; + left: -40px; + top: -40px; + width: 280px; + height: 280px; + + @media all and (min-width: 744px) { + left: -50px; + top: -50px; + width: 400px; + height: 400px; + } + @media all and (min-width: 1024px) { + left: -60px; + top: -60px; + width: 58%; + height: auto; + max-width: 590px; + max-height: 590px; + } +`; +//component +export const Header = () => { + return ( + + + + ); +}; diff --git a/frontend/src/reusables/Loading.jsx b/frontend/src/reusables/Loading.jsx new file mode 100644 index 000000000..758ad3f01 --- /dev/null +++ b/frontend/src/reusables/Loading.jsx @@ -0,0 +1,19 @@ +import styled from "styled-components"; + +export const Loading = styled.div` + border: 4px solid rgba(67, 105, 87, 0.1); + width: 36px; + height: 36px; + border-radius: 50%; + border-left-color: var(--lightgreen); + animation: spin 1s ease infinite; + + @keyframes spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } + } +`; diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 5a33944a9..e645d69a0 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -1,7 +1,15 @@ -import { defineConfig } from 'vite' -import react from '@vitejs/plugin-react' +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; // https://vitejs.dev/config/ export default defineConfig({ - plugins: [react()], -}) + plugins: [ + react({ + babel: { + plugins: ["styled-components"], + babelrc: false, + configFile: false, + }, + }), + ], +}); diff --git a/package.json b/package.json index d774b8cc3..d157509d7 100644 --- a/package.json +++ b/package.json @@ -3,5 +3,17 @@ "version": "1.0.0", "scripts": { "postinstall": "npm install --prefix backend" + }, + "devDependencies": { + "@babel/cli": "^7.24.5", + "@babel/core": "^7.24.5", + "@babel/preset-env": "^7.24.5", + "nodemon": "^3.1.0" + }, + "dependencies": { + "bcrypt": "^5.1.1", + "dotenv": "^16.4.5", + "express-list-endpoints": "^7.1.0", + "mongoose": "^8.4.0" } }