diff --git a/.gitignore b/.gitignore index 3d70248ba2..fac0d22bad 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ node_modules .env.development.local .env.test.local .env.production.local +backend/.env build diff --git a/backend/middlewares/auth.js b/backend/middlewares/auth.js new file mode 100644 index 0000000000..9617a9aa4b --- /dev/null +++ b/backend/middlewares/auth.js @@ -0,0 +1,13 @@ +import jwt from "jsonwebtoken"; + +export default function auth(req, res, next) { + const h = req.headers.authorization || ""; + const token = h.startsWith("Bearer ") ? h.slice(7) : null; + if (!token) return res.status(401).json({ error: "No token" }); + try { + req.user = jwt.verify(token, process.env.JWT_SECRET); // { id, iat, exp } + next(); + } catch { + res.status(401).json({ error: "Invalid token" }); + } +} diff --git a/backend/models/Item.js b/backend/models/Item.js new file mode 100644 index 0000000000..ff8ac58a1e --- /dev/null +++ b/backend/models/Item.js @@ -0,0 +1,16 @@ +import mongoose from "mongoose"; + +const itemSchema = new mongoose.Schema( + { + userId: { type: mongoose.Schema.Types.ObjectId, ref: "User", required: true, index: true }, + childId: { type: mongoose.Schema.Types.ObjectId, ref: "Kid", required: true }, + type: { type: String, enum: ["jacket", "pants", "boots", "hat", "top", "gloves", "other"], default: "other" }, + size: { type: String, required: true }, + season: { type: String, enum: ["winter", "spring", "summer", "autumn", "all"], default: "all" }, + status: { type: String, enum: ["current", "needed", "stored", "to-sell"], default: "current" }, + notes: String + }, + { timestamps: true } +); + +export default mongoose.model("Item", itemSchema); diff --git a/backend/models/Kid.js b/backend/models/Kid.js new file mode 100644 index 0000000000..5673267cb4 --- /dev/null +++ b/backend/models/Kid.js @@ -0,0 +1,16 @@ +import mongoose from "mongoose"; + +const kidSchema = new mongoose.Schema({ + name: { type: String, required: true }, + birthdate: { type: Date, required: true }, + + // optional: male/female/other + sex: { type: String, enum: ["boy", "girl", "other"], default: "other" }, + + // optional: current height in cm + height: { type: Number, min: 40, max: 200 }, + + owner: { type: mongoose.Schema.Types.ObjectId, ref: "User", required: true }, +}, { timestamps: true }); + +export default mongoose.model("Kid", kidSchema); diff --git a/backend/models/User.js b/backend/models/User.js new file mode 100644 index 0000000000..9a9d02ea1e --- /dev/null +++ b/backend/models/User.js @@ -0,0 +1,11 @@ +import mongoose from "mongoose"; + +const userSchema = new mongoose.Schema( + { + email: { type: String, required: true, unique: true, trim: true }, + passwordHash: { type: String, required: true } + }, + { timestamps: true } +); + +export default mongoose.model("User", userSchema); diff --git a/backend/package.json b/backend/package.json index 08f29f2448..bc3b46497e 100644 --- a/backend/package.json +++ b/backend/package.json @@ -12,9 +12,19 @@ "@babel/core": "^7.17.9", "@babel/node": "^7.16.8", "@babel/preset-env": "^7.16.11", + "bcrypt": "^6.0.0", "cors": "^2.8.5", - "express": "^4.17.3", - "mongoose": "^8.4.0", - "nodemon": "^3.0.1" + "dotenv": "^17.2.1", + "express": "^4.21.2", + "jsonwebtoken": "^9.0.2", + "mongodb": "^6.18.0", + "mongoose": "^8.17.2", + "morgan": "^1.10.1", + "react-hook-form": "^7.62.0", + "react-router-dom": "^7.8.1", + "zustand": "^5.0.8" + }, + "devDependencies": { + "nodemon": "^3.1.10" } -} \ No newline at end of file +} diff --git a/backend/routes/auth.js b/backend/routes/auth.js new file mode 100644 index 0000000000..ce92892b4d --- /dev/null +++ b/backend/routes/auth.js @@ -0,0 +1,57 @@ +import express from "express"; +import bcrypt from "bcrypt"; +import jwt from "jsonwebtoken"; +import mongoose from "mongoose"; +import User from "../models/User.js"; + +const router = express.Router(); +const sign = (id) => jwt.sign({ id }, process.env.JWT_SECRET, { expiresIn: "7d" }); + +// POST /auth/register +router.post("/register", async (req, res) => { + try { + const { email, password } = req.body; + + // basic validation + if (!email || !password) return res.status(400).json({ error: "Email and password required" }); + if (password.length < 6) return res.status(400).json({ error: "Password must be ≥ 6 chars" }); + + // unique email + const exists = await User.findOne({ email: email.toLowerCase().trim() }); + if (exists) return res.status(409).json({ error: "Email already registered" }); + + // hash & save + const passwordHash = await bcrypt.hash(password, 10); + const user = await User.create({ email: email.toLowerCase().trim(), passwordHash }); + + // return a token immediately (so the FE can log in) + return res.status(201).json({ token: sign(user._id) }); + } catch (e) { + // handle duplicate key race condition + if (e.code === 11000) return res.status(409).json({ error: "Email already registered" }); + console.error(e); + return res.status(500).json({ error: "Register failed" }); + } +}); + +// POST /auth/login +router.post("/login", async (req, res) => { + try { + const { email, password } = req.body; + if (!email || !password) return res.status(400).json({ error: "Email and password required" }); + + const user = await User.findOne({ email: email.toLowerCase().trim() }); + if (!user) return res.status(401).json({ error: "Invalid credentials" }); + + const ok = await bcrypt.compare(password, user.passwordHash); + if (!ok) return res.status(401).json({ error: "Invalid credentials" }); + + return res.json({ token: sign(user._id) }); + } catch (e) { + console.error(e); + return res.status(500).json({ error: "Login failed" }); + } +}); + + +export default router; diff --git a/backend/routes/items.js b/backend/routes/items.js new file mode 100644 index 0000000000..19b566f4e0 --- /dev/null +++ b/backend/routes/items.js @@ -0,0 +1,38 @@ +import express from "express"; +import Item from "../models/Item.js"; +import auth from "../middlewares/auth.js"; + +const router = express.Router(); + +// list items for user +router.get("/", auth, async (req, res) => { + const items = await Item.find({ userId: req.user.id }).lean(); + res.json(items); +}); + +// create +router.post("/", auth, async (req, res) => { + const { childId, type, size, season, status, notes } = req.body; + const item = await Item.create({ userId: req.user.id, childId, type, size, season, status, notes }); + res.status(201).json(item); +}); + +// update +router.patch("/:id", auth, async (req, res) => { + const item = await Item.findOneAndUpdate( + { _id: req.params.id, userId: req.user.id }, + req.body, + { new: true } + ); + if (!item) return res.status(404).json({ error: "Not found" }); + res.json(item); +}); + +// delete +router.delete("/:id", auth, async (req, res) => { + const ok = await Item.deleteOne({ _id: req.params.id, userId: req.user.id }); + if (!ok.deletedCount) return res.status(404).json({ error: "Not found" }); + res.json({ ok: true }); +}); + +export default router; diff --git a/backend/routes/kids.js b/backend/routes/kids.js new file mode 100644 index 0000000000..e17b42ed1f --- /dev/null +++ b/backend/routes/kids.js @@ -0,0 +1,28 @@ +import express from "express"; +import auth from "../middlewares/auth.js"; +import Kid from "../models/Kid.js"; + +const router = express.Router(); + +router.get("/", auth, async (req, res) => { + try { + const kids = await Kid.find({ owner: req.user.id }).lean(); + res.json(kids); + } catch (e) { + console.error(e); + res.status(500).json({ error: "Failed to fetch kids" }); + } +}); + +router.post("/", auth, async (req, res) => { + try { + const { name, birthdate } = req.body; + const kid = await Kid.create({ name, birthdate, owner: req.user.id }); + res.status(201).json(kid); + } catch (e) { + console.error(e); + res.status(500).json({ error: "Failed to create kid" }); + } +}); + +export default router; diff --git a/backend/server.js b/backend/server.js index 070c875189..3299dc87c4 100644 --- a/backend/server.js +++ b/backend/server.js @@ -1,22 +1,62 @@ +import dotenv from "dotenv"; import express from "express"; import cors from "cors"; import mongoose from "mongoose"; +import morgan from "morgan"; +import authRouter from "./routes/auth.js"; +import "./models/User.js"; +import kidsRouter from "./routes/kids.js"; +import itemsRouter from "./routes/items.js"; +import auth from "./middlewares/auth.js"; -const mongoUrl = process.env.MONGO_URL || "mongodb://localhost/final-project"; -mongoose.connect(mongoUrl); -mongoose.Promise = Promise; -const port = process.env.PORT || 8080; + +dotenv.config(); // 1) Load backend/.env + const app = express(); +const PORT = process.env.PORT || 8081; +const MONGO_URL = process.env.MONGO_URL || "mongodb://localhost/final-project"; +//local dev site and hosted frontend +const allowed = [process.env.FRONTEND_URL, "http://localhost:5173"].filter(Boolean); +app.use(cors({ origin: allowed })); -app.use(cors()); +// 2) Middleware +//app.use(cors()); app.use(express.json()); +app.use(morgan("dev")); // handy request logs during dev + +app.use("/auth", authRouter); + +app.use("/kids", kidsRouter); + +app.use("/items", itemsRouter); + -app.get("/", (req, res) => { + +// 3) Mongo connect (with clear logs) +mongoose + .connect(MONGO_URL) + .then(() => console.log("✅ MongoDB connected")) + .catch((err) => console.error("❌ MongoDB connection error:", err.message)); + +// Helper: human-readable DB status +const dbStatus = () => { + const states = ["disconnected", "connected", "connecting", "disconnecting"]; + return states[mongoose.connection.readyState] || "unknown"; +}; + +// 4) Routes +app.get("/", (_req, res) => { res.send("Hello Technigo!"); }); -// Start the server -app.listen(port, () => { - console.log(`Server running on http://localhost:${port}`); +app.get("/health", (_req, res) => { + res.json({ ok: true, db: dbStatus() }); +}); + +app.get("/me", auth, (req, res) => res.json({ userId: req.user.id })); + +// 5) Start the server +app.listen(PORT, () => { + console.log(`🚀 Server running on http://localhost:${PORT}`); }); diff --git a/frontend/index.html b/frontend/index.html index 664410b5b9..b36bf89b95 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,13 +1,18 @@ - - - - - Technigo React Vite Boiler Plate - - -
- - - + + + + + + + LitenLeap + + + +
+ + + + \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json index 7b2747e949..332716fb06 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,7 +11,11 @@ }, "dependencies": { "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "react-hook-form": "^7.62.0", + "react-router-dom": "^7.8.1", + "recharts": "^3.1.2", + "zustand": "^5.0.8" }, "devDependencies": { "@types/react": "^18.2.15", diff --git a/frontend/public/_redirects b/frontend/public/_redirects new file mode 100644 index 0000000000..78f7f20677 --- /dev/null +++ b/frontend/public/_redirects @@ -0,0 +1 @@ +/* /index.html 200 \ No newline at end of file diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 0a24275e6e..b9195b94ed 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,8 +1,34 @@ -export const App = () => { +import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom"; +import { useAuth } from "./store/auth"; +import Login from "./pages/Login"; +import Register from "./pages/Register"; +import Dashboard from "./pages/Dashboard"; +import Kids from "./pages/Kids"; +import Items from "./pages/Items"; +import Nav from "./components/Nav"; + +const Protected = ({ children }) => { + const token = useAuth((s) => s.token); + return token ? children : ; +}; + +const App = () => { return ( - <> -

Welcome to Final Project!

- + +