diff --git a/.vscode/settings.json b/.vscode/settings.json index 459318d..2f1dcef 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -40,7 +40,7 @@ // Prettier // ============================ "[javascript]": { "editor.defaultFormatter": "vscode.typescript-language-features" }, - "[javascriptreact]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, + "[javascriptreact]": { "editor.defaultFormatter": "vscode.typescript-language-features" }, "[typescript]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, "[typescriptreact]": { "editor.defaultFormatter": "vscode.typescript-language-features" }, diff --git a/backend/app.js b/backend/app.js index 79cb762..c051ed1 100644 --- a/backend/app.js +++ b/backend/app.js @@ -3,8 +3,6 @@ const express = require("express"); const cors = require("cors"); const User = require("./models/user.model"); - - const app = express(); app.use(express.json()); diff --git a/backend/controllers/auth.controller.js b/backend/controllers/auth.controller.js index 9987f67..7f56c80 100644 --- a/backend/controllers/auth.controller.js +++ b/backend/controllers/auth.controller.js @@ -15,9 +15,10 @@ exports.register = async (req, res) => { }; exports.login = async (req, res) => { - const { email, password } = req.body; + const { username, password } = req.body; + console.log(`Login attempt for user: ${username} with password: ${password}`); - const user = await User.findByEmail(email); + const user = await User.findByUsername(username); if (!user) return res.status(401).json({ message: "Invalid credentials" }); const valid = await bcrypt.compare(password, user.password); diff --git a/backend/controllers/users.controller.js b/backend/controllers/users.controller.js index b951424..ba6a748 100644 --- a/backend/controllers/users.controller.js +++ b/backend/controllers/users.controller.js @@ -2,6 +2,7 @@ const User = require("../models/user.model"); exports.getAllUsers = async (req, res) => { const users = await User.getAllUsers(); + console.log(users); res.json(users); }; diff --git a/backend/models/user.model.js b/backend/models/user.model.js index 0267ce8..418d34b 100644 --- a/backend/models/user.model.js +++ b/backend/models/user.model.js @@ -1,7 +1,9 @@ const pool = require("../db/pool"); exports.findByUsername = async (username) => { + query = `SELECT * FROM users WHERE username = ${username}`; const result = await pool.query("SELECT * FROM users WHERE username = $1", [username]); + console.log(query); return result.rows[0]; }; @@ -24,10 +26,13 @@ exports.getAllUsers = async () => { exports.updateUserRole = async (id, role) => { const result = await pool.query( - `UPDATE users SET role = $1 WHERE id = $2 RETURNING id`, + `UPDATE users + SET role = $1 + WHERE id = $2 + RETURNING id, username, name, role`, [role, id] ); - return result.rowCount > 0; + return result.rows[0]; }; @@ -36,7 +41,7 @@ exports.deleteUser = async (id) => { `DELETE FROM users WHERE id = $1 RETURNING id`, [id] ); - return result.rowCount > 0; + return result.rowCount; }; diff --git a/backend/routes/auth.routes.js b/backend/routes/auth.routes.js index 74eaf5c..dba137e 100644 --- a/backend/routes/auth.routes.js +++ b/backend/routes/auth.routes.js @@ -3,5 +3,11 @@ const controller = require("../controllers/auth.controller"); router.post("/register", controller.register); router.post("/login", controller.login); +router.post("/", async (req, res) => { + resText = `Grocery List API is running.\n` + + `Roles available: ${Object.values(User.ROLES).join(', ')}` + + res.status(200).type("text/plain").send(resText); +}); module.exports = router; diff --git a/backend/routes/list.routes.js b/backend/routes/list.routes.js index e48a713..5e67c2a 100644 --- a/backend/routes/list.routes.js +++ b/backend/routes/list.routes.js @@ -3,7 +3,7 @@ const controller = require("../controllers/lists.controller"); const auth = require("../middleware/auth"); const requireRole = require("../middleware/rbac"); const { ROLES } = require("../models/user.model"); -const User = require("./models/user.model"); +const User = require("../models/user.model"); diff --git a/docker b/docker new file mode 100644 index 0000000..e69de29 diff --git a/docker-compose.dev.yml b/docker-compose.yml similarity index 94% rename from docker-compose.dev.yml rename to docker-compose.yml index 259cc37..de6750f 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.yml @@ -1,29 +1,29 @@ -services: - frontend: - build: - context: ./frontend - dockerfile: Dockerfile.dev - environment: - - NODE_ENV=development - volumes: - - ./frontend:/app - - /app/node_modules - ports: - - "3000:5173" - depends_on: - - backend - restart: always - - - backend: - build: - context: ./backend - command: npm run dev - volumes: - - ./backend:/app - - /app/node_modules - ports: - - "5000:5000" - env_file: - - ./backend/.env - restart: always +services: + frontend: + build: + context: ./frontend + dockerfile: Dockerfile.dev + environment: + - NODE_ENV=development + volumes: + - ./frontend:/app + - /app/node_modules + ports: + - "3000:5173" + depends_on: + - backend + restart: always + + + backend: + build: + context: ./backend + command: npm run dev + volumes: + - ./backend:/app + - /app/node_modules + ports: + - "5000:5000" + env_file: + - ./backend/.env + restart: always diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..683181c --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,40 @@ +import { BrowserRouter, Route, Routes } from "react-router-dom"; +import { ROLES } from "./constants/roles"; +import { AuthProvider } from "./context/AuthContext.jsx"; + +import AdminPanel from "./pages/AdminPanel.jsx"; +import GroceryList from "./pages/GroceryList.jsx"; +import Login from "./pages/Login.jsx"; + +import PrivateRoute from "./utils/PrivateRoute.jsx"; +import RoleGuard from "./utils/RoleGuard.jsx"; + +function App() { + return ( + + + + } /> + + + + } + /> + + + + } + /> + + + + ); +} + +export default App; \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx deleted file mode 100644 index 8404d87..0000000 --- a/frontend/src/App.tsx +++ /dev/null @@ -1,155 +0,0 @@ -import { useEffect, useState } from "react"; -import GroceryItem from "./components/GroceryItem"; -import SuggestionList from "./components/SuggestionList"; -import type { GroceryItemType } from "./types"; - - -const API_BASE_URL = import.meta.env.VITE_API_URL; - - -export default function App() { - const [items, setItems] = useState([]); - const [suggestions, setSuggestions] = useState([]); - const [itemInput, setItemInput] = useState(""); - const [quantity, setQuantity] = useState(1); - - - // Load grocery list - const loadList = async () => { - const res = await fetch(`${API_BASE_URL}/list`); - const data: GroceryItemType[] = await res.json(); - setItems(data); - }; - - - useEffect(() => { - loadList(); - }, []); - - - // Suggestion logic - const handleSuggest = async (query: string) => { - setItemInput(query); - - if (!query.trim()) return setSuggestions([]); - - const res = await fetch(`${API_BASE_URL}/suggest?query=${encodeURIComponent(query)}`); - const data: string[] = await res.json(); - setSuggestions(data); - }; - - - // Add Item - const addItem = async () => { - if (!itemInput.trim() || !quantity) return; - - const existing = items.find((i) => i.item_name.toLowerCase() === itemInput.toLowerCase()); - - if (existing) { - if (!confirm("Item already exists. Add more?")) return; - - await fetch(`${API_BASE_URL}/add`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - item_name: itemInput, - quantity: existing.quantity + quantity, - }), - }); - } else { - await fetch(`${API_BASE_URL}/add`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ item_name: itemInput, quantity }), - }); - } - - setItemInput(""); - setQuantity(1); - setSuggestions([]); - loadList(); - }; - - - // Mark bought - const markBought = async (id: number) => { - if (!confirm("Mark this item as bought?")) return; - - await fetch(`${API_BASE_URL}/mark-bought`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ id }), - }); - - loadList(); - }; - - - - return ( -
-

- Costco Grocery List -

- - handleSuggest(e.target.value)} - style={inputStyle} - autoComplete="off" - /> - - { - setItemInput(val); - setSuggestions([]); - }} - /> - - setQuantity(Number(e.target.value))} - placeholder="Quantity" - style={inputStyle} - /> - - - -
    - {items.map((item) => ( - - ))} -
-
- ); -} - -const inputStyle: React.CSSProperties = { - fontSize: "1em", - margin: "0.3em 0", - padding: "0.5em", - width: "100%", - boxSizing: "border-box", -}; - -const buttonStyle = { - ...inputStyle, - cursor: "pointer", -}; diff --git a/frontend/src/api/axios.js b/frontend/src/api/axios.js index 7153c4e..1a74ee0 100644 --- a/frontend/src/api/axios.js +++ b/frontend/src/api/axios.js @@ -1,7 +1,8 @@ import axios from "axios"; +import { API_BASE_URL } from "../config"; const api = axios.create({ - baseURL: process.env.VITE_API_URL || "http://localhost:5000", + baseURL: API_BASE_URL, headers: { "Content-Type": "application/json", }, diff --git a/frontend/src/api/list.js b/frontend/src/api/list.js index 0997c2a..7595d38 100644 --- a/frontend/src/api/list.js +++ b/frontend/src/api/list.js @@ -1,6 +1,5 @@ import api from "./axios"; -export const getList = () => api.get("/list"); -export cosnt addItem = (itemName, quantiy) => - api.post("/list/add", { itemName, quantiy }); +export const getList = () => api.get("/list"); +export const addItem = (itemName, quantiy) => api.post("/list/add", { itemName, quantiy }); export const markBought = (id) => api.post("/list/mark-bought", { id }); \ No newline at end of file diff --git a/frontend/src/components/Navbar.jsx b/frontend/src/components/Navbar.jsx index e69de29..95591dd 100644 --- a/frontend/src/components/Navbar.jsx +++ b/frontend/src/components/Navbar.jsx @@ -0,0 +1,16 @@ +import { useContext } from "react"; +import { Link } from "react-router-dom"; +import { ROLES } from "../constants/roles"; +import { AuthContext } from "../context/AuthContext"; + +export default function Navbar() { + const { role, logout } = useContext(AuthContext); + + return ( + + ) +} \ No newline at end of file diff --git a/frontend/src/config.ts b/frontend/src/config.ts index deb7f5b..0aa5d12 100644 --- a/frontend/src/config.ts +++ b/frontend/src/config.ts @@ -1 +1 @@ -export const API_BASE_URL = import.meta.env.VITE_API_URL || "/api"; \ No newline at end of file +export const API_BASE_URL = import.meta.env.VITE_API_URL || "http://localhost:5000"; \ No newline at end of file diff --git a/frontend/src/constants/roles.js b/frontend/src/constants/roles.js new file mode 100644 index 0000000..fc1b90b --- /dev/null +++ b/frontend/src/constants/roles.js @@ -0,0 +1,5 @@ +export const ROLES = { + VIEWER: "viewer", + EDITOR: "editor", + ADMIN: "admin", +}; \ No newline at end of file diff --git a/frontend/src/context/AuthContext.jsx b/frontend/src/context/AuthContext.jsx index f428041..3d5ad97 100644 --- a/frontend/src/context/AuthContext.jsx +++ b/frontend/src/context/AuthContext.jsx @@ -1,7 +1,12 @@ import { createContext, useState } from 'react'; -import { ROLES } from '../../../backend/models/user.model'; -export const authContext = createContext(); +export const AuthContext = createContext({ + token: null, + role: null, + username: null, + login: () => { }, + logout: () => { }, +}); export const AuthProvider = ({ children }) => { const [token, setToken] = useState(localStorage.getItem('token') || null); @@ -19,14 +24,23 @@ export const AuthProvider = ({ children }) => { const logout = () => { localStorage.clear(); + setToken(null); setRole(null); setUsername(null); }; + const value = { + token, + role, + username, + login, + logout + }; + return ( - + {children} - + ); }; \ No newline at end of file diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index bef5202..d172030 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,7 +1,7 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' +import App from './App.jsx' import './index.css' -import App from './App.tsx' createRoot(document.getElementById('root')!).render( diff --git a/frontend/src/pages/AdminPanel.jsx b/frontend/src/pages/AdminPanel.jsx index e69de29..97024ca 100644 --- a/frontend/src/pages/AdminPanel.jsx +++ b/frontend/src/pages/AdminPanel.jsx @@ -0,0 +1,37 @@ +import { useEffect, useState } from "react"; +import { getAllUsers, updateRole } from "../api/users"; +import { ROLES } from "../constants/roles"; + +export default function AdminPanel() { + const [users, setUsers] = useState([]); + + useEffect(() => { + async function load() { + const allUsers = await getAllUsers(); + setUsers(allUsers); + } + }, []); + + const changeRole = async (id, role) => { + const updated = await updateRole(id, role); + setUsers(users.map(u => (u.id === id ? updated.data : u))) + } + + return ( +
+

Admin Panel

+ + {users.map((user) => ( +
+ {user.username} - {user.role} + +
+ )) + } +
+ ) +} \ No newline at end of file diff --git a/frontend/src/pages/GroceryList.jsx b/frontend/src/pages/GroceryList.jsx index e69de29..2cee0c5 100644 --- a/frontend/src/pages/GroceryList.jsx +++ b/frontend/src/pages/GroceryList.jsx @@ -0,0 +1,103 @@ +import { useContext, useEffect, useState } from "react"; +import { addItem, getList, markBought } from "../api/list"; +import { AuthContext } from "../context/AuthContext"; + +export default function GroceryList() { + const { role, username } = useContext(AuthContext); + + const [items, setItems] = useState([]); + const [itemName, setItemName] = useState(""); + const [quantity, setQuantity] = useState(1); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + + // Load list + const loadItems = async () => { + try { + setLoading(true); + const response = await getList(); + setItems(response.data); + setLoading(false); + } catch (err) { + setError("Failed to load grocery list"); + setLoading(false); + } + }; + + useEffect(() => { + loadItems(); + }, []); + + // Add item (editor/admin) + const handleAdd = async (e) => { + e.preventDefault(); + try { + await addItem(itemName, quantity); + setItemName(""); + setQuantity(1); + loadItems(); + } catch (err) { + console.log(err); + setError("Failed to add item"); + } + }; + + // Mark bought (editor/admin) + const handleBought = async (id) => { + try { + await markBought(id); + loadItems(); + } catch (err) { + setError("Failed to mark item as bought"); + } + }; + + if (loading) return

Loading...

; + if (error) return

{error}

; + + return ( +
+

Grocery List

+

Logged in as: {username} ({role})

+ + {/* Add Item Section (editor/admin only) */} + {(role === "editor" || role === "admin") && ( +
+ setItemName(e.target.value)} + required + /> + setQuantity(e.target.value)} + style={{ width: "70px", marginLeft: "10px" }} + /> + +
+ )} + + {/* Grocery List */} +
    + {items.map((item) => ( +
  • + {item.item_name} — {item.quantity} + + {(role === "editor" || role === "admin") && ( + + )} +
  • + ))} +
+
+ ); +} diff --git a/frontend/src/pages/Login.jsx b/frontend/src/pages/Login.jsx index e69de29..e8d1d2d 100644 --- a/frontend/src/pages/Login.jsx +++ b/frontend/src/pages/Login.jsx @@ -0,0 +1,44 @@ +import { useContext, useState } from "react"; +import { loginRequest } from "../api/auth"; +import { AuthContext } from "../context/AuthContext"; + +export default function Login() { + const { login } = useContext(AuthContext); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + + const submit = async (e) => { + e.preventDefault(); + setError(""); + try { + const data = await loginRequest(username, password); + login(data); + window.location.href = "/"; + } catch (err) { + setError("Invalid username or password"); + } + }; + + + return ( +
+

Login

+ {error &&

{error}

} + +
+ setUsername(e.target.value)} + /> + setPassword(e.target.value)} + /> + +
+
+ ) +} \ No newline at end of file diff --git a/frontend/src/utils/PrivateRoute.jsx b/frontend/src/utils/PrivateRoute.jsx index e69de29..c5091a5 100644 --- a/frontend/src/utils/PrivateRoute.jsx +++ b/frontend/src/utils/PrivateRoute.jsx @@ -0,0 +1,8 @@ +import { useContext } from "react"; +import { Navigate } from "react-router-dom"; +import { AuthContext } from "../context/AuthContext"; + +export default function PrivateRoute({ children }) { + const { token } = useContext(AuthContext); + return token ? children : ; +} \ No newline at end of file diff --git a/frontend/src/utils/RoleGuard.jsx b/frontend/src/utils/RoleGuard.jsx index e69de29..1ab2f3f 100644 --- a/frontend/src/utils/RoleGuard.jsx +++ b/frontend/src/utils/RoleGuard.jsx @@ -0,0 +1,24 @@ +import { useContext } from "react"; +import { Navigate } from "react-router-dom"; +import { AuthContext } from "../context/AuthContext"; + +export default function RoleGuard({ allowed, children }) { + const { role } = useContext(AuthContext); + + if (!role) return ; + if (!allowed.includes(role)) return ; + + return children; +} + + +function usageExample() { + + + + } + /> +} \ No newline at end of file