binary change?
Some checks failed
Build & Deploy Costco Grocery List / build (push) Failing after 11s
Build & Deploy Costco Grocery List / deploy (push) Has been skipped

This commit is contained in:
Nico 2025-11-26 14:12:32 -08:00
parent 2563c1be5c
commit 3ad4b62991
30 changed files with 1177 additions and 1177 deletions

View File

@ -6,7 +6,7 @@ on:
env: env:
IMAGE_NAME: costco-grocery-list IMAGE_NAME: costco-grocery-list
REGISTRY: gitea.nicosaya.com REGISTRY: git.nicosaya.com
jobs: jobs:
build: build:

128
.vscode/settings.json vendored
View File

@ -1,65 +1,65 @@
{ {
// ============================ // ============================
// Editor basics // Editor basics
// ============================ // ============================
"editor.tabSize": 2, "editor.tabSize": 2,
"editor.insertSpaces": true, "editor.insertSpaces": true,
"editor.formatOnSave": true, "editor.formatOnSave": true,
"editor.codeActionsOnSave": { "editor.codeActionsOnSave": {
"source.fixAll": "explicit", "source.fixAll": "explicit",
"source.organizeImports": "explicit" "source.organizeImports": "explicit"
}, },
"editor.defaultFormatter": "esbenp.prettier-vscode", "editor.defaultFormatter": "esbenp.prettier-vscode",
// ============================ // ============================
// JS / TS behavior // JS / TS behavior
// ============================ // ============================
"javascript.updateImportsOnFileMove.enabled": "always", "javascript.updateImportsOnFileMove.enabled": "always",
"typescript.updateImportsOnFileMove.enabled": "always", "typescript.updateImportsOnFileMove.enabled": "always",
"javascript.suggest.autoImports": true, "javascript.suggest.autoImports": true,
"typescript.suggest.autoImports": true, "typescript.suggest.autoImports": true,
// Fix annoying "JSX not allowed" issues in js files // Fix annoying "JSX not allowed" issues in js files
"javascript.validate.enable": false, "javascript.validate.enable": false,
// ============================ // ============================
// React specific // React specific
// ============================ // ============================
"emmet.includeLanguages": { "emmet.includeLanguages": {
"javascript": "javascriptreact", "javascript": "javascriptreact",
"typescript": "typescriptreact" "typescript": "typescriptreact"
}, },
"emmet.triggerExpansionOnTab": true, "emmet.triggerExpansionOnTab": true,
// ============================ // ============================
// Terminal // Terminal
// ============================ // ============================
"terminal.integrated.defaultProfile.windows": "Git Bash", "terminal.integrated.defaultProfile.windows": "Git Bash",
// ============================ // ============================
// Prettier // Prettier
// ============================ // ============================
"[javascript]": { "editor.defaultFormatter": "vscode.typescript-language-features" }, "[javascript]": { "editor.defaultFormatter": "vscode.typescript-language-features" },
"[javascriptreact]": { "editor.defaultFormatter": "vscode.typescript-language-features" }, "[javascriptreact]": { "editor.defaultFormatter": "vscode.typescript-language-features" },
"[typescript]": { "editor.defaultFormatter": "vscode.typescript-language-features" }, "[typescript]": { "editor.defaultFormatter": "vscode.typescript-language-features" },
"[typescriptreact]": { "editor.defaultFormatter": "vscode.typescript-language-features" }, "[typescriptreact]": { "editor.defaultFormatter": "vscode.typescript-language-features" },
// ============================ // ============================
// File Icons // File Icons
// ============================ // ============================
"workbench.iconTheme": "material-icon-theme", "workbench.iconTheme": "material-icon-theme",
"material-icon-theme.folders.associations": { "material-icon-theme.folders.associations": {
"gitea/workflows": "repository", "gitea/workflows": "repository",
}, },
// ============================ // ============================
// Explorer cleanup // Explorer cleanup
// ============================ // ============================
"files.exclude": { "files.exclude": {
"**/.git": true, "**/.git": true,
"**/.DS_Store": true, "**/.DS_Store": true,
"**/node_modules": false, "**/node_modules": false,
"**/*.log": true, "**/*.log": true,
"**/dist": true "**/dist": true
} }
} }

View File

@ -1,43 +1,43 @@
const express = require("express"); 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());
const allowedOrigins = process.env.ALLOWED_ORIGINS.split(",").map(origin => origin.trim()); const allowedOrigins = process.env.ALLOWED_ORIGINS.split(",").map(origin => origin.trim());
console.log("Allowed Origins:", allowedOrigins); console.log("Allowed Origins:", allowedOrigins);
app.use( app.use(
cors({ cors({
origin: function (origin, callback) { origin: function (origin, callback) {
if (!origin) return callback(null, true); if (!origin) return callback(null, true);
if (allowedOrigins.includes(origin)) return callback(null, true); if (allowedOrigins.includes(origin)) return callback(null, true);
if (/^http:\/\/192\.168\.\d+\.\d+/.test(origin)) return callback(null, true); if (/^http:\/\/192\.168\.\d+\.\d+/.test(origin)) return callback(null, true);
if (/^https:\/\/192\.168\.\d+\.\d+/.test(origin)) return callback(null, true); if (/^https:\/\/192\.168\.\d+\.\d+/.test(origin)) return callback(null, true);
callback(new Error("Not allowed by CORS")); callback(new Error("Not allowed by CORS"));
}, },
methods: ["GET", "POST", "PUT", "DELETE"], methods: ["GET", "POST", "PUT", "DELETE"],
}) })
); );
app.get('/', async (req, res) => { app.get('/', async (req, res) => {
resText = `Grocery List API is running.\n` + resText = `Grocery List API is running.\n` +
`Roles available: ${Object.values(User.ROLES).join(', ')}` `Roles available: ${Object.values(User.ROLES).join(', ')}`
res.status(200).type("text/plain").send(resText); res.status(200).type("text/plain").send(resText);
}); });
const authRoutes = require("./routes/auth.routes"); const authRoutes = require("./routes/auth.routes");
app.use("/auth", authRoutes); app.use("/auth", authRoutes);
const listRoutes = require("./routes/list.routes"); const listRoutes = require("./routes/list.routes");
app.use("/list", listRoutes); app.use("/list", listRoutes);
const adminRoutes = require("./routes/admin.routes"); const adminRoutes = require("./routes/admin.routes");
app.use("/admin", adminRoutes); app.use("/admin", adminRoutes);
const usersRoutes = require("./routes/users.routes"); const usersRoutes = require("./routes/users.routes");
app.use("/users", usersRoutes); app.use("/users", usersRoutes);
module.exports = app; module.exports = app;

View File

@ -1,44 +1,44 @@
const bcrypt = require("bcryptjs"); const bcrypt = require("bcryptjs");
const jwt = require("jsonwebtoken"); const jwt = require("jsonwebtoken");
const User = require("../models/user.model"); const User = require("../models/user.model");
exports.register = async (req, res) => { exports.register = async (req, res) => {
let { username, password, name } = req.body; let { username, password, name } = req.body;
username = username.toLowerCase(); username = username.toLowerCase();
console.log(`🆕 Registration attempt for ${name} => username:${username}, password:${password}`); console.log(`🆕 Registration attempt for ${name} => username:${username}, password:${password}`);
try { try {
const hash = await bcrypt.hash(password, 10); const hash = await bcrypt.hash(password, 10);
const user = await User.createUser(username, hash, name); const user = await User.createUser(username, hash, name);
console.log(`✅ User registered: ${username}`); console.log(`✅ User registered: ${username}`);
res.json({ message: "User registered", user }); res.json({ message: "User registered", user });
} catch (err) { } catch (err) {
res.status(400).json({ message: "Registration failed", error: err }); res.status(400).json({ message: "Registration failed", error: err });
} }
}; };
exports.login = async (req, res) => { exports.login = async (req, res) => {
let { username, password } = req.body; let { username, password } = req.body;
username = username.toLowerCase(); username = username.toLowerCase();
const user = await User.findByUsername(username); const user = await User.findByUsername(username);
if (!user) { if (!user) {
console.log(`⚠️ Login attempt -> No user found: ${username}`); console.log(`⚠️ Login attempt -> No user found: ${username}`);
return res.status(401).json({ message: "User not found" }); return res.status(401).json({ message: "User not found" });
} }
const valid = await bcrypt.compare(password, user.password); const valid = await bcrypt.compare(password, user.password);
if (!valid) { if (!valid) {
console.log(`⛔ Login attempt for user ${username} with password ${password}`); console.log(`⛔ Login attempt for user ${username} with password ${password}`);
return res.status(401).json({ message: "Invalid credentials" }); return res.status(401).json({ message: "Invalid credentials" });
} }
const token = jwt.sign( const token = jwt.sign(
{ id: user.id, role: user.role }, { id: user.id, role: user.role },
process.env.JWT_SECRET, process.env.JWT_SECRET,
{ expiresIn: "1d" } { expiresIn: "1d" }
); );
res.json({ token, username, role: user.role }); res.json({ token, username, role: user.role });
}; };

View File

@ -1,37 +1,37 @@
const List = require("../models/list.model"); const List = require("../models/list.model");
exports.getList = async (req, res) => { exports.getList = async (req, res) => {
const items = await List.getUnboughtItems(); const items = await List.getUnboughtItems();
res.json(items); res.json(items);
}; };
exports.getItemByName = async (req, res) => { exports.getItemByName = async (req, res) => {
const { itemName } = req.query; const { itemName } = req.query;
const item = await List.getItemByName(itemName); const item = await List.getItemByName(itemName);
res.json(item); res.json(item);
} }
exports.addItem = async (req, res) => { exports.addItem = async (req, res) => {
const { itemName, quantity } = req.body; const { itemName, quantity } = req.body;
const id = await List.addOrUpdateItem(itemName, quantity); const id = await List.addOrUpdateItem(itemName, quantity);
await List.addHistoryRecord(id, quantity); await List.addHistoryRecord(id, quantity);
res.json({ message: "Item added/updated" }); res.json({ message: "Item added/updated" });
}; };
exports.markBought = async (req, res) => { exports.markBought = async (req, res) => {
await List.setBought(req.body.id); await List.setBought(req.body.id);
res.json({ message: "Item marked bought" }); res.json({ message: "Item marked bought" });
}; };
exports.getSuggestions = async (req, res) => { exports.getSuggestions = async (req, res) => {
const { query } = req.query || ""; const { query } = req.query || "";
const suggestions = await List.getSuggestions(query); const suggestions = await List.getSuggestions(query);
res.json(suggestions); res.json(suggestions);
}; };

View File

@ -1,55 +1,55 @@
const User = require("../models/user.model"); const User = require("../models/user.model");
exports.test = async (req, res) => { exports.test = async (req, res) => {
console.log("User route is working"); console.log("User route is working");
res.json({ message: "User route is working" }); res.json({ message: "User route is working" });
}; };
exports.getAllUsers = async (req, res) => { exports.getAllUsers = async (req, res) => {
console.log(req); console.log(req);
const users = await User.getAllUsers(); const users = await User.getAllUsers();
res.json(users); res.json(users);
}; };
exports.updateUserRole = async (req, res) => { exports.updateUserRole = async (req, res) => {
try { try {
const { id, role } = req.body; const { id, role } = req.body;
console.log(`Updating user ${id} to role ${role}`); console.log(`Updating user ${id} to role ${role}`);
if (!Object.values(User.ROLES).includes(role)) if (!Object.values(User.ROLES).includes(role))
return res.status(400).json({ error: "Invalid role" }); return res.status(400).json({ error: "Invalid role" });
const updated = await User.updateUserRole(id, role); const updated = await User.updateUserRole(id, role);
if (!updated) if (!updated)
return res.status(404).json({ error: "User not found" }); return res.status(404).json({ error: "User not found" });
res.json({ message: "Role updated", id, role }); res.json({ message: "Role updated", id, role });
} catch (err) { } catch (err) {
res.status(500).json({ error: "Failed to update role" }); res.status(500).json({ error: "Failed to update role" });
} }
}; };
exports.deleteUser = async (req, res) => { exports.deleteUser = async (req, res) => {
try { try {
const { id } = req.params; const { id } = req.params;
const deleted = await User.deleteUser(id); const deleted = await User.deleteUser(id);
if (!deleted) if (!deleted)
return res.status(404).json({ error: "User not found" }); return res.status(404).json({ error: "User not found" });
res.json({ message: "User deleted", id }); res.json({ message: "User deleted", id });
} catch (err) { } catch (err) {
res.status(500).json({ error: "Failed to delete user" }); res.status(500).json({ error: "Failed to delete user" });
} }
}; };
exports.checkIfUserExists = async (req, res) => { exports.checkIfUserExists = async (req, res) => {
const { username } = req.query; const { username } = req.query;
const users = await User.checkIfUserExists(username); const users = await User.checkIfUserExists(username);
res.json(users); res.json(users);
}; };

View File

@ -1,73 +1,73 @@
const pool = require("../db/pool"); const pool = require("../db/pool");
exports.getUnboughtItems = async () => { exports.getUnboughtItems = async () => {
const result = await pool.query( const result = await pool.query(
"SELECT * FROM grocery_list WHERE bought = FALSE ORDER BY id ASC" "SELECT * FROM grocery_list WHERE bought = FALSE ORDER BY id ASC"
); );
return result.rows; return result.rows;
}; };
exports.getItemByName = async (itemName) => { exports.getItemByName = async (itemName) => {
const result = await pool.query( const result = await pool.query(
"SELECT * FROM grocery_list WHERE item_name ILIKE $1", "SELECT * FROM grocery_list WHERE item_name ILIKE $1",
[itemName] [itemName]
); );
return result.rows[0] || null; return result.rows[0] || null;
}; };
exports.addOrUpdateItem = async (itemName, quantity) => { exports.addOrUpdateItem = async (itemName, quantity) => {
const result = await pool.query( const result = await pool.query(
"SELECT id, bought FROM grocery_list WHERE item_name ILIKE $1", "SELECT id, bought FROM grocery_list WHERE item_name ILIKE $1",
[itemName] [itemName]
); );
if (result.rowCount > 0) { if (result.rowCount > 0) {
await pool.query( await pool.query(
`UPDATE grocery_list `UPDATE grocery_list
SET quantity = $1, SET quantity = $1,
bought = FALSE bought = FALSE
WHERE id = $2`, WHERE id = $2`,
[quantity, result.rows[0].id] [quantity, result.rows[0].id]
); );
return result.rows[0].id; return result.rows[0].id;
} else { } else {
const insert = await pool.query( const insert = await pool.query(
`INSERT INTO grocery_list `INSERT INTO grocery_list
(item_name, quantity) (item_name, quantity)
VALUES ($1, $2) RETURNING id`, VALUES ($1, $2) RETURNING id`,
[itemName, quantity] [itemName, quantity]
); );
return insert.rows[0].id; return insert.rows[0].id;
} }
}; };
exports.setBought = async (id) => { exports.setBought = async (id) => {
await pool.query("UPDATE grocery_list SET bought = TRUE WHERE id = $1", [id]); await pool.query("UPDATE grocery_list SET bought = TRUE WHERE id = $1", [id]);
}; };
exports.addHistoryRecord = async (itemId, quantity) => { exports.addHistoryRecord = async (itemId, quantity) => {
await pool.query( await pool.query(
`INSERT INTO grocery_history (list_item_id, quantity, added_on) `INSERT INTO grocery_history (list_item_id, quantity, added_on)
VALUES ($1, $2, NOW())`, VALUES ($1, $2, NOW())`,
[itemId, quantity] [itemId, quantity]
); );
}; };
exports.getSuggestions = async (query) => { exports.getSuggestions = async (query) => {
const result = await pool.query( const result = await pool.query(
`SELECT DISTINCT item_name `SELECT DISTINCT item_name
FROM grocery_list FROM grocery_list
WHERE item_name ILIKE $1 WHERE item_name ILIKE $1
LIMIT 10`, LIMIT 10`,
[`%${query}%`] [`%${query}%`]
); );
res = result.rows; res = result.rows;
return result.rows; return result.rows;
}; };

View File

@ -1,61 +1,61 @@
const pool = require("../db/pool"); const pool = require("../db/pool");
exports.ROLES = { exports.ROLES = {
VIEWER: "viewer", VIEWER: "viewer",
EDITOR: "editor", EDITOR: "editor",
ADMIN: "admin", ADMIN: "admin",
} }
exports.findByUsername = async (username) => { exports.findByUsername = async (username) => {
query = `SELECT * FROM users WHERE username = ${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); console.log(query);
return result.rows[0]; return result.rows[0];
}; };
exports.createUser = async (username, hashedPassword, name) => { exports.createUser = async (username, hashedPassword, name) => {
const result = await pool.query( const result = await pool.query(
`INSERT INTO users (username, password, name, role) `INSERT INTO users (username, password, name, role)
VALUES ($1, $2, $3, $4)`, VALUES ($1, $2, $3, $4)`,
[username, hashedPassword, name, this.ROLES.VIEWER] [username, hashedPassword, name, this.ROLES.VIEWER]
); );
return result.rows[0]; return result.rows[0];
}; };
exports.getAllUsers = async () => { exports.getAllUsers = async () => {
const result = await pool.query("SELECT id, username, name, role FROM users ORDER BY id ASC"); const result = await pool.query("SELECT id, username, name, role FROM users ORDER BY id ASC");
return result.rows; return result.rows;
}; };
exports.updateUserRole = async (id, role) => { exports.updateUserRole = async (id, role) => {
const result = await pool.query( const result = await pool.query(
`UPDATE users `UPDATE users
SET role = $1 SET role = $1
WHERE id = $2 WHERE id = $2
RETURNING id, username, name, role`, RETURNING id, username, name, role`,
[role, id] [role, id]
); );
return result.rows[0]; return result.rows[0];
}; };
exports.deleteUser = async (id) => { exports.deleteUser = async (id) => {
const result = await pool.query( const result = await pool.query(
`DELETE FROM users WHERE id = $1 RETURNING id`, `DELETE FROM users WHERE id = $1 RETURNING id`,
[id] [id]
); );
return result.rowCount; return result.rowCount;
}; };
exports.checkIfUserExists = async (username) => { exports.checkIfUserExists = async (username) => {
const result = await pool.query( const result = await pool.query(
"SELECT COUNT(*) FROM users WHERE username = $1", "SELECT COUNT(*) FROM users WHERE username = $1",
[username] [username]
); );
return result.rows[0].count > 0; return result.rows[0].count > 0;
} }

View File

@ -1,11 +1,11 @@
const router = require("express").Router(); const router = require("express").Router();
const auth = require("../middleware/auth"); const auth = require("../middleware/auth");
const requireRole = require("../middleware/rbac"); const requireRole = require("../middleware/rbac");
const usersController = require("../controllers/users.controller"); const usersController = require("../controllers/users.controller");
const { ROLES } = require("../models/user.model"); const { ROLES } = require("../models/user.model");
router.get("/users", auth, requireRole(ROLES.ADMIN), usersController.getAllUsers); router.get("/users", auth, requireRole(ROLES.ADMIN), usersController.getAllUsers);
router.put("/users", auth, requireRole(ROLES.ADMIN), usersController.updateUserRole); router.put("/users", auth, requireRole(ROLES.ADMIN), usersController.updateUserRole);
router.delete("/users", auth, requireRole(ROLES.ADMIN), usersController.deleteUser); router.delete("/users", auth, requireRole(ROLES.ADMIN), usersController.deleteUser);
module.exports = router; module.exports = router;

View File

@ -1,13 +1,13 @@
const router = require("express").Router(); const router = require("express").Router();
const controller = require("../controllers/auth.controller"); 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) => { router.post("/", async (req, res) => {
resText = `Grocery List API is running.\n` + resText = `Grocery List API is running.\n` +
`Roles available: ${Object.values(User.ROLES).join(', ')}` `Roles available: ${Object.values(User.ROLES).join(', ')}`
res.status(200).type("text/plain").send(resText); res.status(200).type("text/plain").send(resText);
}); });
module.exports = router; module.exports = router;

View File

@ -1,19 +1,19 @@
const router = require("express").Router(); const router = require("express").Router();
const controller = require("../controllers/lists.controller"); 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");
router.get("/", auth, requireRole(...Object.values(ROLES)), controller.getList); router.get("/", auth, requireRole(...Object.values(ROLES)), controller.getList);
router.get("/item-by-name", auth, requireRole(...Object.values(ROLES)), controller.getItemByName); router.get("/item-by-name", auth, requireRole(...Object.values(ROLES)), controller.getItemByName);
router.get("/suggest", auth, requireRole(...Object.values(ROLES)), controller.getSuggestions); router.get("/suggest", auth, requireRole(...Object.values(ROLES)), controller.getSuggestions);
router.post("/add", auth, requireRole(ROLES.EDITOR, ROLES.ADMIN), controller.addItem); router.post("/add", auth, requireRole(ROLES.EDITOR, ROLES.ADMIN), controller.addItem);
router.post("/mark-bought", auth, requireRole(ROLES.EDITOR, ROLES.ADMIN), controller.markBought); router.post("/mark-bought", auth, requireRole(ROLES.EDITOR, ROLES.ADMIN), controller.markBought);
module.exports = router; module.exports = router;

View File

@ -1,10 +1,10 @@
const router = require("express").Router(); const router = require("express").Router();
const auth = require("../middleware/auth"); const auth = require("../middleware/auth");
const requireRole = require("../middleware/rbac"); const requireRole = require("../middleware/rbac");
const usersController = require("../controllers/users.controller"); const usersController = require("../controllers/users.controller");
const { ROLES } = require("../models/user.model"); const { ROLES } = require("../models/user.model");
router.get("/exists", usersController.checkIfUserExists); router.get("/exists", usersController.checkIfUserExists);
router.get("/test", usersController.test); router.get("/test", usersController.test);
module.exports = router; module.exports = router;

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

View File

@ -1,53 +1,53 @@
import { BrowserRouter, Route, Routes } from "react-router-dom"; import { BrowserRouter, Route, Routes } from "react-router-dom";
import { ROLES } from "./constants/roles"; import { ROLES } from "./constants/roles";
import { AuthProvider } from "./context/AuthContext.jsx"; import { AuthProvider } from "./context/AuthContext.jsx";
import AdminPanel from "./pages/AdminPanel.jsx"; import AdminPanel from "./pages/AdminPanel.jsx";
import GroceryList from "./pages/GroceryList.jsx"; import GroceryList from "./pages/GroceryList.jsx";
import Login from "./pages/Login.jsx"; import Login from "./pages/Login.jsx";
import Register from "./pages/Register.jsx"; import Register from "./pages/Register.jsx";
import AppLayout from "./components/AppLayout.jsx"; import AppLayout from "./components/AppLayout.jsx";
import PrivateRoute from "./utils/PrivateRoute.jsx"; import PrivateRoute from "./utils/PrivateRoute.jsx";
import RoleGuard from "./utils/RoleGuard.jsx"; import RoleGuard from "./utils/RoleGuard.jsx";
console.log("VITE_ALLOWED_HOSTS:", import.meta.env.VITE_ALLOWED_HOSTS); console.log("VITE_ALLOWED_HOSTS:", import.meta.env.VITE_ALLOWED_HOSTS);
function App() { function App() {
return ( return (
<AuthProvider> <AuthProvider>
<BrowserRouter> <BrowserRouter>
<Routes> <Routes>
{/* Public route */} {/* Public route */}
<Route path="/login" element={<Login />} /> <Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} /> <Route path="/register" element={<Register />} />
{/* Private routes with layout */} {/* Private routes with layout */}
<Route <Route
element={ element={
<PrivateRoute> <PrivateRoute>
<AppLayout /> <AppLayout />
</PrivateRoute> </PrivateRoute>
} }
> >
<Route path="/" element={<GroceryList />} /> <Route path="/" element={<GroceryList />} />
<Route <Route
path="/admin" path="/admin"
element={ element={
<RoleGuard allowed={[ROLES.ADMIN]}> <RoleGuard allowed={[ROLES.ADMIN]}>
<AdminPanel /> <AdminPanel />
</RoleGuard> </RoleGuard>
} }
/> />
</Route> </Route>
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>
</AuthProvider> </AuthProvider>
); );
} }
export default App; export default App;

View File

@ -1,11 +1,11 @@
import api from "./axios"; import api from "./axios";
export const loginRequest = async (username, password) => { export const loginRequest = async (username, password) => {
const res = await api.post("/auth/login", { username, password }); const res = await api.post("/auth/login", { username, password });
return res.data; return res.data;
}; };
export const registerRequest = async (username, password, name) => { export const registerRequest = async (username, password, name) => {
const res = await api.post("/auth/register", { username, password, name }); const res = await api.post("/auth/register", { username, password, name });
return res.data; return res.data;
}; };

View File

@ -1,32 +1,32 @@
import axios from "axios"; import axios from "axios";
import { API_BASE_URL } from "../config"; import { API_BASE_URL } from "../config";
const api = axios.create({ const api = axios.create({
baseURL: API_BASE_URL, baseURL: API_BASE_URL,
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
}); });
api.interceptors.request.use((config => { api.interceptors.request.use((config => {
const token = localStorage.getItem("token"); const token = localStorage.getItem("token");
if (token) { if (token) {
config.headers["Authorization"] = `Bearer ${token}`; config.headers["Authorization"] = `Bearer ${token}`;
} }
return config; return config;
})); }));
api.interceptors.response.use( api.interceptors.response.use(
response => response, response => response,
error => { error => {
if (error.response?.status === 401 && if (error.response?.status === 401 &&
error.response?.data?.message === "Invalid or expired token") { error.response?.data?.message === "Invalid or expired token") {
localStorage.removeItem("token"); localStorage.removeItem("token");
window.location.href = "/login"; window.location.href = "/login";
alert("Your session has expired. Please log in again."); alert("Your session has expired. Please log in again.");
} }
return Promise.reject(error); return Promise.reject(error);
} }
); );
export default api; export default api;

View File

@ -1,7 +1,7 @@
import api from "./axios"; import api from "./axios";
export const getList = () => api.get("/list"); export const getList = () => api.get("/list");
export const getItemByName = (itemName) => api.get("/list/item-by-name", { params: { itemName: itemName } }); export const getItemByName = (itemName) => api.get("/list/item-by-name", { params: { itemName: itemName } });
export const addItem = (itemName, quantity) => api.post("/list/add", { itemName, quantity }); export const addItem = (itemName, quantity) => api.post("/list/add", { itemName, quantity });
export const markBought = (id) => api.post("/list/mark-bought", { id }); export const markBought = (id) => api.post("/list/mark-bought", { id });
export const getSuggestions = (query) => api.get("/list/suggest", { params: { query: query } }); export const getSuggestions = (query) => api.get("/list/suggest", { params: { query: query } });

View File

@ -1,6 +1,6 @@
import api from "./axios"; import api from "./axios";
export const getAllUsers = () => api.get("/admin/users"); export const getAllUsers = () => api.get("/admin/users");
export const updateRole = (id, role) => api.put(`/admin/users`, { id, role }); export const updateRole = (id, role) => api.put(`/admin/users`, { id, role });
export const deleteUser = (id) => api.delete(`/admin/users/${id}`); export const deleteUser = (id) => api.delete(`/admin/users/${id}`);
export const checkIfUserExists = (username) => api.get(`/users/exists`, { params: { username: username } }); export const checkIfUserExists = (username) => api.get(`/users/exists`, { params: { username: username } });

View File

@ -1,11 +1,11 @@
import { Outlet } from "react-router-dom"; import { Outlet } from "react-router-dom";
import Navbar from "./Navbar"; import Navbar from "./Navbar";
export default function AppLayout() { export default function AppLayout() {
return ( return (
<div> <div>
<Navbar /> <Navbar />
<Outlet /> <Outlet />
</div> </div>
); );
} }

View File

@ -1,30 +1,30 @@
import "../styles/Navbar.css"; import "../styles/Navbar.css";
import { useContext } from "react"; import { useContext } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { AuthContext } from "../context/AuthContext"; import { AuthContext } from "../context/AuthContext";
export default function Navbar() { export default function Navbar() {
const { role, logout, username } = useContext(AuthContext); const { role, logout, username } = useContext(AuthContext);
return ( return (
<nav className="navbar"> <nav className="navbar">
<div className="navbar-links"> <div className="navbar-links">
<Link to="/">Home</Link> <Link to="/">Home</Link>
{role === "admin" && <Link to="/admin">Admin</Link>} {role === "admin" && <Link to="/admin">Admin</Link>}
</div> </div>
<div className="navbar-idcard"> <div className="navbar-idcard">
<div className="navbar-idinfo"> <div className="navbar-idinfo">
<span className="navbar-username">{username}</span> <span className="navbar-username">{username}</span>
<span className="navbar-role">{role}</span> <span className="navbar-role">{role}</span>
</div> </div>
</div> </div>
<button className="navbar-logout" onClick={logout}> <button className="navbar-logout" onClick={logout}>
Logout Logout
</button> </button>
</nav> </nav>
); );
} }

View File

@ -1,6 +1,6 @@
export const ROLES = { export const ROLES = {
VIEWER: "viewer", VIEWER: "viewer",
EDITOR: "editor", EDITOR: "editor",
ADMIN: "admin", ADMIN: "admin",
UP_TO_ADMIN: ["viewer", "editor", "admin"], UP_TO_ADMIN: ["viewer", "editor", "admin"],
}; };

View File

@ -1,46 +1,46 @@
import { createContext, useState } from 'react'; import { createContext, useState } from 'react';
export const AuthContext = createContext({ export const AuthContext = createContext({
token: null, token: null,
role: null, role: null,
username: null, username: null,
login: () => { }, login: () => { },
logout: () => { }, 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);
const [role, setRole] = useState(localStorage.getItem('role') || null); const [role, setRole] = useState(localStorage.getItem('role') || null);
const [username, setUsername] = useState(localStorage.getItem('username') || null); const [username, setUsername] = useState(localStorage.getItem('username') || null);
const login = (data) => { const login = (data) => {
localStorage.setItem('token', data.token); localStorage.setItem('token', data.token);
localStorage.setItem('role', data.role); localStorage.setItem('role', data.role);
localStorage.setItem('username', data.username); localStorage.setItem('username', data.username);
setToken(data.token); setToken(data.token);
setRole(data.role); setRole(data.role);
setUsername(data.username); setUsername(data.username);
}; };
const logout = () => { const logout = () => {
localStorage.clear(); localStorage.clear();
setToken(null); setToken(null);
setRole(null); setRole(null);
setUsername(null); setUsername(null);
}; };
const value = { const value = {
token, token,
role, role,
username, username,
login, login,
logout logout
}; };
return ( return (
<AuthContext.Provider value={value}> <AuthContext.Provider value={value}>
{children} {children}
</AuthContext.Provider> </AuthContext.Provider>
); );
}; };

View File

@ -1,10 +1,10 @@
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 App from './App.jsx'
import './index.css' import './index.css'
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<App /> <App />
</StrictMode>, </StrictMode>,
) )

View File

@ -1,40 +1,40 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { getAllUsers, updateRole } from "../api/users"; import { getAllUsers, updateRole } from "../api/users";
import { ROLES } from "../constants/roles"; import { ROLES } from "../constants/roles";
export default function AdminPanel() { export default function AdminPanel() {
const [users, setUsers] = useState([]); const [users, setUsers] = useState([]);
async function loadUsers() { async function loadUsers() {
const allUsers = await getAllUsers(); const allUsers = await getAllUsers();
console.log(allUsers); console.log(allUsers);
setUsers(allUsers.data); setUsers(allUsers.data);
} }
useEffect(() => { useEffect(() => {
loadUsers(); loadUsers();
}, []); }, []);
const changeRole = async (id, role) => { const changeRole = async (id, role) => {
const updated = await updateRole(id, role); const updated = await updateRole(id, role);
if (updated.status !== 200) return; if (updated.status !== 200) return;
loadUsers(); loadUsers();
} }
return ( return (
<div> <div>
<h1>Admin Panel</h1> <h1>Admin Panel</h1>
{users.map((user) => ( {users.map((user) => (
<div key={user.id}> <div key={user.id}>
<strong>{user.username}</strong> - {user.role} <strong>{user.username}</strong> - {user.role}
<select onChange={(e) => changeRole(user.id, e.target.value)} value={user.role}> <select onChange={(e) => changeRole(user.id, e.target.value)} value={user.role}>
<option value={ROLES.VIEWER}>Viewer</option> <option value={ROLES.VIEWER}>Viewer</option>
<option value={ROLES.EDITOR}>Editor</option> <option value={ROLES.EDITOR}>Editor</option>
<option value={ROLES.ADMIN}>Admin</option> <option value={ROLES.ADMIN}>Admin</option>
</select> </select>
</div> </div>
)) ))
} }
</div > </div >
) )
} }

View File

@ -1,193 +1,193 @@
import { useContext, useEffect, useState } from "react"; import { useContext, useEffect, useState } from "react";
import { addItem, getItemByName, getList, getSuggestions, markBought } from "../api/list"; import { addItem, getItemByName, getList, getSuggestions, markBought } from "../api/list";
import { ROLES } from "../constants/roles"; import { ROLES } from "../constants/roles";
import { AuthContext } from "../context/AuthContext"; import { AuthContext } from "../context/AuthContext";
import "../styles/GroceryList.css"; import "../styles/GroceryList.css";
export default function GroceryList() { export default function GroceryList() {
const { role, username } = useContext(AuthContext); const { role, username } = useContext(AuthContext);
const [items, setItems] = useState([]); const [items, setItems] = useState([]);
const [sortedItems, setSortedItems] = useState([]); const [sortedItems, setSortedItems] = useState([]);
const [sortMode, setSortMode] = useState("az"); const [sortMode, setSortMode] = useState("az");
const [showSuggestions, setShowSuggestions] = useState(false); const [showSuggestions, setShowSuggestions] = useState(false);
const [itemName, setItemName] = useState(""); const [itemName, setItemName] = useState("");
const [quantity, setQuantity] = useState(1); const [quantity, setQuantity] = useState(1);
const [suggestions, setSuggestions] = useState([]); const [suggestions, setSuggestions] = useState([]);
const [showAddForm, setShowAddForm] = useState(true); const [showAddForm, setShowAddForm] = useState(true);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const loadItems = async () => { const loadItems = async () => {
setLoading(true); setLoading(true);
const res = await getList(); const res = await getList();
setItems(res.data); setItems(res.data);
setLoading(false); setLoading(false);
}; };
useEffect(() => { useEffect(() => {
loadItems(); loadItems();
}, []); }, []);
useEffect(() => { useEffect(() => {
let sorted = [...items]; let sorted = [...items];
if (sortMode === "az") if (sortMode === "az")
sorted.sort((a, b) => a.item_name.localeCompare(b.item_name)); sorted.sort((a, b) => a.item_name.localeCompare(b.item_name));
if (sortMode === "za") if (sortMode === "za")
sorted.sort((a, b) => b.item_name.localeCompare(a.item_name)); sorted.sort((a, b) => b.item_name.localeCompare(a.item_name));
if (sortMode === "qty-high") if (sortMode === "qty-high")
sorted.sort((a, b) => b.quantity - a.quantity); sorted.sort((a, b) => b.quantity - a.quantity);
if (sortMode === "qty-low") if (sortMode === "qty-low")
sorted.sort((a, b) => a.quantity - b.quantity); sorted.sort((a, b) => a.quantity - b.quantity);
setSortedItems(sorted); setSortedItems(sorted);
}, [items, sortMode]); }, [items, sortMode]);
const handleSuggest = async (text) => { const handleSuggest = async (text) => {
setItemName(text); setItemName(text);
if (!text.trim()) { if (!text.trim()) {
setSuggestions([]); setSuggestions([]);
return; return;
} }
try { try {
let suggestions = await getSuggestions(text); let suggestions = await getSuggestions(text);
suggestions = suggestions.data.map(s => s.item_name); suggestions = suggestions.data.map(s => s.item_name);
setSuggestions(suggestions); setSuggestions(suggestions);
} catch { } catch {
setSuggestions([]); setSuggestions([]);
} }
}; };
const handleAdd = async (e) => { const handleAdd = async (e) => {
e.preventDefault(); e.preventDefault();
if (!itemName.trim()) return; if (!itemName.trim()) return;
let newQuantity = quantity; let newQuantity = quantity;
const item = await getItemByName(itemName); const item = await getItemByName(itemName);
if (item.data && item.data.bought === false) { if (item.data && item.data.bought === false) {
console.log("Item exists:", item.data); console.log("Item exists:", item.data);
let currentQuantity = item.data.quantity; let currentQuantity = item.data.quantity;
const yes = window.confirm( const yes = window.confirm(
`Item "${itemName}" already exists in the list. Do you want to update its quantity from ${currentQuantity} to ${currentQuantity + newQuantity}?` `Item "${itemName}" already exists in the list. Do you want to update its quantity from ${currentQuantity} to ${currentQuantity + newQuantity}?`
); );
if (!yes) return; if (!yes) return;
newQuantity += currentQuantity; newQuantity += currentQuantity;
} }
await addItem(itemName, newQuantity); await addItem(itemName, newQuantity);
setItemName(""); setItemName("");
setQuantity(1); setQuantity(1);
setSuggestions([]); setSuggestions([]);
loadItems(); loadItems();
}; };
const handleBought = async (id) => { const handleBought = async (id) => {
const yes = window.confirm("Mark this item as bought?"); const yes = window.confirm("Mark this item as bought?");
if (!yes) return; if (!yes) return;
await markBought(id); await markBought(id);
loadItems(); loadItems();
}; };
if (loading) return <p>Loading...</p>; if (loading) return <p>Loading...</p>;
return ( return (
<div className="glist-body"> <div className="glist-body">
<div className="glist-container"> <div className="glist-container">
<h1 className="glist-title">Costco Grocery List</h1> <h1 className="glist-title">Costco Grocery List</h1>
{/* Sorting dropdown */} {/* Sorting dropdown */}
<select <select
value={sortMode} value={sortMode}
onChange={(e) => setSortMode(e.target.value)} onChange={(e) => setSortMode(e.target.value)}
className="glist-sort" className="glist-sort"
> >
<option value="az">A Z</option> <option value="az">A Z</option>
<option value="za">Z A</option> <option value="za">Z A</option>
<option value="qty-high">Quantity: High Low</option> <option value="qty-high">Quantity: High Low</option>
<option value="qty-low">Quantity: Low High</option> <option value="qty-low">Quantity: Low High</option>
</select> </select>
{/* Add Item form (editor/admin only) */} {/* Add Item form (editor/admin only) */}
{[ROLES.ADMIN, ROLES.EDITOR].includes(role) && showAddForm && ( {[ROLES.ADMIN, ROLES.EDITOR].includes(role) && showAddForm && (
<> <>
<input <input
type="text" type="text"
className="glist-input" className="glist-input"
placeholder="Item name" placeholder="Item name"
value={itemName} value={itemName}
onChange={(e) => handleSuggest(e.target.value)} onChange={(e) => handleSuggest(e.target.value)}
onBlur={() => setTimeout(() => setShowSuggestions(false), 150)} onBlur={() => setTimeout(() => setShowSuggestions(false), 150)}
onClick={() => setShowSuggestions(true)} onClick={() => setShowSuggestions(true)}
/> />
{showSuggestions && suggestions.length > 0 && ( {showSuggestions && suggestions.length > 0 && (
<ul className="glist-suggest-box"> <ul className="glist-suggest-box">
{suggestions.map((s, i) => ( {suggestions.map((s, i) => (
<li <li
key={i} key={i}
className="glist-suggest-item" className="glist-suggest-item"
onClick={() => { onClick={() => {
setItemName(s); setItemName(s);
setSuggestions([]); setSuggestions([]);
}} }}
> >
{s} {s}
</li> </li>
))} ))}
</ul> </ul>
)} )}
<input <input
type="number" type="number"
min="1" min="1"
className="glist-input" className="glist-input"
value={quantity} value={quantity}
onChange={(e) => setQuantity(Number(e.target.value))} onChange={(e) => setQuantity(Number(e.target.value))}
/> />
<button className="glist-btn" onClick={handleAdd}> <button className="glist-btn" onClick={handleAdd}>
Add Item Add Item
</button> </button>
</> </>
)} )}
{/* Grocery list */} {/* Grocery list */}
<ul className="glist-ul"> <ul className="glist-ul">
{sortedItems.map((item) => ( {sortedItems.map((item) => (
<li <li
key={item.id} key={item.id}
className="glist-li" className="glist-li"
onClick={() => onClick={() =>
[ROLES.ADMIN, ROLES.EDITOR].includes(role) && handleBought(item.id) [ROLES.ADMIN, ROLES.EDITOR].includes(role) && handleBought(item.id)
} }
> >
{item.item_name} ({item.quantity}) {item.item_name} ({item.quantity})
</li> </li>
))} ))}
</ul> </ul>
</div> </div>
{/* Floating Button (editor/admin only) */} {/* Floating Button (editor/admin only) */}
{[ROLES.ADMIN, ROLES.EDITOR].includes(role) && ( {[ROLES.ADMIN, ROLES.EDITOR].includes(role) && (
<button <button
className="glist-fab" className="glist-fab"
onClick={() => setShowAddForm(!showAddForm)} onClick={() => setShowAddForm(!showAddForm)}
> >
{showAddForm ? "" : "+"} {showAddForm ? "" : "+"}
</button> </button>
)} )}
</div> </div>
); );
} }

View File

@ -1,57 +1,57 @@
import { useContext, useState } from "react"; import { useContext, useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { loginRequest } from "../api/auth"; import { loginRequest } from "../api/auth";
import { AuthContext } from "../context/AuthContext"; import { AuthContext } from "../context/AuthContext";
import "../styles/Login.css"; import "../styles/Login.css";
export default function Login() { export default function Login() {
const { login } = useContext(AuthContext); const { login } = useContext(AuthContext);
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [error, setError] = useState(""); const [error, setError] = useState("");
const submit = async (e) => { const submit = async (e) => {
e.preventDefault(); e.preventDefault();
setError(""); setError("");
try { try {
const data = await loginRequest(username, password); const data = await loginRequest(username, password);
login(data); login(data);
window.location.href = "/"; window.location.href = "/";
} catch (err) { } catch (err) {
setError(err.response?.data?.message || "Login failed"); setError(err.response?.data?.message || "Login failed");
} }
}; };
return ( return (
<div className="login-wrapper"> <div className="login-wrapper">
<div className="login-box"> <div className="login-box">
<h1 className="login-title">Login</h1> <h1 className="login-title">Login</h1>
{error && <p className="login-error">{error}</p>} {error && <p className="login-error">{error}</p>}
<form onSubmit={submit}> <form onSubmit={submit}>
<input <input
type="text" type="text"
className="login-input" className="login-input"
placeholder="Username" placeholder="Username"
onChange={(e) => setUsername(e.target.value)} onChange={(e) => setUsername(e.target.value)}
/> />
<input <input
type="password" type="password"
className="login-input" className="login-input"
placeholder="Password" placeholder="Password"
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
/> />
<button type="submit" className="login-button">Login</button> <button type="submit" className="login-button">Login</button>
</form> </form>
<p className="login-register"> <p className="login-register">
Need an account? <Link to="/register">Register here</Link> Need an account? <Link to="/register">Register here</Link>
</p> </p>
</div> </div>
</div> </div>
); );
} }

View File

@ -1,138 +1,138 @@
/* Container */ /* Container */
.glist-body { .glist-body {
font-family: Arial, sans-serif; font-family: Arial, sans-serif;
padding: 1em; padding: 1em;
background: #f8f9fa; background: #f8f9fa;
} }
.glist-container { .glist-container {
max-width: 480px; max-width: 480px;
margin: auto; margin: auto;
background: white; background: white;
padding: 1em; padding: 1em;
border-radius: 8px; border-radius: 8px;
box-shadow: 0 0 10px rgba(0,0,0,0.08); box-shadow: 0 0 10px rgba(0,0,0,0.08);
} }
/* Title */ /* Title */
.glist-title { .glist-title {
text-align: center; text-align: center;
font-size: 1.5em; font-size: 1.5em;
margin-bottom: 0.4em; margin-bottom: 0.4em;
} }
/* Inputs */ /* Inputs */
.glist-input { .glist-input {
font-size: 1em; font-size: 1em;
padding: 0.5em; padding: 0.5em;
margin: 0.3em 0; margin: 0.3em 0;
width: 100%; width: 100%;
box-sizing: border-box; box-sizing: border-box;
} }
/* Buttons */ /* Buttons */
.glist-btn { .glist-btn {
font-size: 1em; font-size: 1em;
padding: 0.55em; padding: 0.55em;
width: 100%; width: 100%;
margin-top: 0.4em; margin-top: 0.4em;
cursor: pointer; cursor: pointer;
border: none; border: none;
background: #007bff; background: #007bff;
color: white; color: white;
border-radius: 4px; border-radius: 4px;
} }
.glist-btn:hover { .glist-btn:hover {
background: #0067d8; background: #0067d8;
} }
/* Suggestion dropdown */ /* Suggestion dropdown */
.glist-suggest-box { .glist-suggest-box {
background: #fff; background: #fff;
border: 1px solid #ccc; border: 1px solid #ccc;
max-height: 150px; max-height: 150px;
overflow-y: auto; overflow-y: auto;
position: absolute; position: absolute;
z-index: 999; z-index: 999;
border-radius: 8px; border-radius: 8px;
box-shadow: 0 0 10px rgba(0,0,0,0.08); box-shadow: 0 0 10px rgba(0,0,0,0.08);
padding: 1em; padding: 1em;
width: calc(100% - 8em); width: calc(100% - 8em);
max-width: 440px; max-width: 440px;
margin: 0 auto; margin: 0 auto;
} }
.glist-suggest-item { .glist-suggest-item {
padding: 0.5em; padding: 0.5em;
padding-inline: 2em; padding-inline: 2em;
cursor: pointer; cursor: pointer;
} }
.glist-suggest-item:hover { .glist-suggest-item:hover {
background: #eee; background: #eee;
} }
/* Grocery list items */ /* Grocery list items */
.glist-ul { .glist-ul {
list-style: none; list-style: none;
padding: 0; padding: 0;
margin-top: 1em; margin-top: 1em;
} }
.glist-li { .glist-li {
padding: 0.7em; padding: 0.7em;
background: #e9ecef; background: #e9ecef;
border-radius: 5px; border-radius: 5px;
margin-bottom: 0.6em; margin-bottom: 0.6em;
cursor: pointer; cursor: pointer;
} }
.glist-li:hover { .glist-li:hover {
background: #dee2e6; background: #dee2e6;
} }
/* Sorting dropdown */ /* Sorting dropdown */
.glist-sort { .glist-sort {
width: 100%; width: 100%;
margin: 0.3em 0; margin: 0.3em 0;
padding: 0.5em; padding: 0.5em;
font-size: 1em; font-size: 1em;
border-radius: 4px; border-radius: 4px;
} }
/* Floating Action Button (FAB) */ /* Floating Action Button (FAB) */
.glist-fab { .glist-fab {
position: fixed; position: fixed;
bottom: 20px; bottom: 20px;
right: 20px; right: 20px;
background: #28a745; background: #28a745;
color: white; color: white;
border: none; border: none;
border-radius: 50%; border-radius: 50%;
width: 62px; width: 62px;
height: 62px; height: 62px;
font-size: 2em; font-size: 2em;
line-height: 0; line-height: 0;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
box-shadow: 0 3px 10px rgba(0,0,0,0.2); box-shadow: 0 3px 10px rgba(0,0,0,0.2);
cursor: pointer; cursor: pointer;
} }
.glist-fab:hover { .glist-fab:hover {
background: #218838; background: #218838;
} }
/* Mobile tweaks */ /* Mobile tweaks */
@media (max-width: 480px) { @media (max-width: 480px) {
.glist-container { .glist-container {
padding: 1em 0.8em; padding: 1em 0.8em;
} }
.glist-fab { .glist-fab {
bottom: 16px; bottom: 16px;
right: 16px; right: 16px;
} }
} }

View File

@ -1,58 +1,58 @@
.navbar { .navbar {
background: #343a40; background: #343a40;
color: white; color: white;
padding: 0.6em 1em; padding: 0.6em 1em;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
border-radius: 4px; border-radius: 4px;
margin-bottom: 1em; margin-bottom: 1em;
} }
.navbar-links a { .navbar-links a {
color: white; color: white;
margin-right: 1em; margin-right: 1em;
text-decoration: none; text-decoration: none;
font-size: 1.1em; font-size: 1.1em;
} }
.navbar-links a:hover { .navbar-links a:hover {
text-decoration: underline; text-decoration: underline;
} }
.navbar-logout { .navbar-logout {
background: #dc3545; background: #dc3545;
color: white; color: white;
border: none; border: none;
padding: 0.4em 0.8em; padding: 0.4em 0.8em;
border-radius: 4px; border-radius: 4px;
cursor: pointer; cursor: pointer;
width: 100px; width: 100px;
} }
.navbar-idcard { .navbar-idcard {
display: flex; display: flex;
align-items: center; align-items: center;
align-content: center; align-content: center;
margin-right: 1em; margin-right: 1em;
padding: 0.3em 0.6em; padding: 0.3em 0.6em;
background: #495057; background: #495057;
border-radius: 4px; border-radius: 4px;
color: white; color: white;
} }
.navbar-idinfo { .navbar-idinfo {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
line-height: 1.1; line-height: 1.1;
} }
.navbar-username { .navbar-username {
font-size: 0.95em; font-size: 0.95em;
font-weight: bold; font-weight: bold;
} }
.navbar-role { .navbar-role {
font-size: 0.75em; font-size: 0.75em;
opacity: 0.8; opacity: 0.8;
} }

View File

@ -1,8 +1,8 @@
import { useContext } from "react"; import { useContext } from "react";
import { Navigate } from "react-router-dom"; import { Navigate } from "react-router-dom";
import { AuthContext } from "../context/AuthContext"; import { AuthContext } from "../context/AuthContext";
export default function PrivateRoute({ children }) { export default function PrivateRoute({ children }) {
const { token } = useContext(AuthContext); const { token } = useContext(AuthContext);
return token ? children : <Navigate to="/login" />; return token ? children : <Navigate to="/login" />;
} }

View File

@ -1,24 +1,24 @@
import { useContext } from "react"; import { useContext } from "react";
import { Navigate } from "react-router-dom"; import { Navigate } from "react-router-dom";
import { AuthContext } from "../context/AuthContext"; import { AuthContext } from "../context/AuthContext";
export default function RoleGuard({ allowed, children }) { export default function RoleGuard({ allowed, children }) {
const { role } = useContext(AuthContext); const { role } = useContext(AuthContext);
if (!role) return <Navigate to="/login" />; if (!role) return <Navigate to="/login" />;
if (!allowed.includes(role)) return <Navigate to="/" />; if (!allowed.includes(role)) return <Navigate to="/" />;
return children; return children;
} }
function usageExample() { function usageExample() {
<Route <Route
path="/admin" path="/admin"
element={ element={
<RoleGuard allowed={[ROLES.ADMIN]}> <RoleGuard allowed={[ROLES.ADMIN]}>
<AdminPanel /> <AdminPanel />
</RoleGuard> </RoleGuard>
} }
/> />
} }