diff --git a/.env.example b/.env.example new file mode 100644 index 000000000..b4128944e --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +VITE_API_KEY= +MONGO_URL= +SECRET= \ No newline at end of file diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 000000000..9f7135d7d --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,2 @@ +MONGO_URL= +SECRET= \ No newline at end of file diff --git a/backend/middleware/Middleware.js b/backend/middleware/Middleware.js new file mode 100644 index 000000000..d8519f350 --- /dev/null +++ b/backend/middleware/Middleware.js @@ -0,0 +1,122 @@ +import User from "../model/user-model"; +import dotenv from "dotenv"; +import Blacklist from "../model/blacklist-model"; +import jwt from "jsonwebtoken"; + +dotenv.config(); +const SECRET = process.env.SECRET || "toast is the best secret"; + +const authenticateUser = async (req, res, next) => { + // Get the token from the request headers + const bearerHeader = req.headers["authorization"]; + //check if token is undefined, because if it is, we don't have any time for that - bzzzt! + if (typeof bearerHeader !== "undefined") { + //split the token like a lumberjack, and separate the bearer from the token + const bearer = bearerHeader.split(" "); + // Check if the bearer array has exactly 2 elements and the first element is 'Bearer' + if (bearer.length === 2 && bearer[0] === "Bearer") { + const bearerToken = bearer[1]; + try { + //decode the token 🤫 + const decoded = jwt.verify(bearerToken, SECRET); + // find user with the decoded id + const user = await User.findById(decoded.id); + //check if user exists and ready to party like a request object + if (user) { + req.user = user; + req.user.accessToken = bearerToken; // Set the access token for the user + //move to the next middleware -> authorizeUser or isLoggedIn to see if token is valid (not stored in blacklist) + next(); + } else { + res.status(401).json({ + loggedOut: true, + message: "you must log in to gain access", + }); + } + } catch (err) { + res.status(403).json({ loggedOut: true, message: "Invalid token" }); + } + } else { + res.status(403).json({ + loggedOut: true, + message: + "Invalid Authorization header format. It should be 'Bearer '", + }); + } + } else { + res.status(403).json({ loggedOut: true, message: "No token provided" }); + } +}; + +// authorize user +const authorizeUser = (roles) => { + return (req, res, next) => { + //check if user role is in the roles array and can hang out with the cool kids + if (!roles.includes(req.user.role)) { + return res.status(403).json({ + message: `You are not authorized to access this page. Required roles: ${roles.join( + ", " + )}`, + }); + } + next(); + }; +}; + +//check if user is logged in and the token is not in the blacklist +const isLoggedIn = async (req, res, next) => { + if (req.user) { + try { + // Query the database to check if the token exists in the blacklist + const tokenInBlacklist = await Blacklist.findOne({ + token: req.user.accessToken, + }); + + // If the token is in the blacklist, return a 401 status + if (tokenInBlacklist) { + return res + .status(401) + .json({ message: "This token has been invalidated" }); + } + + // If the token is not in the blacklist, proceed to the next middleware function or route handler + next(); + } catch (error) { + // If there's an error while querying the database, return a 500 status with a detailed error message + return res.status(500).json({ + message: "Error retrieving token blacklist", + error: error.message, + }); + } + } else { + // If the user is not logged in, return a 403 status + res.status(403).json({ message: "No user logged in" }); + } +}; + +//log out user +const logoutUser = async (req, res, next) => { + try { + if (!req.user || !req.user.accessToken) { + return res.status(400).json({ message: "Invalid user or access token" }); + } + + // Add the token to the blacklist, so it can't be used again + const token = req.user.accessToken; + const blacklistEntry = new Blacklist({ token: token }); + await blacklistEntry.save(); + //remove the access token from the user, this was a one time thing baby- you got to let it go + req.user.accessToken = null; + await req.user.save(); + //tell the user they are logged out, because we are polite like that, and dont ghost people + res.json({ message: "You are now logged out" }); + } catch (error) { + // if we can let that user go, we will let them know - but if we can't, we will let the console know + res.status(500).json({ + message: "An error occurred while logging out", + error: error.message, + }); + } +}; + +export { authenticateUser, authorizeUser, logoutUser, isLoggedIn }; diff --git a/backend/model/blacklist-model.js b/backend/model/blacklist-model.js new file mode 100644 index 000000000..566edb3c2 --- /dev/null +++ b/backend/model/blacklist-model.js @@ -0,0 +1,14 @@ +import mongoose from "mongoose"; + +const BlacklistSchema = new mongoose.Schema({ + token: { + type: String, + required: true, + unique: true, + }, +}); + +const Blacklist = mongoose.model("Blacklist", BlacklistSchema); + +// Export the model +export default Blacklist; diff --git a/backend/model/user-model.js b/backend/model/user-model.js new file mode 100644 index 000000000..c39407a5e --- /dev/null +++ b/backend/model/user-model.js @@ -0,0 +1,50 @@ +import mongoose from "mongoose"; +import bcryptjs from "bcryptjs"; + +const SALT_ROUNDS = 12; // make this configurable so we can adjust the security level if needed + +// Create a schema +const userSchema = new mongoose.Schema({ + name: { + type: String, + required: [true, "Name is required"], + minlength: [2, "Name must be at least 2 characters"], + maxlength: [30, "Name must be at most 30 characters"], + }, + email: { + type: String, + required: [true, "Email is required"], + unique: true, + match: [/\S+@\S+\.\S+/, "Email is invalid"], + }, + password: { + type: String, + required: [true, "Password is required"], + minlength: [5, "Password must be at least 5 characters"], + }, + role: { + type: String, + enum: ["user", "writer", "editor", "admin"], + default: "user", + }, + refreshToken: { + type: String, + default: null, + }, +}); + +//use matchPassword method to compare the entered password with the hashed password in the database +userSchema.methods.matchPassword = async function (enteredPassword) { + return await bcryptjs.compare(enteredPassword, this.password); +}; +//add a pre-save hook to hash the password *before* saving it to the database so we don't store the password in plain text because we are mysterious and security conscious +userSchema.pre("save", async function (next) { + // we only hash the password if it has been modified (or is new) + if (!this.isModified("password")) { + next(); + } + // hash the password before saving it to the database with the SALT_ROUNDS we can easoly adjust the security level. + this.password = await bcryptjs.hash(this.password, SALT_ROUNDS); +}); + +export default mongoose.model("User", userSchema); diff --git a/backend/package.json b/backend/package.json index 8de5c4ce0..bbb9e2662 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,7 +1,7 @@ { "name": "project-auth-backend", "version": "1.0.0", - "description": "Starter project to get up and running with express quickly", + "description": "Project Aunt Authy - a test project for authentication - authentication with JWT, bcryptjs, express, mongoose, and one strict Aunt Authy", "scripts": { "start": "babel-node server.js", "dev": "nodemon server.js --exec babel-node" @@ -12,8 +12,17 @@ "@babel/core": "^7.17.9", "@babel/node": "^7.16.8", "@babel/preset-env": "^7.16.11", + "atob": "^2.1.2", + "bcryptjs": "^2.4.3", + "cookie-parser": "^1.4.6", "cors": "^2.8.5", + "csurf": "^1.11.0", + "dompurify": "^3.1.4", + "dotenv": "^16.4.5", "express": "^4.17.3", + "express-list-endpoints": "^7.1.0", + "express-session": "^1.18.0", + "jsonwebtoken": "^9.0.2", "mongoose": "^8.0.0", "nodemon": "^3.0.1" } diff --git a/backend/routes/adminRoutes.js b/backend/routes/adminRoutes.js new file mode 100644 index 000000000..88bf85951 --- /dev/null +++ b/backend/routes/adminRoutes.js @@ -0,0 +1,137 @@ +import express from "express"; +import User from "../model/user-model"; +import { authenticateUser, authorizeUser } from "../middleware/Middleware"; +import dotenv from "dotenv"; + +dotenv.config(); +const SECRET = process.env.SECRET || "toast is the best secret"; +const adminRouter = express.Router(); +adminRouter.use(authenticateUser, authorizeUser(["admin"])); + +// get all users in the database +adminRouter.get("/users", async (req, res) => { + try { + const users = await User.find(); + res.json(users); + } catch (error) { + res.status(500).json({ + message: "An error occurred while fetching users", + error: error.message, + }); + } +}); + +// route for getting content behind authorization find me at /admin +adminRouter.get("/", (req, res) => { + // This code will only run if the user is an admin + res.render("admin"); +}); + +//admin add user - find me at /admin/users +adminRouter.post("/users", async (req, res) => { + try { + const { name, email, role, password } = req.body; + if (!name || !email || !role || !password) { + return res.status(400).json({ error: "All fields are required" }); + } + const existingUser = await User.findOne({ email }); + if (existingUser) { + return res + .status(400) + .send({ message: "User with this email already exists" }); + } + const newUser = new User({ + name, + email, + role, + password, + }); + const savedUser = await newUser.save(); + res.json(savedUser); + } catch (error) { + res.status(500).json({ + error: "An error occurred while adding the user" + }); + } +}); + +//admin update user - find me at /admin/users/:id +adminRouter.put("/users/:id", async (req, res) => { + try { + const { id } = req.params; + const { name, email, role, password } = req.body; + + if (!(name || email || role || password)) { + return res.status(400).json({ + error: "At least one field is required to update user data", + }); + } + + const user = await User.findById(id); + if (!user) { + return res.status(404).json({ error: "User not found" }); + } + + if (name) user.name = name; + if (email) user.email = email; + if (role) user.role = role; + if (password) user.password = password; + + const updatedUser = await user.save(); + + res.json(updatedUser); + } catch (error) { + console.error(error); + res.status(500).json({ + error: "An error occurred while updating the user", + error: error.message, + }); + } +}); + +//endpoint for only updating the user role - find me at admin/users/:id +adminRouter.patch("/users/:id", async (req, res) => { + try { + const { id } = req.params; + const { role } = req.body; + const updatedUser = await User.findByIdAndUpdate( + id, + { role }, + { new: true } + ); + if (updatedUser) { + res.json({ + message: "User role updated", + success: true, + response: updatedUser, + }); + } else { + res.status(404).json({ message: "User not found", error: error.message }); + } + } catch (error) { + res.status(500).json({ + message: "An error occurred while updating the user", + error: error.message, + }); + } +}); + +//delete user - find me at /admin/users/:id +adminRouter.delete("/users/:id", async (req, res) => { + try { + const { id } = req.params; + const deletedUser = await User.findByIdAndDelete(id); + if (deletedUser) { + res.json({ message: "user deleted", deletedUser }); + } else { + res.status(404).json({ message: "User not found", error: error.message }); + } + } catch (error) { + res.status(500).json({ + message: "An error occurred while deleting the user", + error: error.message, + }); + } +}); + +export default adminRouter; diff --git a/backend/routes/routes.js b/backend/routes/routes.js new file mode 100644 index 000000000..4300039f2 --- /dev/null +++ b/backend/routes/routes.js @@ -0,0 +1,136 @@ +import express from "express"; +import User from "../model/user-model"; +import { + authenticateUser, + logoutUser, + isLoggedIn, +} from "../middleware/Middleware"; +import jwt from "jsonwebtoken"; +import dotenv from "dotenv"; + +dotenv.config(); +const SECRET = process.env.SECRET || "toast is the best secret"; +const router = express.Router(); + +//check if user already exists +router.post("/exists", async (req, res) => { + const { email } = req.body; + try { + const existingUser = await User.findOne({ email }); + if (existingUser) { + res.status(400).json({ + exists: true, + message: "User already exists", + }); + } else { + res.status(200).json({ + exists: false, + message: "User does not exist", + }); + } + } catch (err) { + res.status(400).json({ + message: "An error occurred while checking if user exists", + error: error.message, + }); + } +}); + +// add user +router.post("/user", async (req, res) => { + const { name, email, password } = req.body; + try { + // check for duplicate user, and ensure that the input is treated as a literal value ($eq) to protect against NoSQL injection + const existingUser = await User.findOne({ email: { $eq: email } }); + if (existingUser) { + return res + .status(400) + .send({ message: "User with this email already exists" }); + } + const newUser = await new User({ + name, + email, + password, + }).save(); + + // Generate a new access token + const newAccessToken = jwt.sign({ id: newUser._id }, SECRET, { + expiresIn: "1h", + }); + + res.status(201).json({ + userId: newUser._id, + accessToken: newAccessToken, + }); + } catch (err) { + res + .status(400) + .json({ message: "Could not create user", errors: err.errors }); + } +}); + +//log in user +router.post("/session", async (req, res) => { + try { + const { email, password } = req.body; + const user = await User.findOne({ email }); + if (user && (await user.matchPassword(password))) { + //generate the access token + const newAccessToken = jwt.sign({ id: user._id }, SECRET, { + expiresIn: "1h", + }); + // Send the access token back to the client + res.json({ + userId: user._id, + accessToken: newAccessToken, + role: user.role, + }); + } else { + res.status(400).json({ notFound: true, message: "User not found" }); + } + } catch (error) { + res.status(500).json({ + message: "An error occurred while logging in", + error: error.message, + }); + } +}); + +//route to verify if token is valid with middleware isLoggedIn +router.get("/verify", authenticateUser, isLoggedIn, (req, res) => { + res.json({ message: "You are logged in" }); +}); + +//route to get user role +router.get("/role", authenticateUser, (req, res) => { + res.json({ role: req.user.role }); +}); + +// Route to log out user +router.post("/logout", authenticateUser, logoutUser, (req, res) => { + res.json({ message: "You are now logged out" }); +}); + +// Patch request to update user +router.patch("/users/:id", async (req, res) => { + const { id } = req.params; + const { name, email, password } = req.body; + try { + const user = await User.findById(id); + if (user) { + user.name = name; + user.email = email; + user.password = password; + const updatedUser = await user.save(); + res.json(updatedUser); + } else { + res.status(404).json({ message: "User not found" }); + } + } catch (error) { + res + .status(500) + .json({ message: "An error occurred", error: error.message }); + } +}); + +export default router; diff --git a/backend/server.js b/backend/server.js index dfe86fb8e..a975505b7 100644 --- a/backend/server.js +++ b/backend/server.js @@ -1,24 +1,95 @@ import cors from "cors"; import express from "express"; import mongoose from "mongoose"; +import router from "./routes/routes.js"; +import adminRouter from "./routes/adminRoutes.js"; +import dotenv from "dotenv"; +import listEndpoints from "express-list-endpoints"; +dotenv.config(); -const mongoUrl = process.env.MONGO_URL || "mongodb://localhost/project-mongo"; +const mongoUrl = process.env.MONGO_URL || "mongodb://localhost/aunty"; 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 -const port = process.env.PORT || 8080; +const port = process.env.PORT || 8787; const app = express(); -// Add middlewares to enable cors and json body parsing -app.use(cors()); +const allowedOrigins = [ + "https://auntauthy.netlify.app", + "https://aunt-authy.onrender.com" + +]; + +app.use( + cors({ + origin: function (origin, callback) { + // Allow requests with no origin + // (like mobile apps or curl requests) + if (!origin) return callback(null, true); + if (allowedOrigins.indexOf(origin) === -1) { + var msg = + "The CORS policy for this site does not " + + "allow access from the specified Origin."; + return callback(new Error(msg), false); + } + return callback(null, true); + }, + }) +); + app.use(express.json()); +app.use("/", router); +app.use("/admin", adminRouter); -// Start defining your routes here app.get("/", (req, res) => { - res.send("Hello Technigo!"); + try { + const routerEndpoints = listEndpoints(router); + const adminRouterEndpoints = listEndpoints(adminRouter); + const allEndpoints = [...routerEndpoints, ...adminRouterEndpoints]; + + const descriptions = { + "/admin/users": + "This route retrieves all users from the database. It requires admin authorization.", + "/admin": + "This route renders the admin page. It requires admin authorization.", + "/exists": + "This route checks if a user with the provided email already exists in the database. It expects a JSON body with an 'email' field.", + "/user": + "This route creates a new user with the provided name, email, and password. The password is hashed before being stored in the database. Upon successful creation, it returns the new user's ID and a JWT access token.", + "/session": + "This route logs in a user with the provided email and password. If the email and password match a user in the database, it returns the user's ID, a new JWT access token, and the user's role.", + "/verify": + "This route verifies if a user is authenticated by checking their JWT access token. and if their token is not stored in our blacklist. If the token is valid, it returns a message saying the user is logged in.", + "/role": "This route returns the role of the authenticated user.", + "/logout": + "This route logs out the authenticated user. It invalidates the user's JWT access token.", + "/users/:id": + "This route updates the user with the provided ID. If the user is found and updated successfully, it returns the updated user's data.", + "/admin/users/:id": + "This route updates the user with the provided ID. It requires admin authorization. If the user is found and updated successfully, it returns the updated user's data.", + "/users": "This route retrieves all users from the database.", + "/": "This route retrieves all endpoints available in the API.", + }; + + const updatedEndpoints = allEndpoints.map((endpoint) => { + return { + path: endpoint.path, + methods: endpoint.methods, + description: descriptions[endpoint.path] || "No description provided", + }; + }); + res.json(updatedEndpoints); + } catch (error) { + // If an error occurred, create a new error with a custom message + const customError = new Error( + "An error occurred while fetching the endpoints" + ); + res.status(404).json({ + success: false, + response: error, + message: customError.message, + }); + } }); // Start the server diff --git a/backend/utility/tokenBlacklist.js b/backend/utility/tokenBlacklist.js new file mode 100644 index 000000000..db0b9f86c --- /dev/null +++ b/backend/utility/tokenBlacklist.js @@ -0,0 +1,48 @@ +import atob from "atob"; // Import the atob function to decode base64 strings +import Blacklist from "../model/blacklist-model.js"; // Import the Blacklist model + +// Remove expired tokens from the blacklist to avoid memory leaks, and schedule the next cleanup in an hour +//clutter free db please. my server is not a hoarder +const cleanupBlacklist = () => { + const now = Date.now() / 1000; // Get the current time in seconds + + // Find all tokens in the blacklist + Blacklist.find() + .then((tokens) => { + // Filter out tokens that have expired + const validTokens = tokens.filter((token) => { + let payload; + try { + const parts = token.token.split("."); + if (parts.length !== 3) { + throw new Error("Invalid token"); + } + payload = JSON.parse(atob(parts[1])); + } catch (error) { + console.error("Error parsing token payload: ", error); + return false; // Skip this token if there's an error + } + + return payload.exp > now; + }); + + // Remove expired tokens from the blacklist - wish it was this easy in real life + const expiredTokens = tokens.filter( + (token) => !validTokens.includes(token) + ); + expiredTokens.forEach((token) => { + Blacklist.deleteOne({ _id: token._id }).catch((error) => + console.error(error) + ); + }); + }) + .catch((error) => console.error(error)); + + // Schedule the next cleanup - Cinderella, you shall go to the ball but be back in an hour + setTimeout(cleanupBlacklist, 60 * 60 * 1000); // Clean up every hour +}; + +// Start the cleanup schedule - Cinderella, mop the floor and clean the windows of dirty tokens +cleanupBlacklist(); + +export { cleanupBlacklist }; diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 000000000..c340407de --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1 @@ +VITE_API_KEY= \ No newline at end of file diff --git a/frontend/README.md b/frontend/README.md index f768e33fc..b78944a9c 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,8 +1,20 @@ -# React + Vite +# Project Auth -This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. +This is the auth project for week 16. + +A simple solution for testing out authorization, authentication and token blacklists. + +a website where you can make a user, log in to a dash and log out. +if you are assigned a role as admin, you will se an login for cool admins in dash where you can go to do important admin stuff, such ass creating new users, adjusting data for users, assing roles and delete users. .. and log out + + + + Auth Aunty + API + +test admin : admin@admin.com / pass: admin +test user : user@user.com / pass: user + +or make your own users if you like. admin has a separate login with more options, but easy to miss because the frontend got no love at all -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/_redirects b/frontend/_redirects new file mode 100644 index 000000000..e126d85c8 --- /dev/null +++ b/frontend/_redirects @@ -0,0 +1,10 @@ +/exists https://project-auth-pqxu.onrender.com/exists 200 +/user https://project-auth-pqxu.onrender.com/user 200 +/session https://project-auth-pqxu.onrender.com/session 200 +/verify https://project-auth-pqxu.onrender.com/verify 200 +/role https://project-auth-pqxu.onrender.com/role 200 +/logout https://project-auth-pqxu.onrender.com/logout 200 +/users/:id https://project-auth-pqxu.onrender.com/users/:id 200 +/admin https://project-auth-pqxu.onrender.com/admin 200 +/admin/users https://project-auth-pqxu.onrender.com/admin/users 200 +/admin/users/:id https://project-auth-pqxu.onrender.com/admin/users/:id 200 diff --git a/frontend/components/Admin.jsx b/frontend/components/Admin.jsx new file mode 100644 index 000000000..9a0ff8049 --- /dev/null +++ b/frontend/components/Admin.jsx @@ -0,0 +1,65 @@ +import { useState, useEffect, useCallback } from 'react'; + +import { Logout } from './Logout'; +import { UpdateUser } from './forms/UpdateUser'; +import { DeleteUser } from './forms/DeleteUser'; +import { CreateUser } from './forms/CreateUser'; +import { UpdateUserRole } from './forms/UpdateUserRole'; + +export const Admin = () => { + const apiKey = import.meta.env.VITE_API_KEY; + const API = apiKey + "/admin"; + const [users, setUsers] = useState([]); + const token = sessionStorage.getItem('token'); + const [message, setMessage] = useState(""); + + const getUsers = useCallback(async () => { + try { + const response = await fetch(`${API}/users`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + } + }); + const data = await response.json(); + setUsers(data); + setMessage('User fetched successfully'); + } catch (error) { + setMessage('An unexpected error occurred.'); + } + }, [API, token, setUsers]); + + useEffect(() => { + getUsers(); + }, [getUsers]); + + return ( +
+ +

Admin page

+

Wow - so much admin stuff

+

As an admin can you do lots of exclusive stuff: create and delete users, update user roles, and userinfo

+ + + + + + + + + + {users.map(({ _id, name, email, role }) => ( +
+
    +
  • ID: {_id}
  • +
  • NAME: {name}
  • +
  • EMAIL:{email}
  • +
  • ROLE: {role}
  • +
+
+ ))} + {message &&

{message}

} +
+ ); +} diff --git a/frontend/components/Dashboard.jsx b/frontend/components/Dashboard.jsx new file mode 100644 index 000000000..5eecb2b88 --- /dev/null +++ b/frontend/components/Dashboard.jsx @@ -0,0 +1,53 @@ +import { useNavigate } from "react-router-dom" +import { Logout } from "./Logout" +import { useState, useEffect } from "react"; + +export const Dashboard = () => { + const [isAdmin, setIsAdmin] = useState(false); + const navigate = useNavigate(); + const apiKey = import.meta.env.VITE_API_KEY; + const API = apiKey + "/role"; + + + useEffect(() => { + const fetchData = async () => { + const token = sessionStorage.getItem('token'); + await fetch(`${API}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + }) + .then((res) => { + if (!res.ok) { + throw new Error(`Server error: ${res.status}`); + } + return res.json(); + }) + .then((json) => { + setIsAdmin(json.role === 'admin'); + }) + .catch((err) => { + console.error(err); + }); + }; + + fetchData(); + } + , []); + + return ( + <> +

Dashboard

+

This is a page you have to be logged into see!

+ + + {isAdmin ? ( + + ) : ( + + )} + + ); +} \ No newline at end of file diff --git a/frontend/components/Home.jsx b/frontend/components/Home.jsx new file mode 100644 index 000000000..09b6acedf --- /dev/null +++ b/frontend/components/Home.jsx @@ -0,0 +1,26 @@ +import { Login } from "./Login.jsx" +import { Registration } from "./Registration.jsx" +import { useState } from 'react'; + +export const Home = () => { + const [showLogin, setShowLogin] = useState(true); + return ( + <> +

Home

+

Welcome home old or new sailors!

+ {showLogin ? ( + <> +

Not a member? Register here

+ + + + ) : ( + <> +

Already a member? Login here

+ + + + )} + + ); +} diff --git a/frontend/components/Login.jsx b/frontend/components/Login.jsx new file mode 100644 index 000000000..4c6487673 --- /dev/null +++ b/frontend/components/Login.jsx @@ -0,0 +1,68 @@ +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; + +export const Login = () => { + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [message, setMessage] = useState(""); + const navigate = useNavigate(); + const apiKey = import.meta.env.VITE_API_KEY; + const API = apiKey + "/session"; + + const handleLogin = (event) => { + event.preventDefault(); + fetch(API, { + method: "POST", + body: JSON.stringify({ email, password }), + headers: { "Content-Type": "application/json" }, + + }) + .then((res) => { + if (!res.ok) { + throw new Error("Unable to log in. Please try again."); + } + return res.json(); + }) + .then((json) => { + setMessage("Login successful!"); + navigate("/dashboard"); + // Store the token in session storage + sessionStorage.setItem('token', json.accessToken); + }) + .catch((err) => { + setMessage(err.message); + }); + }; + + return ( + +
+

Login

+
+ + + + +
+ {message && ( +
+

{message}

+
+ )} +
+ ) +}; \ No newline at end of file diff --git a/frontend/components/Logout.jsx b/frontend/components/Logout.jsx new file mode 100644 index 000000000..2a4d462b2 --- /dev/null +++ b/frontend/components/Logout.jsx @@ -0,0 +1,40 @@ + +import { useNavigate } from "react-router-dom"; +import { useState } from "react"; + +export const Logout = () => { + const [message, setMessage] = useState(""); + const apiKey = import.meta.env.VITE_API_KEY; + const API = apiKey + "/logout"; + const navigate = useNavigate(); + + const handleLogout = async () => { + try { + // Retrieve the token from session storage + const yourToken = sessionStorage.getItem('token'); + await fetch(API, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${yourToken}` + }, + }); + // Let's Kon-Mari that token who does not spark joy anymore. it has no purpose and is no longer worthy of our neat session storage + sessionStorage.removeItem('token'); + setMessage("Logout successful! Please sign in."); + navigate("/"); + + } catch (error) { + setMessage(error); + } + }; + + return ( +
+ +

{message}

+
+ ); +} + + diff --git a/frontend/components/ProtectedRoutes.jsx b/frontend/components/ProtectedRoutes.jsx new file mode 100644 index 000000000..316f71587 --- /dev/null +++ b/frontend/components/ProtectedRoutes.jsx @@ -0,0 +1,53 @@ +import { useNavigate } from 'react-router-dom'; +import PropTypes from 'prop-types'; +import { useEffect, useState } from 'react'; + +export const ProtectedRoute = ({ children }) => { + const navigate = useNavigate(); + const [isLoading, setIsLoading] = useState(true); + const apiKey = import.meta.env.VITE_API_KEY; + const API = apiKey + "/verify"; + + + const verifyToken = async () => { + const token = sessionStorage.getItem('token'); + + if (!token) { + navigate('/login'); + return; + } + + try { + const response = await fetch(API, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + "Authorization": `Bearer ${token}`, + }, + }); + + if (!response.ok) { + throw new Error('Token verification failed'); + } + + setIsLoading(false); + } catch (error) { + console.error(error); + navigate('/login'); + } + }; + + useEffect(() => { + verifyToken(); + }, []); + + if (isLoading) { + return
Loading...
; + } + + return children; +}; + +ProtectedRoute.propTypes = { + children: PropTypes.node.isRequired, +}; \ No newline at end of file diff --git a/frontend/components/Registration.jsx b/frontend/components/Registration.jsx new file mode 100644 index 000000000..8fb44bb96 --- /dev/null +++ b/frontend/components/Registration.jsx @@ -0,0 +1,90 @@ +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; + +export const Registration = () => { + const [name, setName] = useState(""); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [message, setMessage] = useState(""); + const navigate = useNavigate(); + const apiKey = import.meta.env.VITE_API_KEY; + const signup = apiKey + "/user"; + const exist = apiKey + "/exists"; + + const handleRegistration = (event) => { + event.preventDefault(); + + // First check if the user already exists because brainfog is a post-covid vibe, and people have to many accounts and things going on in their lives to remember everything + fetch(exist, { + method: "POST", + body: JSON.stringify({ email }), + headers: { "Content-Type": "application/json" }, + }) + .then((res) => res.json()) + .then((data) => { + if (data.exists) { + // If the user exists, we alert them, and redirect them to the login page because we are super helpful but not patronizing + alert("Woops! You already have an account you silly goose 🪿, Let's redirect you to our loginpage."); + navigate("/login"); + } else { + // If the user does not exist silent celebrate our new member🥳 then play it cool and nonchalant, and proceed with the registration + fetch(signup, { + method: "POST", + body: JSON.stringify({ name, email, password }), + headers: { "Content-Type": "application/json" }, + }) + .then((res) => { + if (!res.ok) { + throw new Error("Unable to register. Please try again."); + } + return res.json(); + }) + .then(() => { + setMessage("Registration successful! Please sign in."); + navigate("/login"); + }) + .catch((err) => { + setMessage(err.message); + }); + } + }) + .catch(() => { + setMessage("An error occurred while checking if user exists"); + }); + }; + + return ( +
+

Register

+
+ + + + +
+ {message &&

{message}

} + +
+ ); +} diff --git a/frontend/components/forms/CreateUser.jsx b/frontend/components/forms/CreateUser.jsx new file mode 100644 index 000000000..427ecd7f2 --- /dev/null +++ b/frontend/components/forms/CreateUser.jsx @@ -0,0 +1,86 @@ +import { useState } from 'react'; + +const apiKey = import.meta.env.VITE_API_KEY; +const API = apiKey + "/admin" + +export const CreateUser = ({ getUsers }) => { + const [name, setName] = useState(''); + const [email, setEmail] = useState(''); + const [role, setRole] = useState('user'); + const [password, setPassword] = useState(''); + const [errors, setErrors] = useState({}); + const [message, setMessage] = useState(''); + const Update = async () => { + const token = sessionStorage.getItem('token'); + try { + const response = await fetch(`${API}/users`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + body: JSON.stringify({ name, email, role, password }), + }); + if (!response.ok) { + const errorData = await response.json(); + const errorMessage = errorData.message || errorData.error; + setMessage(errorMessage || 'An error occurred while creating the user.'); + throw new Error(errorMessage); + } + + getUsers(); + setMessage('User created successfully'); + } catch (error) { + // check if it includes the error message from the backend + if (error.message.includes('User with this email already exists')) { + setMessage('A user with the same email already exists.'); + } else { + setMessage(`An unexpected error occurred: ${error.message}`); + } + } + }; + + const handleSubmit = (e) => { + e.preventDefault(); + let errors = {}; + + if (!name) errors.name = "Name is required."; + if (!email) errors.email = "Email is required."; + if (!password) errors.password = "Password is required."; + if (!role) errors.role = "Role is required."; + setErrors(errors) + + if (Object.keys(errors).length === 0) { + // If no errors, celebrate and send fresh user data to the server + Update(); + } + }; + + + return ( +
+ + + + setName(e.target.value.trim())} /> + {errors.name &&

{errors.name}

} + + setEmail(e.target.value.trim())} /> + {errors.email &&

{errors.email}

} + + + + {errors.role &&

{errors.role}

} + + setPassword(e.target.value.trim())} /> + {errors.password &&

{errors.password}

} + + {message &&

{message}

} +
+ ); +} \ No newline at end of file diff --git a/frontend/components/forms/DeleteUser.jsx b/frontend/components/forms/DeleteUser.jsx new file mode 100644 index 000000000..faa99f813 --- /dev/null +++ b/frontend/components/forms/DeleteUser.jsx @@ -0,0 +1,38 @@ +import { useState } from "react"; + +export const DeleteUser = ({ getUsers }) => { + const apiKey = import.meta.env.VITE_API_KEY; + const API = apiKey + "/admin"; + const token = sessionStorage.getItem('token'); + const [message, setMessage] = useState(""); // Move this line inside the DeleteUser component + + const Delete = async (e) => { + e.preventDefault(); + const id = e.target.id.value; + try { + await fetch(`${API}/users/${id}`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + }); + + setMessage('User deleted successfully'); + getUsers(); + } + catch (error) { + console.error(error); + setMessage('An unexpected error occurred.'); + } + } + return ( +
+ + + + + {message &&

{message}

} +
+ ); +}; diff --git a/frontend/components/forms/UpdateUser.jsx b/frontend/components/forms/UpdateUser.jsx new file mode 100644 index 000000000..916ba8bca --- /dev/null +++ b/frontend/components/forms/UpdateUser.jsx @@ -0,0 +1,108 @@ +import { useState, useEffect } from 'react'; + +const apiKey = import.meta.env.VITE_API_KEY; +const API = apiKey + "/admin" + +export const UpdateUser = ({ getUsers }) => { + const [id, setId] = useState(''); + const [name, setName] = useState(''); + const [email, setEmail] = useState(''); + const [role, setRole] = useState('user'); + const [password, setPassword] = useState(''); + const [errors, setErrors] = useState({}); + const [message, setMessage] = useState(''); + + // Store the initial values + const [initialName, setInitialName] = useState(name); + const [initialEmail, setInitialEmail] = useState(email); + const [initialRole, setInitialRole] = useState(role); + const [initialPassword, setInitialPassword] = useState(password); + + // Set the initial values when the component mounts + useEffect(() => { + setInitialName(name); + setInitialEmail(email); + setInitialRole(role); + setInitialPassword(password); + }, []); + + const Update = async () => { + const token = sessionStorage.getItem('token'); + let userData = {}; + + // Check if any field has changed + if (name === initialName && email === initialEmail && role === initialRole && password === initialPassword) { + setMessage("No changes made to the form, lets try again.😅"); + return; // Stop execution if no fields have changed + } + if (name) userData.name = name; + if (email) userData.email = email; + if (role) userData.role = role; + if (password) userData.password = password; + try { + const response = await fetch(`${API}/users/${id}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + + body: JSON.stringify({ name, email, role, password }), + }); + if (response.ok) { + setMessage("User updated successfully"); + getUsers(); + } else + if (!response.ok) { + setMessage("An error occurred while updating the user." + response.statusText); + } + + } catch (error) { + console.error(error); + } + }; + + const handleSubmit = (e) => { + e.preventDefault(); + let errors = {}; + //check that at least one field in the form is filled in + if (!name && !email && !password && !role) { + errors.general = "At least one field is required for update."; + setErrors(errors); + return; + } + if (Object.keys(errors).length === 0) { + // If no errors, celebrate and send the data to the server + Update(); + } + }; + + + return ( +
+ + + setId(e.target.value.trim())} /> + {errors.id &&

{errors.id}

} + + setName(e.target.value.trim())} /> + {errors.name &&

{errors.name}

} + + setEmail(e.target.value.trim())} /> + {errors.email &&

{errors.email}

} + + + {errors.role &&

{errors.role}

} + + setPassword(e.target.value.trim())} /> + {errors.password &&

{errors.password}

} + + {message &&
{message}
} +
+ ); +} \ No newline at end of file diff --git a/frontend/components/forms/UpdateUserRole.jsx b/frontend/components/forms/UpdateUserRole.jsx new file mode 100644 index 000000000..4038c122a --- /dev/null +++ b/frontend/components/forms/UpdateUserRole.jsx @@ -0,0 +1,59 @@ +import { useState } from 'react'; + +export const UpdateUserRole = ({ getUsers }) => { + const [id, setId] = useState(''); + const [role, setRole] = useState('user'); + const [message, setMessage] = useState(''); + const apiKey = import.meta.env.VITE_API_KEY; + const API = apiKey + "/admin"; + + + + const updateRole = async (e) => { + const token = sessionStorage.getItem('token'); + e.preventDefault(); + const id = e.target.id.value; + const role = e.target.role.value; + + if (!id || !role) { + setMessage('Please fill in all fields'); + return; + } + + try { + const response = await fetch(`${API}/users/${id}`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + body: JSON.stringify({ role }), + }); + + if (!response.ok) { + throw new Error('Network response was not ok'); + } + setMessage('User role updated successfully'); + getUsers(); + + } catch (error) { + setMessage('An error occurred while updating the user role'); + } + }; + return ( +
+ + + setId(e.target.value.trim())} /> + + + + {message &&

{message}

} +
+ ); +}; \ No newline at end of file diff --git a/frontend/netlify.toml b/frontend/netlify.toml new file mode 100644 index 000000000..32464b375 --- /dev/null +++ b/frontend/netlify.toml @@ -0,0 +1,4 @@ +[build] + base = "frontend/" + publish = "dist" + command = "npm run build" \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json index e9c95b79f..5f778fc94 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -5,13 +5,16 @@ "type": "module", "scripts": { "dev": "vite", - "build": "vite build", + "build": "vite build && cp _redirects dist/", "lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0", "preview": "vite preview" }, "dependencies": { + "dompurify": "^3.1.4", + "dotenv": "^16.4.5", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "react-router-dom": "^6.23.1" }, "devDependencies": { "@types/react": "^18.2.15", diff --git a/frontend/routes/AppRoutes.jsx b/frontend/routes/AppRoutes.jsx new file mode 100644 index 000000000..2e455d298 --- /dev/null +++ b/frontend/routes/AppRoutes.jsx @@ -0,0 +1,26 @@ +import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'; +import { Registration } from "../components/Registration.jsx"; +import { Login } from "../components/Login.jsx"; +import { Home } from "../components/Home.jsx"; +import { Dashboard } from "../components/Dashboard.jsx"; +import { ProtectedRoute } from '../components/ProtectedRoutes.jsx'; +import { Admin } from '../components/Admin.jsx'; + +export const AppRoutes = () => { + + + return ( + + + } fallbackComponent={} /> + } /> + } /> + } /> + } fallbackComponent={} /> + } /> + Not Found} /> + + + + ) +}; diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 1091d4310..6eb2b2659 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,3 +1,10 @@ +import { AppRoutes } from '/routes/AppRoutes.jsx'; + export const App = () => { - return
Find me in src/app.jsx!
; + return ( + + + + + ); }; 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"