diff --git a/README.md b/README.md index dfa05e177..1c2a66b4e 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,16 @@ -# Project Auth API +# Project Auth - Olga & Elin -Replace this readme with your own information about your project. +This is a project to pratice authentication and authorization. The task was to create endpoints and UI for signing up, logging in/out and displaying some authenticated content for the user. The project utilises Express.js, MongoDB and React. -Start by briefly describing the assignment in a sentence or two. Keep it short and to the point. +Our version of the project returns the user a random inspirational coding-related quote from our database. ## 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? +As we're handing this project in really late, the final project is already finished and approved. As most of the needed codes were "already there", we thought this one would've been really straightforward - however, we ran into at least as many errors connecting the backend and frontend of this one as with the final project. Problems especially arose after deploying everything, but many of them were mostly careless mistakes and lack of knowledge. ## 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. +Please note that the deployed backend is quite slow and the initial attempt to sign up or login might take a while! + +Deployed site: https://projectauth-olgaelin.netlify.app +Backend: https://project-auth-pjm5.onrender.com/ diff --git a/backend/config/db.js b/backend/config/db.js new file mode 100644 index 000000000..8eb5c28a8 --- /dev/null +++ b/backend/config/db.js @@ -0,0 +1,16 @@ +import mongoose from "mongoose"; +import dotenv from "dotenv"; +dotenv.config(); +import asyncHandler from "express-async-handler"; + +export const connectDB = asyncHandler(async () => { + try { + const conn = await mongoose.connect( + process.env.MONGO_URL + ) + console.log(`Mongo DB connected: ${conn.connection.host}`) + } catch (error) { + console.log(error); + process.exit(1); + } +}) \ No newline at end of file diff --git a/backend/middleware/authenticateUser.js b/backend/middleware/authenticateUser.js new file mode 100644 index 000000000..d07e118fe --- /dev/null +++ b/backend/middleware/authenticateUser.js @@ -0,0 +1,22 @@ +import { UserModel } from "../models/userModel.js"; + +export const authenticateUser = async (req, res, next) => { + + // Retrieve the access token from the request header + const accessToken = req.header("Authorization"); + + try { + // Find a user in the database using the retrieved access token + const user = await UserModel.findOne({ accessToken: accessToken }); + if (user) { + // If a user is found, add the user object to the request object + req.user = user; // Add user to the request object + next(); + } else { + res.status(401).json({ success: false, response: "Please log in" }); + } + } catch (e) { + // Handle any errors that occur during the database query or user authentication + res.status(500).json({ success: false, response: e.message }); + } +}; \ No newline at end of file diff --git a/backend/models/quoteModel.js b/backend/models/quoteModel.js new file mode 100644 index 000000000..724a9d221 --- /dev/null +++ b/backend/models/quoteModel.js @@ -0,0 +1,11 @@ +import mongoose from "mongoose"; + +const { Schema } = mongoose; + +export const quoteSchema = new Schema({ + quote: { + type: String + } +}); + +export const QuoteModel = mongoose.model('quote', quoteSchema) \ No newline at end of file diff --git a/backend/models/userModel.js b/backend/models/userModel.js new file mode 100644 index 000000000..00e5fa099 --- /dev/null +++ b/backend/models/userModel.js @@ -0,0 +1,33 @@ +import mongoose from "mongoose"; +import crypto from "crypto"; + +const { Schema } = mongoose; + +export const userSchema = new Schema({ + username: { + type: String, + required: true, + minlength: 5, + unique: true + }, + email: { + type: String, + required: true, + unique: true + }, + password: { + type: String, + required: true, + minlength: 6 + }, + accessToken: { + type: String, + default: () => crypto.randomBytes(128).toString('hex') + } +}, + { + timestamps: true + } +) + +export const UserModel = mongoose.model('user', userSchema) \ No newline at end of file diff --git a/backend/package.json b/backend/package.json index 8de5c4ce0..2af8da0e1 100644 --- a/backend/package.json +++ b/backend/package.json @@ -12,9 +12,16 @@ "@babel/core": "^7.17.9", "@babel/node": "^7.16.8", "@babel/preset-env": "^7.16.11", + "bcrypt": "^5.1.1", + "bcrypt-nodejs": "^0.0.3", "cors": "^2.8.5", + "crypto": "^1.0.1", + "dotenv": "^16.3.2", "express": "^4.17.3", - "mongoose": "^8.0.0", + "express-async-handler": "^1.2.0", + "express-list-endpoints": "^6.0.0", + "jsonwebtoken": "^9.0.2", + "mongoose": "^8.0.4", "nodemon": "^3.0.1" } } diff --git a/backend/routes/quoteRoutes.js b/backend/routes/quoteRoutes.js new file mode 100644 index 000000000..4e0f86e10 --- /dev/null +++ b/backend/routes/quoteRoutes.js @@ -0,0 +1,54 @@ +import express from "express" +import { QuoteModel } from "../models/quoteModel.js"; +import { authenticateUser } from "../middleware/authenticateUser.js"; +import asyncHandler from "express-async-handler" + +const router = express.Router(); + +router.get( + "/getQuote", + authenticateUser, + asyncHandler(async (req, res) => { + try { + const quotes = await QuoteModel.find(); + if (quotes.length > 0) { + const randomIndex = Math.floor(Math.random() * quotes.length); + const randomQuote = quotes[randomIndex]; + res.json(randomQuote); + } else { + res.status(404).json({ error: "No quotes found" }); + } + } catch (error) { + res.status(500).json({ success: false, response: error.message }); + } + }) +); + + +// Get all the quotes in the database +router.get( + "/allQuotes", async (req, res) => { + try { + const quotes = await QuoteModel.find(); + res.json(quotes); + } catch (err) { + res.status(400).json({ error: err.message }); + } + } +); + +router.post( + "/addQuote", async (req, res) => { + try { + const { quote } = req.body; + const newQuote = new QuoteModel({ quote }) + + await newQuote.save() + res.json(newQuote) + } catch (err) { + res.status(404).json({ success: false, message: "Could not add quote" }) + } + } +) + +export default router \ No newline at end of file diff --git a/backend/routes/userRoutes.js b/backend/routes/userRoutes.js new file mode 100644 index 000000000..48606b44b --- /dev/null +++ b/backend/routes/userRoutes.js @@ -0,0 +1,90 @@ +import express from "express"; +import { UserModel } from "../models/userModel.js" +import bcrypt, { genSaltSync } from "bcrypt"; +import asyncHandler from "express-async-handler"; +import dotenv from "dotenv" +dotenv.config() + +const router = express.Router() + +router.post("/register", + asyncHandler(async (req, res) => { + const { email, username, password } = req.body; + + try { + if (!email || !username || !password) { + res.status(400) + throw new Error("Please add all fields") + } + + const existingUser = await UserModel.findOne({ + $or: [{ email }, { username }] + }) + if (existingUser) { + res.status(400) + throw new Error( + `User with ${existingUser.username === username ? "email" : "username"} + already exists.` + ) + } + + const salt = genSaltSync(10); + const hashedPassword = bcrypt.hashSync(password, salt) + + const newUser = new UserModel({ + email, + username, + password: hashedPassword + }) + + await newUser.save(); + + res.status(201).json({ + success: true, + response: { + email: newUser.email, + username: newUser.username, + id: newUser._id, + accessToken: newUser.accessToken + } + }) + } catch (e) { + res.status(500).json({ success: false, response: e.message }) + } + }) +) + +router.post("/login", + asyncHandler(async (req, res) => { + const { username, password } = req.body; + + try { + const user = await UserModel.findOne({ username }) + if (!user) { + return res + .status(401) + .json({ success: false, response: "User not found" }) + } + + const isMatch = await bcrypt.compare(password, user.password); + if (!isMatch) { + return res + .status(401) + .json({ success: false, response: "Incorrect password" }) + } + + res.status(200).json({ + success: true, + response: { + user: user.username, + id: user._id, + accessToken: user.accessToken + } + }) + } catch (e) { + res.status(500).json({ success: false, response: e.message }) + } + }) +) + +export default router \ No newline at end of file diff --git a/backend/server.js b/backend/server.js index 2d7ae8aa1..c07327513 100644 --- a/backend/server.js +++ b/backend/server.js @@ -1,27 +1,35 @@ import express from "express"; import cors from "cors"; -import mongoose from "mongoose"; - -const mongoUrl = process.env.MONGO_URL || "mongodb://localhost/project-mongo"; -mongoose.connect(mongoUrl, { useNewUrlParser: true, useUnifiedTopology: true }); -mongoose.Promise = Promise; +import dotenv from "dotenv"; +dotenv.config(); +import listEndpoints from "express-list-endpoints"; +import userRoutes from "./routes/userRoutes.js" +import quoteRoutes from "./routes/quoteRoutes.js" +import { connectDB } from "./config/db.js" // 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 -const port = process.env.PORT || 8080; +const port = process.env.PORT || 3000; const app = express(); // Add middlewares to enable cors and json body parsing app.use(cors()); app.use(express.json()); +app.use(express.urlencoded({ extended: false })); + +app.use(userRoutes); +app.use(quoteRoutes); -// Start defining your routes here +// Function to connect to MongoDB +connectDB(); + +// List all endpoints app.get("/", (req, res) => { - res.send("Hello Technigo!"); + res.send(listEndpoints(app)); }); // Start the server app.listen(port, () => { console.log(`Server running on http://localhost:${port}`); -}); +}); \ No newline at end of file diff --git a/frontend/README.md b/frontend/README.md index f768e33fc..8b1378917 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,8 +1 @@ -# React + Vite -This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. - -Currently, two official plugins are available: - -- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh -- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh diff --git a/frontend/index.html b/frontend/index.html index 0c589eccd..21b0e0fdc 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,7 +4,7 @@ - Vite + React + Something about coding...
diff --git a/frontend/package.json b/frontend/package.json index e9c95b79f..c7c3cc96a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,8 +10,12 @@ "preview": "vite preview" }, "dependencies": { + "crypto": "^1.0.1", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "react-router": "^6.21.2", + "react-router-dom": "^6.21.2", + "zustand": "^4.5.0" }, "devDependencies": { "@types/react": "^18.2.15", diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 1091d4310..bc0424bb8 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,3 +1,19 @@ +import { BrowserRouter, Routes } from "react-router-dom"; +import "./index.css"; +import { routes } from "./routes/routes.jsx" + export const App = () => { - return
Find me in src/app.jsx!
; + return ( + <> + +
+ + {routes} + +
+
+ + ); }; + +export default App \ No newline at end of file diff --git a/frontend/src/index.css b/frontend/src/index.css index 3e560a674..6d2eb8b93 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -10,4 +10,107 @@ code { font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace; +} + +html { + background-color:rgb(178, 192, 183); +} + +.mainContainer { + display: grid; + grid-template-columns: repeat(4, 1fr); +} + +.homePage, .logIn, .signUp, .loggedIn { + grid-column: 2 / 4; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + /* height: 100vh; */ +} + +.naviButton { + margin: 20px; +} + +h1 { + color:rgb(70, 116, 117); + text-align: center; + font-size: 50px; + text-shadow: 2px 2px 4px #35353580; +} + +button { + background-color: cadetblue; + border: solid; + border-color: azure; + border-radius: 23px; + margin: 40px; + padding: 10px; + font-size: 15px; + width: 140px; +} + +button:hover { + color: cadetblue; + background-color: azure; + cursor: pointer; +} + +.buttonContainer { + display: flex; + justify-content: center; +} + +.logIn { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; +} + +h3 { +color: rgb(70, 116, 117); +text-align: center; +font-size: 30px; +} + +label { + color:rgb(39, 64, 65); + font-size: 20px; + padding: 10px; +} + +input { + padding: 2px; + height: 2em; + border-radius: 10px; + +} + +form { + display: flex; + flex-direction: column; + align-items: center; +} + +@media screen and (max-width: 667px) { + .mainContainer { + grid-template-columns: 1fr; + } + + .homePage, .logIn, .signUp, .loggedIn { + grid-column: 1 / 2; + } +} + +@media screen and (min-width: 668px) and (max-width: 1024px) { + .mainContainer { + grid-template-columns: repeat(2, 1fr); + } + + .homePage, .logIn, .signUp, .loggedIn { + grid-column: 1 / 3; + } } \ No newline at end of file diff --git a/frontend/src/pages/Home.jsx b/frontend/src/pages/Home.jsx new file mode 100644 index 000000000..cb9f5bead --- /dev/null +++ b/frontend/src/pages/Home.jsx @@ -0,0 +1,18 @@ +import "../index.css" +import { Link } from "react-router-dom" + +export const Home = () => { + return ( + <> +
+
+

Something about coding...

+ + +
+
+ + ) +} + +export default Home \ No newline at end of file diff --git a/frontend/src/pages/LogIn.jsx b/frontend/src/pages/LogIn.jsx new file mode 100644 index 000000000..d3858c098 --- /dev/null +++ b/frontend/src/pages/LogIn.jsx @@ -0,0 +1,43 @@ +import "../index.css" +import { Link } from "react-router-dom" +import { userStore } from '../stores/userStore.jsx' +import { useNavigate } from 'react-router-dom'; + +export const LogIn = () => { + const { handleLogin } = userStore(); + const navigate = useNavigate(); + + const handleLoginFormSubmit = async (e) => { + e.preventDefault(); + const username = e.target.username.value; + const password = e.target.password.value; + + try { + await handleLogin(username, password); + navigate('/loggedIn'); + } catch (error) { + console.error('Login error:', error); + } + + }; + + return ( + <> +
+
+

Log In

+
+ + + + + +
+ +
+
+ + ) +} + +export default LogIn \ No newline at end of file diff --git a/frontend/src/pages/LoggedIn.jsx b/frontend/src/pages/LoggedIn.jsx new file mode 100644 index 000000000..2048031e4 --- /dev/null +++ b/frontend/src/pages/LoggedIn.jsx @@ -0,0 +1,44 @@ +import "../index.css" +import { userStore } from "../stores/userStore.jsx"; +import { quoteStore } from "../stores/quoteStore.jsx"; +import { useStore } from "zustand" +import { useNavigate } from "react-router-dom" +import { useEffect } from "react"; + +export const LoggedIn = () => { + const storeHandleLogout = userStore((state) => state.handleLogout); + const navigate = useNavigate(); + + const { quotes, fetchQuotes } = useStore(quoteStore); + + useEffect(() => { + console.log("Quotes array:", quotes); + // Fetch a quote when the component mounts + fetchQuotes(); + }, []); + + // Handle the click event of the logout button + const onLogoutClick = () => { + storeHandleLogout(); + alert("Logout successful"); + // Navigate to the homepage if logout was successful + navigate("/"); + }; + + return ( + <> +
+
+

Welcome!

+

Here's a little thought for today:

+

{quotes.length > 0 ? `${quotes[0].quote}` : "Couldn't find a quote."}

+
+ +
+
+
+ + ) +} + +export default LoggedIn \ No newline at end of file diff --git a/frontend/src/pages/SignUp.jsx b/frontend/src/pages/SignUp.jsx new file mode 100644 index 000000000..8a4b7fe9d --- /dev/null +++ b/frontend/src/pages/SignUp.jsx @@ -0,0 +1,56 @@ +import "../index.css" +import { Link } from "react-router-dom" +import { useNavigate } from "react-router-dom" +import { userStore } from "../stores/userStore" + +export const SignUp = () => { + const { handleSignup } = userStore(); + const navigate = useNavigate(); + + const handleSignUpSubmit = async (e) => { + e.preventDefault(); + const email = e.target.email.value; + const username = e.target.username.value; + const password = e.target.password.value; + + try { + await handleSignup(email, username, password); + navigate('/'); + } catch (error) { + console.error('Signup error:', error); + } + } + + return ( + <> +
+
+

Sign Up

+
+ + + + + + + +
+
+ +
+
+
+ + ) +} + +export default SignUp \ No newline at end of file diff --git a/frontend/src/routes/routes.jsx b/frontend/src/routes/routes.jsx new file mode 100644 index 000000000..5616ad880 --- /dev/null +++ b/frontend/src/routes/routes.jsx @@ -0,0 +1,16 @@ +import { Route } from "react-router-dom" +import { Home } from "../pages/Home.jsx" +import { SignUp } from "../pages/SignUp.jsx" +import { LogIn } from "../pages/LogIn.jsx" +import { LoggedIn } from "../pages/LoggedIn.jsx" + +export const routes = ( + <> + } /> + } /> + } /> + } /> + +) + +export default routes \ No newline at end of file diff --git a/frontend/src/stores/quoteStore.jsx b/frontend/src/stores/quoteStore.jsx new file mode 100644 index 000000000..6a47d7293 --- /dev/null +++ b/frontend/src/stores/quoteStore.jsx @@ -0,0 +1,31 @@ +import { create } from "zustand"; + +const apiEnv = import.meta.env.VITE_BACKEND_API.replace(/\/$/, '');; +console.log(apiEnv); + +export const quoteStore = create((set) => ({ + quotes: [], + addQuote: (newQuote) => set((state) => ({ quotes: [...state.quotes, newQuote] })), + setQuotes: (quotes) => set({ quotes }), + + // Fetch a quote for the user to see + fetchQuotes: async () => { + try { + const response = await fetch(`${apiEnv}/getQuote`, { + method: "GET", + headers: { + Authorization: localStorage.getItem("accessToken"), + }, + }); + if (response.ok) { + const data = await response.json(); + set({ quotes: [data] }); + return data; + } else { + console.error("Failed to find a quote"); + } + } catch (error) { + console.error(error); + } + } +})) \ No newline at end of file diff --git a/frontend/src/stores/userStore.jsx b/frontend/src/stores/userStore.jsx new file mode 100644 index 000000000..8a1e0d430 --- /dev/null +++ b/frontend/src/stores/userStore.jsx @@ -0,0 +1,98 @@ +import { create } from "zustand"; + +const apiEnv = import.meta.env.VITE_BACKEND_API.replace(/\/$/, '');; + +export const userStore = create((set, get) => ({ + email: "", + setEmail: (email) => set({ email }), + username: "", + setUsername: (username) => set({ username }), + password: "", + setPassword: (password) => set({ password }), + accessToken: null, // Add this if you plan to store the access token + setAccessToken: (token) => set({ accessToken: token }), + isLoggedIn: false, // Added to track if the user is logged in + setIsLoggedIn: (isLoggedIn) => set({ isLoggedIn }), + + // FUNCTION TO REGISTER USERS + handleSignup: async (email, username, password) => { + if (!email || !username || !password) { + alert("Please enter username, email and password"); + return; + } + + try { + const response = await fetch(`${apiEnv}/register`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email, username, password }), + }); + + const data = await response.json(); + if (data.success) { + set({ username }); + // Redirect or update UI + alert("Signup successful!"); + console.log("Signing up with:", username); + } else { + // Display error message from server + alert(data.response || "Signup failed"); + } + } catch (error) { + console.error("Signup error:", error); + alert("An error occurred during signup"); + } + }, + + // LOGIN + handleLogin: async (username, password) => { + // Check if both username and password are provided and display an alert if not. + if (!username || !password) { + alert("Please enter both username and password"); + return; + } + + try { + // Send a POST request to the login endpoint with user data. + const response = await fetch(`${apiEnv}/login`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ username, password }), + }); + + // Parse the response data as JSON. + const data = await response.json(); + if (data.success) { + // Update the state with username, accessToken, and set isLoggedIn to true. + set({ + username, + accessToken: data.response.accessToken, + isLoggedIn: true, + }); + // Store the accessToken in the browser's localStorage. + localStorage.setItem("accessToken", data.response.accessToken); + // Display a success alert. + alert("Login successful!"); + console.log("Logging in with:", username, password); + } else { + // Display an error message from the server or a generic message. + alert("Incorrect username or password") + throw new Error(data.response || "Login failed"); + } + } catch (error) { + // Handle and log any login errors. + throw error; + } + }, + + handleLogout: () => { + // Clear user information and set isLoggedIn to false + set({ username: "", accessToken: null, isLoggedIn: false }); + localStorage.removeItem("accessToken"); + // Additional logout logic if needed + }, +})); \ No newline at end of file diff --git a/netlify.toml b/netlify.toml index 95443a1f3..ad1dc5d88 100644 --- a/netlify.toml +++ b/netlify.toml @@ -1,6 +1,12 @@ # This file tells netlify where the code for this project is and # how it should build the JavaScript assets to deploy from. -[build] - base = "frontend/" - publish = "build/" + +[ build ] + base = "frontend" + publish = "dist" command = "npm run build" + +[[redirects]] + from = "/*" + to = "/index.html" + status = 200 \ No newline at end of file diff --git a/package.json b/package.json index d774b8cc3..cfea95321 100644 --- a/package.json +++ b/package.json @@ -3,5 +3,13 @@ "version": "1.0.0", "scripts": { "postinstall": "npm install --prefix backend" + }, + "dependencies": { + "dotenv": "^16.3.2", + "express-list-endpoints": "^6.0.0", + "mongo": "^0.1.0", + "mongodb": "^6.3.0", + "mongoose": "^8.1.0", + "zustand": "^4.5.0" } }