wip
This commit is contained in:
parent
4d8c3cb2e4
commit
ffbaa2a8e0
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@ -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" },
|
||||
|
||||
|
||||
@ -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());
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
};
|
||||
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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");
|
||||
|
||||
|
||||
|
||||
|
||||
40
frontend/src/App.jsx
Normal file
40
frontend/src/App.jsx
Normal 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;
|
||||
@ -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",
|
||||
};
|
||||
@ -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",
|
||||
},
|
||||
|
||||
@ -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 addItem = (itemName, quantiy) => api.post("/list/add", { itemName, quantiy });
|
||||
export const markBought = (id) => api.post("/list/mark-bought", { id });
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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";
|
||||
5
frontend/src/constants/roles.js
Normal file
5
frontend/src/constants/roles.js
Normal file
@ -0,0 +1,5 @@
|
||||
export const ROLES = {
|
||||
VIEWER: "viewer",
|
||||
EDITOR: "editor",
|
||||
ADMIN: "admin",
|
||||
};
|
||||
@ -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 (
|
||||
<authContext.Provider value={{ token, role, username, login, logout, ROLES }}>
|
||||
<AuthContext.Provider value={value}>
|
||||
{children}
|
||||
</authContext.Provider>
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
@ -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(
|
||||
<StrictMode>
|
||||
|
||||
@ -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 >
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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" />;
|
||||
}
|
||||
@ -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>
|
||||
}
|
||||
/>
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user