Update backend for RBAC

This commit is contained in:
Nico 2025-11-21 18:09:33 -08:00
parent d972335de5
commit 2a7457c614
22 changed files with 364 additions and 118 deletions

0
.gitignore vendored Executable file → Normal file
View File

0
backend/.dockerignore Executable file → Normal file
View File

0
backend/Dockerfile Executable file → Normal file
View File

46
backend/app.js Normal file
View File

@ -0,0 +1,46 @@
require("dotenv").config();
const express = require("express");
const cors = require("cors");
const app = express();
app.use(express.json());
const allowedOrigins = [
"http://localhost:3000",
"https://costco.nicosaya.com",
"https://costco.api.nicosaya.com",
];
app.use(
cors({
origin: function (origin, callback) {
if (!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 (/^https:\/\/192\.168\.\d+\.\d+/.test(origin)) return callback(null, true);
callback(new Error("Not allowed by CORS"));
},
methods: ["GET", "POST", "PUT", "DELETE"],
})
);
app.get('/', async (req, res) => {
res.status(200).send('Grocery List API is running.');
});
const authRoutes = require("./routes/auth.routes");
app.use("/auth", authRoutes);
const listRoutes = require("./routes/list.routes");
app.use("/list", listRoutes);
const adminRoutes = require("./routes/admin.routes");
app.use("/admin", adminRoutes);
const usersRoutes = require("./routes/users.routes");
app.use("/users", usersRoutes);
module.exports = app;

View File

@ -0,0 +1,33 @@
const bcrypt = require("bcryptjs");
const jwt = require("jsonwebtoken");
const User = require("../models/user.model");
exports.register = async (req, res) => {
const { email, password, role } = req.body;
try {
const hash = await bcrypt.hash(password, 10);
const user = await User.createUser(email, hash, role);
res.json({ message: "User registered", user });
} catch (err) {
res.status(400).json({ message: "Registration failed", error: err });
}
};
exports.login = async (req, res) => {
const { email, password } = req.body;
const user = await User.findByEmail(email);
if (!user) return res.status(401).json({ message: "Invalid credentials" });
const valid = await bcrypt.compare(password, user.password);
if (!valid) return res.status(401).json({ message: "Invalid credentials" });
const token = jwt.sign(
{ id: user.id, role: user.role },
process.env.JWT_SECRET,
{ expiresIn: "1d" }
);
res.json({ token, role: user.role });
};

View File

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

View File

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

11
backend/db/pool.js Normal file
View File

@ -0,0 +1,11 @@
const { Pool } = require("pg");
const pool = new Pool({
user: process.env.DB_USER,
password: process.env.DB_PASS,
host: process.env.DB_HOST,
database: process.env.DB_NAME,
port: 5432,
});
module.exports = pool;

View File

@ -0,0 +1,19 @@
const jwt = require("jsonwebtoken");
function auth(req, res, next) {
const header = req.headers.authorization;
if (!header) return res.status(401).json({ message: "Missing token" });
const token = header.split(" ")[1];
if (!token) return res.status(401).json({ message: "Invalid token format" });
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = decoded; // id + role
next();
} catch (err) {
res.status(401).json({ message: "Invalid or expired token" });
}
}
module.exports = auth;

View File

@ -0,0 +1,11 @@
function requireRole(...allowedRoles) {
return (req, res, next) => {
if (!req.user) return res.status(401).json({ message: "Authentication required" });
if (!allowedRoles.includes(req.user.role))
return res.status(403).json({ message: "Forbidden" });
next();
};
}
module.exports = requireRole;

View File

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

View File

@ -0,0 +1,22 @@
const pool = require("../db/pool");
exports.findByUsername = async (username) => {
const result = await pool.query("SELECT * FROM users WHERE username = $1", [username]);
return result.rows[0];
};
exports.createUser = async (username, hashedPassword, name, role = "viewer") => {
const result = await pool.query(
`INSERT INTO users (username, password, name, role)
VALUES ($1, $2, $3, $4)
RETURNING id, username, role`,
[username, hashedPassword, name, role]
);
return result.rows[0];
};
exports.getAllUsers = async () => {
const result = await pool.query("SELECT id, username, name, role FROM users ORDER BY id ASC");
return result.rows;
};

99
backend/package-lock.json generated Executable file → Normal file
View File

@ -5,9 +5,11 @@
"packages": {
"": {
"dependencies": {
"bcryptjs": "^3.0.3",
"cors": "^2.8.5",
"dotenv": "^17.2.3",
"express": "^5.1.0",
"jsonwebtoken": "^9.0.2",
"pg": "^8.16.0"
},
"devDependencies": {
@ -607,6 +609,14 @@
"node": ">=0.10.0"
}
},
"node_modules/bcryptjs": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
"bin": {
"bcrypt": "bin/bcrypt"
}
},
"node_modules/binary-extensions": {
"version": "1.13.1",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-1.13.1.tgz",
@ -669,6 +679,11 @@
"node": ">=0.10.0"
}
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@ -1044,6 +1059,14 @@
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
"dev": true
},
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@ -1906,6 +1929,46 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/jsonwebtoken": {
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz",
"integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==",
"dependencies": {
"jws": "^3.2.2",
"lodash.includes": "^4.3.0",
"lodash.isboolean": "^3.0.3",
"lodash.isinteger": "^4.0.4",
"lodash.isnumber": "^3.0.3",
"lodash.isplainobject": "^4.0.6",
"lodash.isstring": "^4.0.1",
"lodash.once": "^4.0.0",
"ms": "^2.1.1",
"semver": "^7.5.4"
},
"engines": {
"node": ">=12",
"npm": ">=6"
}
},
"node_modules/jwa": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz",
"integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz",
"integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==",
"dependencies": {
"jwa": "^1.4.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/kind-of": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
@ -1918,6 +1981,41 @@
"node": ">=0.10.0"
}
},
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="
},
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg=="
},
"node_modules/lodash.isinteger": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA=="
},
"node_modules/lodash.isnumber": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw=="
},
"node_modules/lodash.isplainobject": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA=="
},
"node_modules/lodash.isstring": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw=="
},
"node_modules/lodash.once": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg=="
},
"node_modules/lru-cache": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.1.0.tgz",
@ -3331,7 +3429,6 @@
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"dev": true,
"bin": {
"semver": "bin/semver.js"
},

2
backend/package.json Executable file → Normal file
View File

@ -1,8 +1,10 @@
{
"dependencies": {
"bcryptjs": "^3.0.3",
"cors": "^2.8.5",
"dotenv": "^17.2.3",
"express": "^5.1.0",
"jsonwebtoken": "^9.0.2",
"pg": "^8.16.0"
},
"devDependencies": {

View File

@ -0,0 +1,9 @@
const router = require("express").Router();
const auth = require("../middleware/auth");
const requireRole = require("../middleware/rbac");
const usersController = require("../controllers/users.controller");
// Admin-only route
router.get("/users", auth, requireRole("admin"), usersController.getAllUsers);
module.exports = router;

View File

@ -0,0 +1,7 @@
const router = require("express").Router();
const controller = require("../controllers/auth.controller");
router.post("/register", controller.register);
router.post("/login", controller.login);
module.exports = router;

View File

@ -0,0 +1,14 @@
const router = require("express").Router();
const controller = require("../controllers/lists.controller");
const auth = require("../middleware/auth");
const requireRole = require("../middleware/rbac");
router.get("/", auth, requireRole("viewer", "editor", "admin"), controller.getList);
router.post("/add", auth, requireRole("editor", "admin"), controller.addItem);
router.post("/mark-bought", auth, requireRole("editor", "admin"), controller.markBought);
module.exports = router;

View File

@ -0,0 +1,8 @@
const router = require("express").Router();
const auth = require("../middleware/auth");
const requireRole = require("../middleware/rbac");
const usersController = require("../controllers/users.controller");
router.get("/", auth, requireRole("admin"), usersController.getAllUsers);
module.exports = router;

106
backend/server.js Executable file → Normal file
View File

@ -1,103 +1,7 @@
require('dotenv').config();
const app = require("./app");
const express = require('express');
const cors = require('cors');
const { Pool } = require('pg');
const PORT = process.env.PORT || 5000;
const app = express();
const port = 5000;
const pool = new Pool({
user: process.env.DB_USER,
password: process.env.DB_PASS,
host: process.env.DB_HOST,
database: process.env.DB_NAME,
port: 5432,
});
app.use(express.json());
const allowedOrigins = [
"http://localhost:3000",
"https://costco.nicosaya.com",
"https://costco.api.nicosaya.com",
];
app.use(cors({
origin: function (origin, callback) {
if (!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 (/^https:\/\/192\.168\.\d+\.\d+/.test(origin)) return callback(null, true);
callback(new Error("Not allowed by CORS"));
},
methods: ["GET", "POST"],
}));
app.get('/', async (req, res) => {
const { query } = req.query;
const { rows } = await pool.query(
'SELECT DISTINCT item_name FROM grocery_list WHERE item_name ILIKE $1 LIMIT 10',
[`%${query}%`]
);
res.status(200).send('Grocery List API is running.');
});
app.get('/suggest', async (req, res) => {
const { query } = req.query;
const { rows } = await pool.query(
'SELECT DISTINCT item_name FROM grocery_list WHERE item_name ILIKE $1 LIMIT 10',
[`%${query}%`]
);
res.json(rows.map(r => r.item_name));
});
app.post('/add', async (req, res) => {
const { item_name, quantity } = req.body;
const result = await pool.query(
'SELECT id, bought FROM grocery_list WHERE item_name = $1',
[item_name]
);
let listItemId;
if (result.rowCount > 0) {
listItemId = result.rows[0].id;
await pool.query(
'UPDATE grocery_list SET quantity = $1, bought = FALSE WHERE id = $2',
[quantity, listItemId]
);
res.json({ message: 'Item re-added with updated quantity.' });
} else {
const insertResult = await pool.query(
'INSERT INTO grocery_list (item_name, quantity) VALUES ($1, $2) RETURNING id',
[item_name, quantity]
);
listItemId = insertResult.rows[0].id;
res.json({ message: 'Item added to list.' });
}
await pool.query(
'INSERT INTO grocery_history (list_item_id, quantity, added_on) VALUES ($1, $2, NOW())',
[listItemId, quantity]
);
});
app.post('/mark-bought', async (req, res) => {
const { id } = req.body;
await pool.query('UPDATE grocery_list SET bought = TRUE WHERE id = $1', [id]);
res.json({ message: 'Item marked as bought.' });
});
app.get('/list', async (req, res) => {
const { rows } = await pool.query('SELECT * FROM grocery_list WHERE bought = FALSE');
res.json(rows);
});
app.listen(port, () => console.log(`Listening at http://localhost:${port}`));
app.listen(PORT, () =>
console.log(`Backend running on http://localhost:${PORT}`)
);

View File

@ -1,15 +0,0 @@
// build.js
const esbuild = require('esbuild');
const fs = require('fs');
// Ensure dist folder exists
if (!fs.existsSync('dist')) fs.mkdirSync('dist', { recursive: true });
esbuild.build({
entryPoints: ['server.js'],
outfile: 'dist/server.js',
bundle: true,
platform: 'node',
target: 'node18',
minify: false,
}).catch(() => process.exit(1));

4
docker-compose.dev.yml Executable file → Normal file
View File

@ -16,7 +16,9 @@ services:
backend:
build: ./backend
build:
context: ./backend
command: npm run dev
volumes:
- ./backend:/app
- /app/node_modules

0
docker-compose.prod.yml Executable file → Normal file
View File