diff --git a/Procfile b/Procfile deleted file mode 100644 index c154cc553..000000000 --- a/Procfile +++ /dev/null @@ -1 +0,0 @@ -web: npm start --prefix backend diff --git a/README.md b/README.md index dfa05e177..d3ff50537 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,27 @@ # 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 project is a user authentication system built with React, Express.js, MongoDB, bcrypt, and crypto. The application provides secure user registration, login, and access to protected routes. ## 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? +.React: For building the frontend. +.Express.js: For handling backend routing and API logic. +.MongoDB: For storing user data. +.bcrypt: For hashing passwords before storing them in the database. +.crypto: For generating secure access tokens. + +If more time were available, the next steps would include implementing user roles, adding more comprehensive validation and error handling. + +## API Endpoints + +GET +./: Basic root route for testing, returns a welcome message. +./secrets: Access a protected route, requires a valid access token. + +POST +./users: Register a new user. +./sessions: Log in an existing user. ## 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. +You can view the live project [here](https://doggyadopt.netlify.app/) diff --git a/backend/.gitignore b/backend/.gitignore index 25c8fdbab..8f5e467c8 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -1,2 +1,3 @@ node_modules -package-lock.json \ No newline at end of file +package-lock.json +.env \ No newline at end of file diff --git a/backend/package.json b/backend/package.json index 8de5c4ce0..29b1eb4ef 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,5 +1,5 @@ { - "name": "project-auth-backend", + "name": "project-auth-backendproject-auth", "version": "1.0.0", "description": "Starter project to get up and running with express quickly", "scripts": { @@ -9,12 +9,17 @@ "author": "", "license": "ISC", "dependencies": { - "@babel/core": "^7.17.9", - "@babel/node": "^7.16.8", - "@babel/preset-env": "^7.16.11", + "@babel/core": "^7.24.5", + "@babel/node": "^7.23.9", + "@babel/preset-env": "^7.24.5", + "bcrypt-nodejs": "^0.0.3", + "bcryptjs": "^2.4.3", "cors": "^2.8.5", - "express": "^4.17.3", - "mongoose": "^8.0.0", - "nodemon": "^3.0.1" + "dotenv": "^16.4.5", + "express": "^4.19.2", + "express-list-endpoints": "^7.1.0", + "mongodb": "^6.6.2", + "mongoose": "^8.3.5", + "nodemon": "^3.1.0" } } diff --git a/backend/server.js b/backend/server.js index 2d7ae8aa1..594a96c4f 100644 --- a/backend/server.js +++ b/backend/server.js @@ -1,27 +1,114 @@ -import express from "express"; -import cors from "cors"; -import mongoose from "mongoose"; +import express from "express" +import cors from "cors" +import mongoose from "mongoose" +import dotenv from "dotenv" +import crypto from "crypto" +import bcrypt from "bcryptjs" -const mongoUrl = process.env.MONGO_URL || "mongodb://localhost/project-mongo"; -mongoose.connect(mongoUrl, { useNewUrlParser: true, useUnifiedTopology: true }); -mongoose.Promise = Promise; +dotenv.config() -// 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 app = express(); +const mongoUrl = process.env.MONGO_URL || "mongodb://localhost/Authorization" +mongoose.connect(mongoUrl) +mongoose.Promise = Promise +const SALT_ROUNDS = 12 +const passwordValidator = (password) => { + const regex = /^(?=.*[A-Z])(?=.*d)[A-Za-zd]{8,}$/ + return regex.test(password) +} +const userSchema = new mongoose.Schema({ + name: { + type: String, + unique: true, + minLength: 5, + }, + email: { + type: String, + unique: true, + required: [true, "Email is required"], + match: [/\S+@\S+\.\S+/, "Email is invalid"], + }, + password: { + type: String, + required: [true, "Password is required"], + validate: { + validator: passwordValidator, + message: + "Password must contain at least one uppercase letter, one number, and be at least 8 characters long", + }, + }, + accessToken: { + type: String, + default: () => crypto.randomBytes(128).toString("hex"), + }, +}) +userSchema.pre("save", async function (next) { + if (this.isModified("password") || this.isNew) { + this.password = await bcrypt.hash(this.password, SALT_ROUNDS) + } + next() +}) +const User = mongoose.model("User", userSchema) +const authenticateUser = async (req, res, next) => { + const user = await User.findOne({ + accessToken: req.header("Authorization"), + }).exec() + if (!accessToken) { + return res + .status(401) + .json({ error: "Unauthorized. Access token missing." }) + } + if (user) { + req.user = user + next() + } else { + res.status(401).json({ loggedOut: true }) + } +} -// Add middlewares to enable cors and json body parsing -app.use(cors()); -app.use(express.json()); +const port = process.env.PORT || 8080 +const app = express() + +app.use(cors()) +app.use(express.json()) + +// Enable CORS middleware +app.use( + cors({ + origin: true, + methods: ["GET", "POST"], + allowedHeaders: ["Content-Type", "Authorization"], + }) +) // Start defining your routes here -app.get("/", (req, res) => { - res.send("Hello Technigo!"); -}); +app.post("/users", async (req, res) => { + try { + const { name, email, password } = req.body + const existingUser = await User.findOne({ name }).exec() + if (existingUser) { + return res.status(409).json({ message: "Username already taken" }) + } + const user = new User({ name, email, password: bcrypt.hashSync(password) }) + user.save() + res.status(201).json({ id: user._id, accessToken: user.accessToken }) + } catch (err) { + res.status(400).json({ message: "Could not save user", errors: err.errors }) + } +}) +app.get("/secrets", authenticateUser) +app.get("/secrets", (req, res) => { + res.send(" This is the secret page to show after logging or registration.") +}) +app.post("/sessions", async (req, res) => { + const user = await User.findOne({ email: req.body.email }).exec() + if (user && bcrypt.compareSync(req.body.password, user.password)) { + res.json({ userId: user._id, accessToken: user.accessToken }) + } else { + res.json({ notFound: true }) + } +}) // Start the server app.listen(port, () => { - console.log(`Server running on http://localhost:${port}`); -}); + console.log(`Server running on http://localhost:${port}`) +}) diff --git a/frontend/index.html b/frontend/index.html index 0c589eccd..d123758e3 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,10 +1,10 @@ - + - + - Vite + React + DoggyAdopt
diff --git a/frontend/package.json b/frontend/package.json index e9c95b79f..866abc046 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,8 +10,10 @@ "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", diff --git a/frontend/public/dog.png b/frontend/public/dog.png new file mode 100644 index 000000000..03f8f2ec1 Binary files /dev/null and b/frontend/public/dog.png differ diff --git a/frontend/public/dog2.jpg b/frontend/public/dog2.jpg new file mode 100644 index 000000000..fa2174beb Binary files /dev/null and b/frontend/public/dog2.jpg differ diff --git a/frontend/public/dog3.jpg b/frontend/public/dog3.jpg new file mode 100644 index 000000000..796198c4c Binary files /dev/null and b/frontend/public/dog3.jpg differ diff --git a/frontend/public/dog4.jpg b/frontend/public/dog4.jpg new file mode 100644 index 000000000..1df3021f4 Binary files /dev/null and b/frontend/public/dog4.jpg differ diff --git a/frontend/public/logo.svg b/frontend/public/logo.svg new file mode 100644 index 000000000..a60d66e84 --- /dev/null +++ b/frontend/public/logo.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/public/paw-print.svg b/frontend/public/paw-print.svg new file mode 100644 index 000000000..0b5910c1a --- /dev/null +++ b/frontend/public/paw-print.svg @@ -0,0 +1,150 @@ + + + + + + + + + + + image/svg+xml + + + + + Openclipart + + + Paw Print + 2011-06-05T04:06:25 + A simple paw print + https://openclipart.org/detail/142447/paw-print-by-kattekrab + + + kattekrab + + + + + animal + animal foot print + black + paw print + simple + + + + + + + + + + + diff --git a/frontend/public/rectangles.svg b/frontend/public/rectangles.svg new file mode 100644 index 000000000..362b0e9f0 --- /dev/null +++ b/frontend/public/rectangles.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/rottie.jpg b/frontend/public/rottie.jpg new file mode 100644 index 000000000..0633bc915 Binary files /dev/null and b/frontend/public/rottie.jpg differ diff --git a/frontend/public/svg-edited.svg b/frontend/public/svg-edited.svg new file mode 100644 index 000000000..27d0a0ff7 --- /dev/null +++ b/frontend/public/svg-edited.svg @@ -0,0 +1,150 @@ + + + + + + + + + + + image/svg+xml + + + + + Openclipart + + + Paw Print + 2011-06-05T04:06:25 + A simple paw print + https://openclipart.org/detail/142447/paw-print-by-kattekrab + + + kattekrab + + + + + animal + animal foot print + black + paw print + simple + + + + + + + + + + + diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 1091d4310..40ebdd187 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,3 +1,19 @@ +import { RegistrationForm } from "./RegistrationForm" +import { BrowserRouter, Routes, Route } from "react-router-dom" +import { Homepage } from "./Homepage" +import { Dashboard } from "./Dashboard" +import { Login } from "./Login" + export const App = () => { - return
Find me in src/app.jsx!
; -}; + return ( + + + } /> + } /> + } /> + } /> + } /> + + + ) +} diff --git a/frontend/src/Dashboard.jsx b/frontend/src/Dashboard.jsx new file mode 100644 index 000000000..232ed4bd8 --- /dev/null +++ b/frontend/src/Dashboard.jsx @@ -0,0 +1,108 @@ +import styled from "styled-components" +import { SwitchLabel } from "./SwitchLabel" +import { useNavigate } from "react-router-dom" + +const StyledSection = styled.div` + background-color: #ffd5c5; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 20px; + height: 100vh; + padding: 128px 16px; + gap: 16px; + div { + display: flex; + flex-direction: column; + } + img { + width: 100px; + height: auto; + border-radius: 20px; + } + ul { + display: flex; + gap: 10px; + list-style: none; + align-items: center; + flex-wrap: wrap; + } + li { + text-align: center; + padding: 5px; + } + + section { + display: flex; + align-items: center; + flex-direction: column; + } +` +const SwitchLabelWrapper = styled.div` + cursor: pointer; +` + +export const Dashboard = () => { + const navigate = useNavigate() + // Dummy data for adopted dogs + const adoptedDogs = [ + { id: 1, name: "Buddy", img: "/dog4.jpg" }, + { id: 2, name: "Max", img: "/dog3.jpg" }, + { id: 3, name: "Bella", img: "/dog2.jpg" }, + { id: 4, name: "Roy", img: "/rottie.jpg" }, + ] + const handleLogOut = async () => { + try { + const accessToken = localStorage.getItem("accessToken") + if (accessToken) { + localStorage.removeItem("accessToken") + + navigate("/login") + } else { + console.error("No access token found") + } + } catch (error) { + console.error("Error logging out:", error) + } + } + // const handleLogOut = async () => { + + // try { + // const response = await fetch(`${process.env.REACT_APP_API_KEY}/logout`, { + // method: "DELETE", + // headers: { + // Authorization: `Bearer ${localStorage.getItem("accessToken")}`, + // }, + // }) + + // if (response.ok) { + // localStorage.removeItem("accessToken") + // navigate("/login") + // } else { + // console.error("Logout failed:", response.statusText) + // } + // } catch (error) { + // console.error("Error logging out:", error) + // } + + return ( + +

Your Dogs 🐶

+ +
+

Rest of content coming soon...

+
+ + + +
+ ) +} diff --git a/frontend/src/Homepage.jsx b/frontend/src/Homepage.jsx new file mode 100644 index 000000000..78036b1ab --- /dev/null +++ b/frontend/src/Homepage.jsx @@ -0,0 +1,39 @@ +import styled from "styled-components" +import { SwitchLabel } from "./SwitchLabel" +import { useNavigate } from "react-router-dom" +const StyledSection = styled.div` + background-color: #ffd5c5; + display: flex; + flex-direction: column; + align-items: center; + padding: 20px; + height: 100vh; + padding: 128px 16px; + gap: 16px; +` +const SwitchLabelWrapper = styled.div` + cursor: pointer; +` +export const Homepage = () => { + const navigate = useNavigate() + const handleSwitchLabelClick = () => { + // Redirect to the login page when switch label is clicked + navigate("/login") + } + + return ( + +
+ Background +
+
+ Logo +
+ +

Find your perfect companion

+ + + +
+ ) +} diff --git a/frontend/src/Login.jsx b/frontend/src/Login.jsx new file mode 100644 index 000000000..2de985c0b --- /dev/null +++ b/frontend/src/Login.jsx @@ -0,0 +1,145 @@ +import { useState } from "react" +import styled from "styled-components" +import { Link, useNavigate } from "react-router-dom" +const API_KEY = "https://project-auth-2qfo.onrender.com" +import { SwitchLabel } from "./SwitchLabel" + +const RegistrationContainer = styled.div` + background-color: #ffd5c5; + display: flex; + flex-direction: column; + align-items: center; + padding: 20px; + height: 100vh; + padding: 128px 16px; +` + +const RegistrationFormStyled = styled.form` + background-color: #ffd5c5; + padding: 20px; + border-radius: 20px; + display: flex; + flex-direction: column; + max-width: 300px; + margin-bottom: 20px; + align-items: center; + + label { + font-weight: bold; + font-size: 25px; + margin-bottom: 5px; + } + + input { + font-size: 15px; + border-radius: 70px; + padding: 10px; + margin-bottom: 15px; + width: 100%; + text-align: center; + outline: none; + border: none; + } + + button { + background-color: white; + border: none; + border-radius: 70px; + font-size: 15px; + padding: 10px; + cursor: pointer; + transition: background-color 0.3s ease; + + &:hover { + background-color: #e0e0e0; + } + } +` + +export const Login = () => { + const navigate = useNavigate() + + const [formData, setFormData] = useState({ + email: "", + password: "", + }) + + const handleChange = (e) => { + const { name, value } = e.target + setFormData({ + ...formData, + [name]: value, + }) + } + + const handleSubmit = async (e) => { + e.preventDefault() + try { + const response = await fetch(`${API_KEY}/sessions`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${localStorage.getItem("accessToken")}`, + }, + body: JSON.stringify(formData), + }) + if (response.ok) { + const data = await response.json() + const accessToken = data.accessToken + localStorage.setItem("accessToken", accessToken) + + navigate("/secrets") + } else { + console.error("Login failed:", response.statusText) + } + } catch (error) { + console.error("Error logging in:", error) + } + } + const SwitchLabelWrapper = styled.div` + cursor: pointer; + display: flex; + flex-direction: column; + align-items: center; + gap: 15px; + ` + + return ( + +

UserLogin

+

Welcome Back!

+ + + + + + + + + + + + + Not registered yet? Sign Up + +
+ ) +} diff --git a/frontend/src/RegistrationForm.jsx b/frontend/src/RegistrationForm.jsx new file mode 100644 index 000000000..c6646716d --- /dev/null +++ b/frontend/src/RegistrationForm.jsx @@ -0,0 +1,152 @@ +import { useState } from "react" +import styled from "styled-components" +import { useNavigate } from "react-router-dom" +const API_KEY = "https://project-auth-2qfo.onrender.com" +import { SwitchLabel } from "./SwitchLabel" + +const RegistrationContainer = styled.div` + background-color: #ffd5c5; + display: flex; + flex-direction: column; + align-items: center; + padding: 20px; + height: 100vh; + padding: 128px 16px; +` + +const RegistrationFormStyled = styled.form` + background-color: #ffd5c5; + padding: 20px; + border-radius: 20px; + display: flex; + flex-direction: column; + max-width: 300px; + margin-bottom: 20px; + align-items: center; + + label { + font-weight: bold; + font-size: 25px; + margin-bottom: 5px; + } + + input { + font-size: 15px; + border-radius: 70px; + padding: 10px; + margin-bottom: 15px; + width: 100%; + text-align: center; + outline: none; + border: none; + } + + button { + background-color: white; + border: none; + border-radius: 70px; + font-size: 15px; + padding: 10px; + cursor: pointer; + transition: background-color 0.3s ease; + + &:hover { + background-color: #e0e0e0; + } + } +` + +export const RegistrationForm = () => { + const [error, setError] = useState(null) + const navigate = useNavigate() + + const [formData, setFormData] = useState({ + name: "", + email: "", + password: "", + }) + + const handleChange = (e) => { + const { name, value } = e.target + setFormData({ + ...formData, + [name]: value, + }) + } + + const handleSubmit = async (e) => { + e.preventDefault() + try { + const response = await fetch(`${API_KEY}/users`, { + 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("/secrets") + } else { + const errorData = await response.json() + setError(errorData.error) + } + } catch (error) { + console.error("Error registering:", error) + setError("Something went wrong") + } + } + const SwitchLabelWrapper = styled.div` + cursor: pointer; + ` + + return ( + +

UserRegistration

+ + + + + + + + + + {error &&

Something went wrong

} + + + +
+
+ ) +} diff --git a/frontend/src/SwitchLabel.jsx b/frontend/src/SwitchLabel.jsx new file mode 100644 index 000000000..6f6d5b11c --- /dev/null +++ b/frontend/src/SwitchLabel.jsx @@ -0,0 +1,114 @@ +import styled from "styled-components" +import { useLocation } from "react-router-dom" +import { useState } from "react" + +const StyledSwitchLabel = styled.label` + position: relative; + display: inline-block; + width: 60px; + height: 34px; + + /* Hide default HTML checkbox */ + input { + opacity: 0; + width: 0; + height: 0; + } + + /* The slider */ + .slider { + border: none; + border-radius: 70px; + width: 100%; + font-size: 15px; + padding: 10px; + cursor: pointer; + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: #ffffff82; + -webkit-transition: 0.4s; + transition: 0.4s; + } + + .slider:before { + position: absolute; + content: ""; + height: 26px; + width: 26px; + left: 4px; + bottom: 4px; + background-image: url("/svg-edited.svg"); + background-color: black; + background-size: 15px; + background-repeat: no-repeat; + background-position: center; + -webkit-transition: 0.4s; + transition: 0.4s; + } + + input:checked + .slider { + background-color: white; + } + + input:focus + .slider { + box-shadow: 0 0 1px white; + } + + input:checked + .slider:before { + -webkit-transform: translateX(48px); + -ms-transform: translateX(48px); + transform: translateX(48px); + } + + /* Rounded sliders */ + .slider.round { + border-radius: 34px; + text-align: right; + font-size: 9px; + } + + .slider.round:before { + border-radius: 50%; + } +` + +export const SwitchLabel = () => { + const [isChecked, setIsChecked] = useState(false) + const location = useLocation() + + let labelText = "" + switch (location.pathname) { + case "/": + labelText = "Start" + break + case "/registration": + labelText = "Register" + break + case "/login": + labelText = "Login" + break + case "/secrets": + labelText = "LogOut" + break + default: + labelText = "" + } + const handleCheckboxChange = () => { + setIsChecked(!isChecked) + } + + return ( + + + {isChecked ? "" : labelText} + + ) +} diff --git a/frontend/src/index.css b/frontend/src/index.css index 3e560a674..50ed14179 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,13 +1,24 @@ +@import url("https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap"); + :root { margin: 0; - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", - "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", - sans-serif; + font-family: "Inter", sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; + font-optical-sizing: auto; + font-style: normal; + font-variation-settings: "slnt" 0; + font-weight: normal; + padding: 0; +} +body { + margin: 0; + padding: 0; } code { - font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", - monospace; -} \ No newline at end of file + font-family: "Inter", sans-serif; + font-optical-sizing: auto; + font-style: normal; + font-variation-settings: "slnt" 0; +} diff --git a/netlify.toml b/netlify.toml deleted file mode 100644 index 95443a1f3..000000000 --- a/netlify.toml +++ /dev/null @@ -1,6 +0,0 @@ -# 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/" - command = "npm run build" diff --git a/package.json b/package.json index d774b8cc3..2af3113e2 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,5 @@ { - "name": "project-auth-parent", - "version": "1.0.0", - "scripts": { - "postinstall": "npm install --prefix backend" + "dependencies": { + "react-router-dom": "^6.23.1" } } diff --git a/pull_request_template.md b/pull_request_template.md deleted file mode 100644 index d92c89b51..000000000 --- a/pull_request_template.md +++ /dev/null @@ -1,7 +0,0 @@ -## Netlify link -Add your Netlify link here. -PS. Don't forget to add it in your readme as well. - -## Collaborators -Add your collaborators here. Write their GitHub usernames in square brackets. If there's more than one, separate them with a comma, like this: -[github-username-1, github-username-2]