This commit is contained in:
Nico 2025-11-22 00:42:15 -08:00
parent 4d8c3cb2e4
commit ffbaa2a8e0
23 changed files with 350 additions and 203 deletions

View File

@ -40,7 +40,7 @@
// Prettier // Prettier
// ============================ // ============================
"[javascript]": { "editor.defaultFormatter": "vscode.typescript-language-features" }, "[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" }, "[typescript]": { "editor.defaultFormatter": "esbenp.prettier-vscode" },
"[typescriptreact]": { "editor.defaultFormatter": "vscode.typescript-language-features" }, "[typescriptreact]": { "editor.defaultFormatter": "vscode.typescript-language-features" },

View File

@ -3,8 +3,6 @@ const express = require("express");
const cors = require("cors"); const cors = require("cors");
const User = require("./models/user.model"); const User = require("./models/user.model");
const app = express(); const app = express();
app.use(express.json()); app.use(express.json());

View File

@ -15,9 +15,10 @@ exports.register = async (req, res) => {
}; };
exports.login = 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" }); if (!user) return res.status(401).json({ message: "Invalid credentials" });
const valid = await bcrypt.compare(password, user.password); const valid = await bcrypt.compare(password, user.password);

View File

@ -2,6 +2,7 @@ const User = require("../models/user.model");
exports.getAllUsers = async (req, res) => { exports.getAllUsers = async (req, res) => {
const users = await User.getAllUsers(); const users = await User.getAllUsers();
console.log(users);
res.json(users); res.json(users);
}; };

View File

@ -1,7 +1,9 @@
const pool = require("../db/pool"); const pool = require("../db/pool");
exports.findByUsername = async (username) => { exports.findByUsername = async (username) => {
query = `SELECT * FROM users WHERE username = ${username}`;
const result = await pool.query("SELECT * FROM users WHERE username = $1", [username]); const result = await pool.query("SELECT * FROM users WHERE username = $1", [username]);
console.log(query);
return result.rows[0]; return result.rows[0];
}; };
@ -24,10 +26,13 @@ exports.getAllUsers = async () => {
exports.updateUserRole = async (id, role) => { exports.updateUserRole = async (id, role) => {
const result = await pool.query( 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] [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`, `DELETE FROM users WHERE id = $1 RETURNING id`,
[id] [id]
); );
return result.rowCount > 0; return result.rowCount;
}; };

View File

@ -3,5 +3,11 @@ const controller = require("../controllers/auth.controller");
router.post("/register", controller.register); router.post("/register", controller.register);
router.post("/login", controller.login); 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; module.exports = router;

View File

@ -3,7 +3,7 @@ const controller = require("../controllers/lists.controller");
const auth = require("../middleware/auth"); const auth = require("../middleware/auth");
const requireRole = require("../middleware/rbac"); const requireRole = require("../middleware/rbac");
const { ROLES } = require("../models/user.model"); const { ROLES } = require("../models/user.model");
const User = require("./models/user.model"); const User = require("../models/user.model");

0
docker Normal file
View File

View File

@ -1,29 +1,29 @@
services: services:
frontend: frontend:
build: build:
context: ./frontend context: ./frontend
dockerfile: Dockerfile.dev dockerfile: Dockerfile.dev
environment: environment:
- NODE_ENV=development - NODE_ENV=development
volumes: volumes:
- ./frontend:/app - ./frontend:/app
- /app/node_modules - /app/node_modules
ports: ports:
- "3000:5173" - "3000:5173"
depends_on: depends_on:
- backend - backend
restart: always restart: always
backend: backend:
build: build:
context: ./backend context: ./backend
command: npm run dev command: npm run dev
volumes: volumes:
- ./backend:/app - ./backend:/app
- /app/node_modules - /app/node_modules
ports: ports:
- "5000:5000" - "5000:5000"
env_file: env_file:
- ./backend/.env - ./backend/.env
restart: always restart: always

40
frontend/src/App.jsx Normal file
View File

@ -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 (
<AuthProvider>
<BrowserRouter>
<Routes>
<Route path="/login" element={<Login />} />
<Route
path="/"
element={
<PrivateRoute>
<GroceryList />
</PrivateRoute>
}
/>
<Route
path="/admin"
element={
<RoleGuard allowed={[ROLES.ADMIN]}>
<AdminPanel />
</RoleGuard>
}
/>
</Routes>
</BrowserRouter>
</AuthProvider>
);
}
export default App;

View File

@ -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<GroceryItemType[]>([]);
const [suggestions, setSuggestions] = useState<string[]>([]);
const [itemInput, setItemInput] = useState<string>("");
const [quantity, setQuantity] = useState<number>(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 (
<div
className="container"
style={{
maxWidth: "480px",
margin: "auto",
background: "white",
padding: "1em",
borderRadius: "8px",
boxShadow: "0 0 10px rgba(0,0,0,0.1)",
}}
>
<h1 style={{ textAlign: "center", fontSize: "1.5em" }}>
Costco Grocery List
</h1>
<input
type="text"
value={itemInput}
placeholder="Item name"
onChange={(e) => handleSuggest(e.target.value)}
style={inputStyle}
autoComplete="off"
/>
<SuggestionList
suggestions={suggestions}
onSelect={(val: string) => {
setItemInput(val);
setSuggestions([]);
}}
/>
<input
type="number"
value={quantity}
min={1}
onChange={(e) => setQuantity(Number(e.target.value))}
placeholder="Quantity"
style={inputStyle}
/>
<button onClick={addItem} style={buttonStyle}>
Add Item
</button>
<ul style={{ listStyle: "none", padding: 0 }}>
{items.map((item) => (
<GroceryItem key={item.id} item={item} onClick={markBought} />
))}
</ul>
</div>
);
}
const inputStyle: React.CSSProperties = {
fontSize: "1em",
margin: "0.3em 0",
padding: "0.5em",
width: "100%",
boxSizing: "border-box",
};
const buttonStyle = {
...inputStyle,
cursor: "pointer",
};

View File

@ -1,7 +1,8 @@
import axios from "axios"; import axios from "axios";
import { API_BASE_URL } from "../config";
const api = axios.create({ const api = axios.create({
baseURL: process.env.VITE_API_URL || "http://localhost:5000", baseURL: API_BASE_URL,
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },

View File

@ -1,6 +1,5 @@
import api from "./axios"; import api from "./axios";
export const getList = () => api.get("/list"); export const getList = () => api.get("/list");
export cosnt addItem = (itemName, quantiy) => export const addItem = (itemName, quantiy) => api.post("/list/add", { itemName, quantiy });
api.post("/list/add", { itemName, quantiy });
export const markBought = (id) => api.post("/list/mark-bought", { id }); export const markBought = (id) => api.post("/list/mark-bought", { id });

View File

@ -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 (
<nav>
<Link to="/">List</Link>
{role === ROLES.ADMIN && <Link to="/admin">Admin Panel</Link>}
<button onClick={logout}>Logout</button>
</nav>
)
}

View File

@ -1 +1 @@
export const API_BASE_URL = import.meta.env.VITE_API_URL || "/api"; export const API_BASE_URL = import.meta.env.VITE_API_URL || "http://localhost:5000";

View File

@ -0,0 +1,5 @@
export const ROLES = {
VIEWER: "viewer",
EDITOR: "editor",
ADMIN: "admin",
};

View File

@ -1,7 +1,12 @@
import { createContext, useState } from 'react'; 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 }) => { export const AuthProvider = ({ children }) => {
const [token, setToken] = useState(localStorage.getItem('token') || null); const [token, setToken] = useState(localStorage.getItem('token') || null);
@ -19,14 +24,23 @@ export const AuthProvider = ({ children }) => {
const logout = () => { const logout = () => {
localStorage.clear(); localStorage.clear();
setToken(null); setToken(null);
setRole(null); setRole(null);
setUsername(null); setUsername(null);
}; };
const value = {
token,
role,
username,
login,
logout
};
return ( return (
<authContext.Provider value={{ token, role, username, login, logout, ROLES }}> <AuthContext.Provider value={value}>
{children} {children}
</authContext.Provider> </AuthContext.Provider>
); );
}; };

View File

@ -1,7 +1,7 @@
import { StrictMode } from 'react' import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import App from './App.jsx'
import './index.css' import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>

View File

@ -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 (
<div>
<h1>Admin Panel</h1>
{users.map((user) => (
<div key={user.id}>
<strong>{user.username}</strong> - {user.role}
<select onChange={(e) => changeRole(user.id, e.target.value)} value={user.role}>
<option value={ROLES.VIEWER}>Viewer</option>
<option value={ROLES.EDITOR}>Editor</option>
<option value={ROLES.ADMIN}>Admin</option>
</select>
</div>
))
}
</div >
)
}

View File

@ -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 <p>Loading...</p>;
if (error) return <p style={{ color: "red" }}>{error}</p>;
return (
<div style={{ padding: "20px" }}>
<h1>Grocery List</h1>
<p>Logged in as: <strong>{username}</strong> ({role})</p>
{/* Add Item Section (editor/admin only) */}
{(role === "editor" || role === "admin") && (
<form onSubmit={handleAdd} style={{ margin: "20px 0" }}>
<input
type="text"
placeholder="Item name"
value={itemName}
onChange={(e) => setItemName(e.target.value)}
required
/>
<input
type="number"
min="1"
value={quantity}
onChange={(e) => setQuantity(e.target.value)}
style={{ width: "70px", marginLeft: "10px" }}
/>
<button style={{ marginLeft: "10px" }}>Add</button>
</form>
)}
{/* Grocery List */}
<ul>
{items.map((item) => (
<li key={item.id} style={{ margin: "10px 0" }}>
{item.item_name} {item.quantity}
{(role === "editor" || role === "admin") && (
<button
onClick={() => handleBought(item.id)}
style={{ marginLeft: "15px" }}
>
Mark Bought
</button>
)}
</li>
))}
</ul>
</div>
);
}

View File

@ -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 (
<div>
<h1>Login</h1>
{error && <p style={{ color: "red" }}>{error}</p>}
<form onSubmit={submit}>
<input
type="text"
placeholder="Username"
onChange={(e) => setUsername(e.target.value)}
/>
<input
type="password"
placeholder="Password"
onChange={(e) => setPassword(e.target.value)}
/>
<button type="submit">Login</button>
</form>
</div>
)
}

View File

@ -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 : <Navigate to="/login" />;
}

View File

@ -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 <Navigate to="/login" />;
if (!allowed.includes(role)) return <Navigate to="/" />;
return children;
}
function usageExample() {
<Route
path="/admin"
element={
<RoleGuard allowed={[ROLES.ADMIN]}>
<AdminPanel />
</RoleGuard>
}
/>
}