Add role-based permissions, place sharing, and superadmin
This commit is contained in:
parent
877f48e4d0
commit
247d56263a
48
lib/auth.js
Normal file
48
lib/auth.js
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
// Shared permission levels for users.
|
||||||
|
// 1 = Normal user (default): standard access to their own places/readers.
|
||||||
|
// 2 = Elevated: same as normal, but bypasses ownership checks (can see/manage all places/readers).
|
||||||
|
// 3 = Admin: same as elevated, and can also administrate users (create/delete/reset password/change role).
|
||||||
|
// 4 = Superadmin: same as admin, and can also grant/revoke the admin role itself. Automatically granted
|
||||||
|
// to the very first registered user. There is no API path that can create/promote another superadmin;
|
||||||
|
// the only way to grant this role is by editing the database directly.
|
||||||
|
const ROLES = {
|
||||||
|
USER: 1,
|
||||||
|
ELEVATED: 2,
|
||||||
|
ADMIN: 3,
|
||||||
|
SUPERADMIN: 4
|
||||||
|
};
|
||||||
|
|
||||||
|
// Returns Express middleware that requires an authenticated session and (optionally) a minimum role.
|
||||||
|
// `getDb` is a function returning the current db instance, since route modules assign `db` after this
|
||||||
|
// middleware is registered (see routes/dashboard.js and routes/admin.js).
|
||||||
|
function requireAuth(getDb, minRole = ROLES.USER) {
|
||||||
|
return function (req, res, next) {
|
||||||
|
if (!req.session || !req.session.userId) {
|
||||||
|
return res.status(401).json({ success: false, message: "Not authenticated" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
db.get(`SELECT id, username, role FROM users WHERE id = ?`, [req.session.userId], (err, user) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Failed to load user:', err.message);
|
||||||
|
return res.status(500).json({ success: false, message: "Internal server error" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return req.session.destroy(() => {
|
||||||
|
res.status(401).json({ success: false, message: "Not authenticated" });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
req.user = user;
|
||||||
|
|
||||||
|
if (user.role < minRole) {
|
||||||
|
return res.status(403).json({ success: false, message: "Insufficient permissions" });
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { ROLES, requireAuth };
|
||||||
7
migrations/0004_roles_and_place_access.sql
Normal file
7
migrations/0004_roles_and_place_access.sql
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
ALTER TABLE users ADD COLUMN role INTEGER NOT NULL DEFAULT 1;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS place_access (id INTEGER PRIMARY KEY AUTOINCREMENT, placeId TEXT NOT NULL, userId INTEGER NOT NULL, grantedBy INTEGER, createdAt DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY(placeId) REFERENCES places(id), FOREIGN KEY(userId) REFERENCES users(id), FOREIGN KEY(grantedBy) REFERENCES users(id), UNIQUE(placeId, userId));
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_place_access_userId ON place_access(userId);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_place_access_placeId ON place_access(placeId);
|
||||||
197
routes/admin.js
Normal file
197
routes/admin.js
Normal file
|
|
@ -0,0 +1,197 @@
|
||||||
|
const express = require('express');
|
||||||
|
const bcrypt = require('bcryptjs');
|
||||||
|
const router = express.Router();
|
||||||
|
const { requireAuth, ROLES } = require('../lib/auth');
|
||||||
|
|
||||||
|
let db;
|
||||||
|
|
||||||
|
module.exports = (dbInit) => {
|
||||||
|
db = dbInit;
|
||||||
|
return router;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Admins (and superadmins) can administrate users: create, delete, reset password, change role.
|
||||||
|
router.use(requireAuth(() => db, ROLES.ADMIN));
|
||||||
|
|
||||||
|
// Superadmin (role 4) can never be granted through the API — the only way to obtain that role is by
|
||||||
|
// editing the database directly (see routes/auth.js, which grants it to the very first registered user).
|
||||||
|
const VALID_ROLES = [ROLES.USER, ROLES.ELEVATED, ROLES.ADMIN];
|
||||||
|
|
||||||
|
// Only a superadmin may grant the admin role to someone else.
|
||||||
|
function canAssignRole(callerRole, role) {
|
||||||
|
if (role === ROLES.ADMIN) {
|
||||||
|
return callerRole >= ROLES.SUPERADMIN;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regular admins may not modify or delete admin/superadmin accounts; only a superadmin can.
|
||||||
|
function canManageTarget(callerRole, targetRole) {
|
||||||
|
if (callerRole >= ROLES.SUPERADMIN) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return targetRole < ROLES.ADMIN;
|
||||||
|
}
|
||||||
|
|
||||||
|
// List all users
|
||||||
|
router.get('/users', (req, res) => {
|
||||||
|
db.all(`SELECT id, username, role, created_at FROM users ORDER BY id`, [], (err, users) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Failed to retrieve users:', err.message);
|
||||||
|
return res.status(500).json({ success: false, message: "Internal server error" });
|
||||||
|
}
|
||||||
|
res.json({ success: true, users });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create a new user
|
||||||
|
router.post('/users', (req, res) => {
|
||||||
|
const { username, password } = req.body || {};
|
||||||
|
const role = req.body.role !== undefined ? parseInt(req.body.role, 10) : ROLES.USER;
|
||||||
|
|
||||||
|
if (!username || !password) {
|
||||||
|
return res.status(400).json({ success: false, message: "Username and password are required" });
|
||||||
|
}
|
||||||
|
if (typeof username !== 'string' || typeof password !== 'string' || username.trim().length < 3 || password.length < 6) {
|
||||||
|
return res.status(400).json({ success: false, message: "Username must be at least 3 characters and password at least 6 characters" });
|
||||||
|
}
|
||||||
|
if (!VALID_ROLES.includes(role)) {
|
||||||
|
return res.status(400).json({ success: false, message: "Invalid role" });
|
||||||
|
}
|
||||||
|
if (!canAssignRole(req.user.role, role)) {
|
||||||
|
return res.status(403).json({ success: false, message: "Only a superadmin can grant the admin role" });
|
||||||
|
}
|
||||||
|
|
||||||
|
db.get(`SELECT id FROM users WHERE username = ?`, [username], (err, existing) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Failed to check for existing user:', err.message);
|
||||||
|
return res.status(500).json({ success: false, message: "Internal server error" });
|
||||||
|
}
|
||||||
|
if (existing) {
|
||||||
|
return res.status(409).json({ success: false, message: "Username is already taken" });
|
||||||
|
}
|
||||||
|
|
||||||
|
bcrypt.hash(password, 10, (err, hash) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Failed to hash password:', err.message);
|
||||||
|
return res.status(500).json({ success: false, message: "Internal server error" });
|
||||||
|
}
|
||||||
|
|
||||||
|
db.run(`INSERT INTO users (username, password, role) VALUES (?, ?, ?)`, [username, hash, role], function (err) {
|
||||||
|
if (err) {
|
||||||
|
console.error('Failed to create user:', err.message);
|
||||||
|
return res.status(500).json({ success: false, message: "Internal server error" });
|
||||||
|
}
|
||||||
|
res.json({ success: true, user: { id: this.lastID, username, role } });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update a user's role and/or reset their password
|
||||||
|
router.put('/users/:userId', (req, res) => {
|
||||||
|
const userId = parseInt(req.params.userId, 10);
|
||||||
|
|
||||||
|
db.get(`SELECT id, username, role FROM users WHERE id = ?`, [userId], (err, user) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Failed to retrieve user:', err.message);
|
||||||
|
return res.status(500).json({ success: false, message: "Internal server error" });
|
||||||
|
}
|
||||||
|
if (!user) {
|
||||||
|
return res.status(404).json({ success: false, message: "User not found" });
|
||||||
|
}
|
||||||
|
if (!canManageTarget(req.user.role, user.role)) {
|
||||||
|
return res.status(403).json({ success: false, message: "Only a superadmin can modify an admin or superadmin account" });
|
||||||
|
}
|
||||||
|
|
||||||
|
let role = user.role;
|
||||||
|
if (req.body && req.body.role !== undefined) {
|
||||||
|
role = parseInt(req.body.role, 10);
|
||||||
|
if (!VALID_ROLES.includes(role)) {
|
||||||
|
return res.status(400).json({ success: false, message: "Invalid role" });
|
||||||
|
}
|
||||||
|
if (!canAssignRole(req.user.role, role)) {
|
||||||
|
return res.status(403).json({ success: false, message: "Only a superadmin can grant the admin role" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const password = req.body ? req.body.password : undefined;
|
||||||
|
if (password !== undefined && (typeof password !== 'string' || password.length < 6)) {
|
||||||
|
return res.status(400).json({ success: false, message: "Password must be at least 6 characters" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyUpdate = (hash) => {
|
||||||
|
const sql = hash
|
||||||
|
? `UPDATE users SET role = ?, password = ? WHERE id = ?`
|
||||||
|
: `UPDATE users SET role = ? WHERE id = ?`;
|
||||||
|
const params = hash ? [role, hash, userId] : [role, userId];
|
||||||
|
|
||||||
|
db.run(sql, params, (err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Failed to update user:', err.message);
|
||||||
|
return res.status(500).json({ success: false, message: "Internal server error" });
|
||||||
|
}
|
||||||
|
res.json({ success: true, user: { id: userId, username: user.username, role } });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!password) {
|
||||||
|
return applyUpdate(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
bcrypt.hash(password, 10, (err, hash) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Failed to hash password:', err.message);
|
||||||
|
return res.status(500).json({ success: false, message: "Internal server error" });
|
||||||
|
}
|
||||||
|
applyUpdate(hash);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete a user
|
||||||
|
router.delete('/users/:userId', (req, res) => {
|
||||||
|
const userId = parseInt(req.params.userId, 10);
|
||||||
|
|
||||||
|
if (userId === req.user.id) {
|
||||||
|
return res.status(400).json({ success: false, message: "You cannot delete your own account" });
|
||||||
|
}
|
||||||
|
|
||||||
|
db.get(`SELECT id, role FROM users WHERE id = ?`, [userId], (err, user) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Failed to retrieve user:', err.message);
|
||||||
|
return res.status(500).json({ success: false, message: "Internal server error" });
|
||||||
|
}
|
||||||
|
if (!user) {
|
||||||
|
return res.status(404).json({ success: false, message: "User not found" });
|
||||||
|
}
|
||||||
|
if (!canManageTarget(req.user.role, user.role)) {
|
||||||
|
return res.status(403).json({ success: false, message: "Only a superadmin can delete an admin or superadmin account" });
|
||||||
|
}
|
||||||
|
|
||||||
|
db.get(`SELECT COUNT(*) as count FROM places WHERE ownerId = ?`, [userId], (err, row) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Failed to check owned places:', err.message);
|
||||||
|
return res.status(500).json({ success: false, message: "Internal server error" });
|
||||||
|
}
|
||||||
|
if (row.count > 0) {
|
||||||
|
return res.status(409).json({ success: false, message: "This user still owns places. Reassign or delete them first." });
|
||||||
|
}
|
||||||
|
|
||||||
|
db.run(`DELETE FROM place_access WHERE userId = ?`, [userId], (err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Failed to delete access grants for user:', err.message);
|
||||||
|
return res.status(500).json({ success: false, message: "Internal server error" });
|
||||||
|
}
|
||||||
|
|
||||||
|
db.run(`DELETE FROM users WHERE id = ?`, [userId], (err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Failed to delete user:', err.message);
|
||||||
|
return res.status(500).json({ success: false, message: "Internal server error" });
|
||||||
|
}
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const bcrypt = require('bcryptjs');
|
const bcrypt = require('bcryptjs');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
const { ROLES } = require('../lib/auth');
|
||||||
|
|
||||||
let db;
|
let db;
|
||||||
|
|
||||||
|
|
@ -30,21 +31,32 @@ router.post('/register', (req, res) => {
|
||||||
return res.status(409).json({ success: false, message: "Username is already taken" });
|
return res.status(409).json({ success: false, message: "Username is already taken" });
|
||||||
}
|
}
|
||||||
|
|
||||||
bcrypt.hash(password, 10, (err, hash) => {
|
// The very first registered user becomes a superadmin; every user after that is a normal user.
|
||||||
|
// This is the only way to obtain a superadmin account short of editing the database directly.
|
||||||
|
db.get(`SELECT COUNT(*) as count FROM users`, [], (err, row) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error('Failed to hash password:', err.message);
|
console.error('Failed to count users:', err.message);
|
||||||
return res.status(500).json({ success: false, message: "Internal server error" });
|
return res.status(500).json({ success: false, message: "Internal server error" });
|
||||||
}
|
}
|
||||||
|
|
||||||
db.run(`INSERT INTO users (username, password) VALUES (?, ?)`, [username, hash], function (err) {
|
const role = row.count === 0 ? ROLES.SUPERADMIN : ROLES.USER;
|
||||||
|
|
||||||
|
bcrypt.hash(password, 10, (err, hash) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error('Failed to create user:', err.message);
|
console.error('Failed to hash password:', err.message);
|
||||||
return res.status(500).json({ success: false, message: "Internal server error" });
|
return res.status(500).json({ success: false, message: "Internal server error" });
|
||||||
}
|
}
|
||||||
|
|
||||||
req.session.userId = this.lastID;
|
db.run(`INSERT INTO users (username, password, role) VALUES (?, ?, ?)`, [username, hash, role], function (err) {
|
||||||
req.session.username = username;
|
if (err) {
|
||||||
res.json({ success: true, user: { id: this.lastID, username } });
|
console.error('Failed to create user:', err.message);
|
||||||
|
return res.status(500).json({ success: false, message: "Internal server error" });
|
||||||
|
}
|
||||||
|
|
||||||
|
req.session.userId = this.lastID;
|
||||||
|
req.session.username = username;
|
||||||
|
res.json({ success: true, user: { id: this.lastID, username, role } });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -79,7 +91,7 @@ router.post('/login', (req, res) => {
|
||||||
|
|
||||||
req.session.userId = user.id;
|
req.session.userId = user.id;
|
||||||
req.session.username = user.username;
|
req.session.username = user.username;
|
||||||
res.json({ success: true, user: { id: user.id, username: user.username } });
|
res.json({ success: true, user: { id: user.id, username: user.username, role: user.role } });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -99,6 +111,20 @@ router.get('/me', (req, res) => {
|
||||||
if (!req.session.userId) {
|
if (!req.session.userId) {
|
||||||
return res.status(401).json({ success: false, message: "Not authenticated" });
|
return res.status(401).json({ success: false, message: "Not authenticated" });
|
||||||
}
|
}
|
||||||
res.json({ success: true, user: { id: req.session.userId, username: req.session.username } });
|
|
||||||
|
db.get(`SELECT id, username, role FROM users WHERE id = ?`, [req.session.userId], (err, user) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Failed to retrieve user:', err.message);
|
||||||
|
return res.status(500).json({ success: false, message: "Internal server error" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return req.session.destroy(() => {
|
||||||
|
res.status(401).json({ success: false, message: "Not authenticated" });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true, user });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
const { requireAuth, ROLES } = require('../lib/auth');
|
||||||
|
|
||||||
let db;
|
let db;
|
||||||
|
|
||||||
|
|
@ -8,16 +9,11 @@ module.exports = (dbInit) => {
|
||||||
return router;
|
return router;
|
||||||
};
|
};
|
||||||
|
|
||||||
function requireAuth(req, res, next) {
|
// Loads the place, granting access if the current user owns it, holds an elevated/admin role
|
||||||
if (!req.session || !req.session.userId) {
|
// (which bypasses ownership checks entirely), or has been explicitly granted shared access to it.
|
||||||
return res.status(401).json({ success: false, message: "Not authenticated" });
|
function loadPlace(req, res, next) {
|
||||||
}
|
|
||||||
next();
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadOwnedPlace(req, res, next) {
|
|
||||||
const { placeId } = req.params;
|
const { placeId } = req.params;
|
||||||
db.get(`SELECT * FROM places WHERE id = ? AND ownerId = ?`, [placeId, req.session.userId], (err, place) => {
|
db.get(`SELECT * FROM places WHERE id = ?`, [placeId], (err, place) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error('Failed to retrieve place:', err.message);
|
console.error('Failed to retrieve place:', err.message);
|
||||||
return res.status(500).json({ success: false, message: "Internal server error" });
|
return res.status(500).json({ success: false, message: "Internal server error" });
|
||||||
|
|
@ -25,22 +21,68 @@ function loadOwnedPlace(req, res, next) {
|
||||||
if (!place) {
|
if (!place) {
|
||||||
return res.status(404).json({ success: false, message: "Place not found" });
|
return res.status(404).json({ success: false, message: "Place not found" });
|
||||||
}
|
}
|
||||||
req.place = place;
|
|
||||||
next();
|
if (place.ownerId === req.user.id) {
|
||||||
|
req.place = place;
|
||||||
|
req.placeAccessLevel = 'owner';
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.user.role >= ROLES.ELEVATED) {
|
||||||
|
req.place = place;
|
||||||
|
req.placeAccessLevel = 'bypass';
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
db.get(`SELECT 1 FROM place_access WHERE placeId = ? AND userId = ?`, [placeId, req.user.id], (err, grant) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Failed to check place access:', err.message);
|
||||||
|
return res.status(500).json({ success: false, message: "Internal server error" });
|
||||||
|
}
|
||||||
|
if (!grant) {
|
||||||
|
return res.status(404).json({ success: false, message: "Place not found" });
|
||||||
|
}
|
||||||
|
req.place = place;
|
||||||
|
req.placeAccessLevel = 'shared';
|
||||||
|
next();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
router.use(requireAuth);
|
// Only the owner (or an elevated/admin user, who bypasses ownership checks) may perform this action,
|
||||||
|
// e.g. deleting the place or managing who else has shared access to it.
|
||||||
|
function requireOwnerOrBypass(req, res, next) {
|
||||||
|
if (req.placeAccessLevel === 'owner' || req.placeAccessLevel === 'bypass') {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
return res.status(403).json({ success: false, message: "Only the place owner or an elevated user can perform this action" });
|
||||||
|
}
|
||||||
|
|
||||||
// List places owned by the current user
|
router.use(requireAuth(() => db));
|
||||||
|
|
||||||
|
// List places owned by, or shared with, the current user. Elevated/admin users see every place.
|
||||||
router.get('/places', (req, res) => {
|
router.get('/places', (req, res) => {
|
||||||
db.all(`SELECT * FROM places WHERE ownerId = ?`, [req.session.userId], (err, places) => {
|
if (req.user.role >= ROLES.ELEVATED) {
|
||||||
if (err) {
|
return db.all(`SELECT * FROM places`, [], (err, places) => {
|
||||||
console.error('Failed to retrieve places:', err.message);
|
if (err) {
|
||||||
return res.status(500).json({ success: false, message: "Internal server error" });
|
console.error('Failed to retrieve places:', err.message);
|
||||||
|
return res.status(500).json({ success: false, message: "Internal server error" });
|
||||||
|
}
|
||||||
|
res.json({ success: true, places });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
db.all(
|
||||||
|
`SELECT * FROM places WHERE ownerId = ? OR id IN (SELECT placeId FROM place_access WHERE userId = ?)`,
|
||||||
|
[req.user.id, req.user.id],
|
||||||
|
(err, places) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Failed to retrieve places:', err.message);
|
||||||
|
return res.status(500).json({ success: false, message: "Internal server error" });
|
||||||
|
}
|
||||||
|
res.json({ success: true, places });
|
||||||
}
|
}
|
||||||
res.json({ success: true, places });
|
);
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create a new place
|
// Create a new place
|
||||||
|
|
@ -59,29 +101,29 @@ router.post('/places', (req, res) => {
|
||||||
return res.status(409).json({ success: false, message: "A place with that id already exists" });
|
return res.status(409).json({ success: false, message: "A place with that id already exists" });
|
||||||
}
|
}
|
||||||
|
|
||||||
db.run(`INSERT INTO places (id, apiKey, settings, ownerId) VALUES (?, ?, ?, ?)`, [id, apiKey, settings || '{}', req.session.userId], (err) => {
|
db.run(`INSERT INTO places (id, apiKey, settings, ownerId) VALUES (?, ?, ?, ?)`, [id, apiKey, settings || '{}', req.user.id], (err) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error('Failed to create place:', err.message);
|
console.error('Failed to create place:', err.message);
|
||||||
return res.status(500).json({ success: false, message: "Internal server error" });
|
return res.status(500).json({ success: false, message: "Internal server error" });
|
||||||
}
|
}
|
||||||
res.json({ success: true, place: { id, apiKey, settings: settings || '{}', ownerId: req.session.userId } });
|
res.json({ success: true, place: { id, apiKey, settings: settings || '{}', ownerId: req.user.id } });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get a single place with its access points (readers)
|
// Get a single place with its access points (readers)
|
||||||
router.get('/places/:placeId', loadOwnedPlace, (req, res) => {
|
router.get('/places/:placeId', loadPlace, (req, res) => {
|
||||||
db.all(`SELECT * FROM access_points WHERE placeId = ?`, [req.place.id], (err, accessPoints) => {
|
db.all(`SELECT * FROM access_points WHERE placeId = ?`, [req.place.id], (err, accessPoints) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error('Failed to retrieve access points:', err.message);
|
console.error('Failed to retrieve access points:', err.message);
|
||||||
return res.status(500).json({ success: false, message: "Internal server error" });
|
return res.status(500).json({ success: false, message: "Internal server error" });
|
||||||
}
|
}
|
||||||
res.json({ success: true, place: req.place, accessPoints });
|
res.json({ success: true, place: req.place, accessPoints, accessLevel: req.placeAccessLevel });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update a place's apiKey/settings
|
// Update a place's apiKey/settings
|
||||||
router.put('/places/:placeId', loadOwnedPlace, (req, res) => {
|
router.put('/places/:placeId', loadPlace, (req, res) => {
|
||||||
const apiKey = req.body.apiKey !== undefined ? req.body.apiKey : req.place.apiKey;
|
const apiKey = req.body.apiKey !== undefined ? req.body.apiKey : req.place.apiKey;
|
||||||
const settings = req.body.settings !== undefined ? req.body.settings : req.place.settings;
|
const settings = req.body.settings !== undefined ? req.body.settings : req.place.settings;
|
||||||
|
|
||||||
|
|
@ -94,8 +136,8 @@ router.put('/places/:placeId', loadOwnedPlace, (req, res) => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Delete a place (and its readers/acl entries/access groups/scan logs)
|
// Delete a place (and its readers/acl entries/access groups/scan logs/shared access grants)
|
||||||
router.delete('/places/:placeId', loadOwnedPlace, (req, res) => {
|
router.delete('/places/:placeId', loadPlace, requireOwnerOrBypass, (req, res) => {
|
||||||
db.all(`SELECT id FROM access_points WHERE placeId = ?`, [req.place.id], (err, accessPoints) => {
|
db.all(`SELECT id FROM access_points WHERE placeId = ?`, [req.place.id], (err, accessPoints) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error('Failed to retrieve access points:', err.message);
|
console.error('Failed to retrieve access points:', err.message);
|
||||||
|
|
@ -144,12 +186,19 @@ router.delete('/places/:placeId', loadOwnedPlace, (req, res) => {
|
||||||
return res.status(500).json({ success: false, message: "Internal server error" });
|
return res.status(500).json({ success: false, message: "Internal server error" });
|
||||||
}
|
}
|
||||||
|
|
||||||
db.run(`DELETE FROM places WHERE id = ?`, [req.place.id], (err) => {
|
db.run(`DELETE FROM place_access WHERE placeId = ?`, [req.place.id], (err) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error('Failed to delete place:', err.message);
|
console.error('Failed to delete access grants for place:', err.message);
|
||||||
return res.status(500).json({ success: false, message: "Internal server error" });
|
return res.status(500).json({ success: false, message: "Internal server error" });
|
||||||
}
|
}
|
||||||
res.json({ success: true });
|
|
||||||
|
db.run(`DELETE FROM places WHERE id = ?`, [req.place.id], (err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Failed to delete place:', err.message);
|
||||||
|
return res.status(500).json({ success: false, message: "Internal server error" });
|
||||||
|
}
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -172,7 +221,7 @@ function loadOwnedAccessPoint(req, res, next) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a new reader (access point) for a place
|
// Create a new reader (access point) for a place
|
||||||
router.post('/places/:placeId/access-points', loadOwnedPlace, (req, res) => {
|
router.post('/places/:placeId/access-points', loadPlace, (req, res) => {
|
||||||
const { id, name } = req.body || {};
|
const { id, name } = req.body || {};
|
||||||
if (!id || !name) {
|
if (!id || !name) {
|
||||||
return res.status(400).json({ success: false, message: "Reader id and name are required" });
|
return res.status(400).json({ success: false, message: "Reader id and name are required" });
|
||||||
|
|
@ -206,7 +255,7 @@ router.post('/places/:placeId/access-points', loadOwnedPlace, (req, res) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update a reader's settings
|
// Update a reader's settings
|
||||||
router.put('/places/:placeId/access-points/:accessPointId', loadOwnedPlace, loadOwnedAccessPoint, (req, res) => {
|
router.put('/places/:placeId/access-points/:accessPointId', loadPlace, loadOwnedAccessPoint, (req, res) => {
|
||||||
const ap = req.accessPoint;
|
const ap = req.accessPoint;
|
||||||
const name = req.body.name !== undefined ? req.body.name : ap.name;
|
const name = req.body.name !== undefined ? req.body.name : ap.name;
|
||||||
const enabled = req.body.enabled !== undefined ? (req.body.enabled ? 1 : 0) : ap.enabled;
|
const enabled = req.body.enabled !== undefined ? (req.body.enabled ? 1 : 0) : ap.enabled;
|
||||||
|
|
@ -231,7 +280,7 @@ router.put('/places/:placeId/access-points/:accessPointId', loadOwnedPlace, load
|
||||||
});
|
});
|
||||||
|
|
||||||
// Delete a reader
|
// Delete a reader
|
||||||
router.delete('/places/:placeId/access-points/:accessPointId', loadOwnedPlace, loadOwnedAccessPoint, (req, res) => {
|
router.delete('/places/:placeId/access-points/:accessPointId', loadPlace, loadOwnedAccessPoint, (req, res) => {
|
||||||
db.run(`DELETE FROM acl WHERE accessPoint = ?`, [req.accessPoint.id], (err) => {
|
db.run(`DELETE FROM acl WHERE accessPoint = ?`, [req.accessPoint.id], (err) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error('Failed to delete ACL entries for reader:', err.message);
|
console.error('Failed to delete ACL entries for reader:', err.message);
|
||||||
|
|
@ -254,7 +303,7 @@ router.delete('/places/:placeId/access-points/:accessPointId', loadOwnedPlace, l
|
||||||
});
|
});
|
||||||
|
|
||||||
// List scan logs for a reader (most recent first)
|
// List scan logs for a reader (most recent first)
|
||||||
router.get('/places/:placeId/access-points/:accessPointId/logs', loadOwnedPlace, loadOwnedAccessPoint, (req, res) => {
|
router.get('/places/:placeId/access-points/:accessPointId/logs', loadPlace, loadOwnedAccessPoint, (req, res) => {
|
||||||
const limit = Math.min(parseInt(req.query.limit, 10) || 100, 500);
|
const limit = Math.min(parseInt(req.query.limit, 10) || 100, 500);
|
||||||
db.all(
|
db.all(
|
||||||
`SELECT * FROM scan_logs WHERE accessPoint = ? ORDER BY scannedAt DESC, id DESC LIMIT ?`,
|
`SELECT * FROM scan_logs WHERE accessPoint = ? ORDER BY scannedAt DESC, id DESC LIMIT ?`,
|
||||||
|
|
@ -270,7 +319,7 @@ router.get('/places/:placeId/access-points/:accessPointId/logs', loadOwnedPlace,
|
||||||
});
|
});
|
||||||
|
|
||||||
// List ACL entries for a reader
|
// List ACL entries for a reader
|
||||||
router.get('/places/:placeId/access-points/:accessPointId/acl', loadOwnedPlace, loadOwnedAccessPoint, (req, res) => {
|
router.get('/places/:placeId/access-points/:accessPointId/acl', loadPlace, loadOwnedAccessPoint, (req, res) => {
|
||||||
db.all(`SELECT * FROM acl WHERE accessPoint = ?`, [req.accessPoint.id], (err, entries) => {
|
db.all(`SELECT * FROM acl WHERE accessPoint = ?`, [req.accessPoint.id], (err, entries) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error('Failed to retrieve ACL entries:', err.message);
|
console.error('Failed to retrieve ACL entries:', err.message);
|
||||||
|
|
@ -281,7 +330,7 @@ router.get('/places/:placeId/access-points/:accessPointId/acl', loadOwnedPlace,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add an ACL entry for a reader
|
// Add an ACL entry for a reader
|
||||||
router.post('/places/:placeId/access-points/:accessPointId/acl', loadOwnedPlace, loadOwnedAccessPoint, (req, res) => {
|
router.post('/places/:placeId/access-points/:accessPointId/acl', loadPlace, loadOwnedAccessPoint, (req, res) => {
|
||||||
const { type, data } = req.body || {};
|
const { type, data } = req.body || {};
|
||||||
if (type === undefined || !data) {
|
if (type === undefined || !data) {
|
||||||
return res.status(400).json({ success: false, message: "ACL type and data are required" });
|
return res.status(400).json({ success: false, message: "ACL type and data are required" });
|
||||||
|
|
@ -297,7 +346,7 @@ router.post('/places/:placeId/access-points/:accessPointId/acl', loadOwnedPlace,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Delete an ACL entry
|
// Delete an ACL entry
|
||||||
router.delete('/places/:placeId/access-points/:accessPointId/acl/:aclId', loadOwnedPlace, loadOwnedAccessPoint, (req, res) => {
|
router.delete('/places/:placeId/access-points/:accessPointId/acl/:aclId', loadPlace, loadOwnedAccessPoint, (req, res) => {
|
||||||
db.run(`DELETE FROM acl WHERE id = ? AND accessPoint = ?`, [req.params.aclId, req.accessPoint.id], (err) => {
|
db.run(`DELETE FROM acl WHERE id = ? AND accessPoint = ?`, [req.params.aclId, req.accessPoint.id], (err) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error('Failed to delete ACL entry:', err.message);
|
console.error('Failed to delete ACL entry:', err.message);
|
||||||
|
|
@ -322,7 +371,7 @@ function loadOwnedAccessGroup(req, res, next) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// List access groups for a place
|
// List access groups for a place
|
||||||
router.get('/places/:placeId/access-groups', loadOwnedPlace, (req, res) => {
|
router.get('/places/:placeId/access-groups', loadPlace, (req, res) => {
|
||||||
db.all(`SELECT * FROM access_groups WHERE placeId = ?`, [req.place.id], (err, groups) => {
|
db.all(`SELECT * FROM access_groups WHERE placeId = ?`, [req.place.id], (err, groups) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error('Failed to retrieve access groups:', err.message);
|
console.error('Failed to retrieve access groups:', err.message);
|
||||||
|
|
@ -333,7 +382,7 @@ router.get('/places/:placeId/access-groups', loadOwnedPlace, (req, res) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create an access group (e.g. "Staff") for a place
|
// Create an access group (e.g. "Staff") for a place
|
||||||
router.post('/places/:placeId/access-groups', loadOwnedPlace, (req, res) => {
|
router.post('/places/:placeId/access-groups', loadPlace, (req, res) => {
|
||||||
const { name } = req.body || {};
|
const { name } = req.body || {};
|
||||||
if (!name) {
|
if (!name) {
|
||||||
return res.status(400).json({ success: false, message: "Access group name is required" });
|
return res.status(400).json({ success: false, message: "Access group name is required" });
|
||||||
|
|
@ -349,7 +398,7 @@ router.post('/places/:placeId/access-groups', loadOwnedPlace, (req, res) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Rename an access group
|
// Rename an access group
|
||||||
router.put('/places/:placeId/access-groups/:groupId', loadOwnedPlace, loadOwnedAccessGroup, (req, res) => {
|
router.put('/places/:placeId/access-groups/:groupId', loadPlace, loadOwnedAccessGroup, (req, res) => {
|
||||||
const name = req.body.name !== undefined ? req.body.name : req.accessGroup.name;
|
const name = req.body.name !== undefined ? req.body.name : req.accessGroup.name;
|
||||||
if (!name) {
|
if (!name) {
|
||||||
return res.status(400).json({ success: false, message: "Access group name is required" });
|
return res.status(400).json({ success: false, message: "Access group name is required" });
|
||||||
|
|
@ -365,7 +414,7 @@ router.put('/places/:placeId/access-groups/:groupId', loadOwnedPlace, loadOwnedA
|
||||||
});
|
});
|
||||||
|
|
||||||
// Delete an access group (and its members, and any ACL entries referencing it)
|
// Delete an access group (and its members, and any ACL entries referencing it)
|
||||||
router.delete('/places/:placeId/access-groups/:groupId', loadOwnedPlace, loadOwnedAccessGroup, (req, res) => {
|
router.delete('/places/:placeId/access-groups/:groupId', loadPlace, loadOwnedAccessGroup, (req, res) => {
|
||||||
db.run(`DELETE FROM access_group_members WHERE groupId = ?`, [req.accessGroup.id], (err) => {
|
db.run(`DELETE FROM access_group_members WHERE groupId = ?`, [req.accessGroup.id], (err) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error('Failed to delete access group members:', err.message);
|
console.error('Failed to delete access group members:', err.message);
|
||||||
|
|
@ -389,7 +438,7 @@ router.delete('/places/:placeId/access-groups/:groupId', loadOwnedPlace, loadOwn
|
||||||
});
|
});
|
||||||
|
|
||||||
// List members (creds) of an access group
|
// List members (creds) of an access group
|
||||||
router.get('/places/:placeId/access-groups/:groupId/members', loadOwnedPlace, loadOwnedAccessGroup, (req, res) => {
|
router.get('/places/:placeId/access-groups/:groupId/members', loadPlace, loadOwnedAccessGroup, (req, res) => {
|
||||||
db.all(`SELECT * FROM access_group_members WHERE groupId = ?`, [req.accessGroup.id], (err, members) => {
|
db.all(`SELECT * FROM access_group_members WHERE groupId = ?`, [req.accessGroup.id], (err, members) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error('Failed to retrieve access group members:', err.message);
|
console.error('Failed to retrieve access group members:', err.message);
|
||||||
|
|
@ -400,7 +449,7 @@ router.get('/places/:placeId/access-groups/:groupId/members', loadOwnedPlace, lo
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add a member (user, card, group rank rule, etc) to an access group
|
// Add a member (user, card, group rank rule, etc) to an access group
|
||||||
router.post('/places/:placeId/access-groups/:groupId/members', loadOwnedPlace, loadOwnedAccessGroup, (req, res) => {
|
router.post('/places/:placeId/access-groups/:groupId/members', loadPlace, loadOwnedAccessGroup, (req, res) => {
|
||||||
const { type, data } = req.body || {};
|
const { type, data } = req.body || {};
|
||||||
if (type === undefined || !data) {
|
if (type === undefined || !data) {
|
||||||
return res.status(400).json({ success: false, message: "Member type and data are required" });
|
return res.status(400).json({ success: false, message: "Member type and data are required" });
|
||||||
|
|
@ -420,7 +469,7 @@ router.post('/places/:placeId/access-groups/:groupId/members', loadOwnedPlace, l
|
||||||
});
|
});
|
||||||
|
|
||||||
// Remove a member from an access group
|
// Remove a member from an access group
|
||||||
router.delete('/places/:placeId/access-groups/:groupId/members/:memberId', loadOwnedPlace, loadOwnedAccessGroup, (req, res) => {
|
router.delete('/places/:placeId/access-groups/:groupId/members/:memberId', loadPlace, loadOwnedAccessGroup, (req, res) => {
|
||||||
db.run(`DELETE FROM access_group_members WHERE id = ? AND groupId = ?`, [req.params.memberId, req.accessGroup.id], (err) => {
|
db.run(`DELETE FROM access_group_members WHERE id = ? AND groupId = ?`, [req.params.memberId, req.accessGroup.id], (err) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error('Failed to remove access group member:', err.message);
|
console.error('Failed to remove access group member:', err.message);
|
||||||
|
|
@ -430,3 +479,69 @@ router.delete('/places/:placeId/access-groups/:groupId/members/:memberId', loadO
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Shared place access (lets an owner grant other users access to a place's settings) ---
|
||||||
|
|
||||||
|
// List users who have been granted shared access to this place
|
||||||
|
router.get('/places/:placeId/access', loadPlace, requireOwnerOrBypass, (req, res) => {
|
||||||
|
db.all(
|
||||||
|
`SELECT place_access.id, place_access.userId, users.username, place_access.createdAt
|
||||||
|
FROM place_access JOIN users ON users.id = place_access.userId
|
||||||
|
WHERE place_access.placeId = ?`,
|
||||||
|
[req.place.id],
|
||||||
|
(err, grants) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Failed to retrieve access grants:', err.message);
|
||||||
|
return res.status(500).json({ success: false, message: "Internal server error" });
|
||||||
|
}
|
||||||
|
res.json({ success: true, grants });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Grant another user access to this place's settings, by username
|
||||||
|
router.post('/places/:placeId/access', loadPlace, requireOwnerOrBypass, (req, res) => {
|
||||||
|
const { username } = req.body || {};
|
||||||
|
if (!username) {
|
||||||
|
return res.status(400).json({ success: false, message: "Username is required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
db.get(`SELECT id FROM users WHERE username = ?`, [username], (err, user) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Failed to look up user:', err.message);
|
||||||
|
return res.status(500).json({ success: false, message: "Internal server error" });
|
||||||
|
}
|
||||||
|
if (!user) {
|
||||||
|
return res.status(404).json({ success: false, message: "No user with that username was found" });
|
||||||
|
}
|
||||||
|
if (user.id === req.place.ownerId) {
|
||||||
|
return res.status(400).json({ success: false, message: "The place owner already has full access" });
|
||||||
|
}
|
||||||
|
|
||||||
|
db.run(
|
||||||
|
`INSERT INTO place_access (placeId, userId, grantedBy) VALUES (?, ?, ?)`,
|
||||||
|
[req.place.id, user.id, req.user.id],
|
||||||
|
function (err) {
|
||||||
|
if (err) {
|
||||||
|
if (err.message && err.message.includes('UNIQUE')) {
|
||||||
|
return res.status(409).json({ success: false, message: "That user already has access to this place" });
|
||||||
|
}
|
||||||
|
console.error('Failed to grant place access:', err.message);
|
||||||
|
return res.status(500).json({ success: false, message: "Internal server error" });
|
||||||
|
}
|
||||||
|
res.json({ success: true, grant: { id: this.lastID, placeId: req.place.id, userId: user.id, username } });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Revoke a user's shared access to this place
|
||||||
|
router.delete('/places/:placeId/access/:userId', loadPlace, requireOwnerOrBypass, (req, res) => {
|
||||||
|
db.run(`DELETE FROM place_access WHERE placeId = ? AND userId = ?`, [req.place.id, req.params.userId], (err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Failed to revoke place access:', err.message);
|
||||||
|
return res.status(500).json({ success: false, message: "Internal server error" });
|
||||||
|
}
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue