diff --git a/lib/auth.js b/lib/auth.js
new file mode 100644
index 0000000..cc6db11
--- /dev/null
+++ b/lib/auth.js
@@ -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 };
diff --git a/migrations/0004_roles_and_place_access.sql b/migrations/0004_roles_and_place_access.sql
new file mode 100644
index 0000000..70df0d0
--- /dev/null
+++ b/migrations/0004_roles_and_place_access.sql
@@ -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);
diff --git a/public/admin/index.html b/public/admin/index.html
new file mode 100644
index 0000000..1cc13b8
--- /dev/null
+++ b/public/admin/index.html
@@ -0,0 +1,56 @@
+
+
+
+
+
+ User administration - NOTXCS
+
+
+
+
+
+
+
+
+
User administration
+
+
+
+
+
+
Users
+
No users found.
+
+
+
+
+
+
+
+
diff --git a/public/dashboard/index.html b/public/dashboard/index.html
index 51c430e..b36d986 100644
--- a/public/dashboard/index.html
+++ b/public/dashboard/index.html
@@ -10,7 +10,8 @@
+
+
+
Shared access
+
Grant other users access to manage this place's settings, readers and access groups.
+
+
No other users have been granted access to this place.
+
+
diff --git a/public/js/admin.js b/public/js/admin.js
new file mode 100644
index 0000000..861f20d
--- /dev/null
+++ b/public/js/admin.js
@@ -0,0 +1,173 @@
+let currentUser = null;
+let users = [];
+
+const ROLE_LABELS = {
+ 1: 'User',
+ 2: 'Elevated',
+ 3: 'Admin',
+ 4: 'Superadmin'
+};
+
+async function api(path, options = {}) {
+ const res = await fetch(path, {
+ headers: { 'Content-Type': 'application/json' },
+ ...options
+ });
+ const data = await res.json().catch(() => ({ success: false, message: 'Invalid response from server' }));
+ if (res.status === 401) {
+ window.location.href = '/login';
+ throw new Error('Not authenticated');
+ }
+ return data;
+}
+
+function escapeHtml(str) {
+ const div = document.createElement('div');
+ div.textContent = str;
+ return div.innerHTML;
+}
+
+async function init() {
+ const me = await api('/auth/me');
+ if (!me.success) {
+ window.location.href = '/login';
+ return;
+ }
+ if (me.user.role < 3) {
+ window.location.href = '/dashboard';
+ return;
+ }
+ currentUser = me.user;
+ document.getElementById('username-display').textContent = me.user.username;
+ document.getElementById('role-badge').textContent = ROLE_LABELS[me.user.role] || 'User';
+
+ // Only a superadmin can grant the admin role; hide that option for regular admins.
+ if (currentUser.role < 4) {
+ document.querySelector('#new-user-role option[value="3"]').remove();
+ }
+
+ await loadUsers();
+}
+
+async function loadUsers() {
+ const data = await api('/admin/users');
+ users = data.users || [];
+ renderUsersList();
+}
+
+function roleOptions(selectedRole) {
+ const canGrantAdmin = currentUser.role >= 4;
+ const options = [
+ { value: 1, label: 'User' },
+ { value: 2, label: 'Elevated' }
+ ];
+ if (canGrantAdmin || selectedRole === 3) {
+ options.push({ value: 3, label: 'Admin' });
+ }
+ return options.map(o => `${o.label} `).join('');
+}
+
+function renderUsersList() {
+ const list = document.getElementById('users-list');
+ const empty = document.getElementById('users-empty');
+ list.innerHTML = '';
+
+ if (users.length === 0) {
+ empty.classList.remove('hidden');
+ return;
+ }
+ empty.classList.add('hidden');
+
+ users.forEach((user) => {
+ const isSuperadmin = user.role >= 4;
+ const canManage = currentUser.role >= 4 || user.role < 3;
+ const li = document.createElement('li');
+ li.className = 'acl-entry';
+
+ if (isSuperadmin) {
+ li.innerHTML = `
+ ${escapeHtml(user.username)}
+ Superadmin
+ `;
+ list.appendChild(li);
+ return;
+ }
+
+ li.innerHTML = `
+ ${escapeHtml(user.username)}
+ ${roleOptions(user.role)}
+
+ Save
+ Delete
+ `;
+
+ li.querySelector('.save-btn').addEventListener('click', () => saveUser(user.id, li));
+ li.querySelector('.delete-btn').addEventListener('click', () => deleteUser(user.id, user.username));
+
+ list.appendChild(li);
+ });
+}
+
+async function saveUser(userId, li) {
+ const role = parseInt(li.querySelector('.role-select').value, 10);
+ const password = li.querySelector('.password-input').value;
+
+ const body = { role };
+ if (password) {
+ if (password.length < 6) {
+ alert('Password must be at least 6 characters');
+ return;
+ }
+ body.password = password;
+ }
+
+ const result = await api(`/admin/users/${encodeURIComponent(userId)}`, {
+ method: 'PUT',
+ body: JSON.stringify(body)
+ });
+
+ if (result.success) {
+ await loadUsers();
+ } else {
+ alert(result.message || 'Failed to update user');
+ }
+}
+
+async function deleteUser(userId, username) {
+ if (!confirm(`Delete user "${username}"?`)) return;
+
+ const result = await api(`/admin/users/${encodeURIComponent(userId)}`, { method: 'DELETE' });
+ if (result.success) {
+ await loadUsers();
+ } else {
+ alert(result.message || 'Failed to delete user');
+ }
+}
+
+document.getElementById('add-user-form').addEventListener('submit', async (e) => {
+ e.preventDefault();
+
+ const username = document.getElementById('new-user-username').value.trim();
+ const password = document.getElementById('new-user-password').value;
+ const role = parseInt(document.getElementById('new-user-role').value, 10);
+ if (!username || !password) return;
+
+ const result = await api('/admin/users', {
+ method: 'POST',
+ body: JSON.stringify({ username, password, role })
+ });
+
+ if (result.success) {
+ document.getElementById('add-user-form').reset();
+ await loadUsers();
+ } else {
+ alert(result.message || 'Failed to create user');
+ }
+});
+
+document.getElementById('logout-btn').addEventListener('click', async () => {
+ await api('/auth/logout', { method: 'POST' });
+ window.location.href = '/login';
+});
+
+init();
diff --git a/public/js/dashboard.js b/public/js/dashboard.js
index 6b2161b..b23968b 100644
--- a/public/js/dashboard.js
+++ b/public/js/dashboard.js
@@ -1,6 +1,15 @@
let currentPlaceId = null;
let places = [];
let accessGroups = [];
+let currentUser = null;
+let currentPlaceAccessLevel = null;
+
+const ROLE_LABELS = {
+ 1: 'User',
+ 2: 'Elevated',
+ 3: 'Admin',
+ 4: 'Superadmin'
+};
async function api(path, options = {}) {
const res = await fetch(path, {
@@ -21,7 +30,10 @@ async function init() {
window.location.href = '/login';
return;
}
+ currentUser = me.user;
document.getElementById('username-display').textContent = me.user.username;
+ document.getElementById('role-badge').textContent = ROLE_LABELS[me.user.role] || 'User';
+ document.getElementById('admin-link').classList.toggle('hidden', me.user.role < 3);
await loadPlaces();
}
@@ -52,12 +64,21 @@ async function selectPlace(placeId) {
const data = await api(`/dashboard/places/${encodeURIComponent(placeId)}`);
if (!data.success) return;
+ currentPlaceAccessLevel = data.accessLevel;
+
document.getElementById('no-place').classList.add('hidden');
document.getElementById('place-view').classList.remove('hidden');
document.getElementById('place-title').textContent = placeId;
+ const canManageOwnership = currentPlaceAccessLevel === 'owner' || currentPlaceAccessLevel === 'bypass';
+ document.getElementById('delete-place-btn').classList.toggle('hidden', !canManageOwnership);
+ document.getElementById('shared-access-card').classList.toggle('hidden', !canManageOwnership);
+
renderReaders(data.accessPoints || []);
await loadAccessGroups();
+ if (canManageOwnership) {
+ await loadSharedAccess();
+ }
}
function renderReaders(accessPoints) {
@@ -561,6 +582,67 @@ document.getElementById('group-modal').addEventListener('click', (e) => {
if (e.target.id === 'group-modal') closeGroupModal();
});
+// --- Shared place access (owner/elevated/admin can grant other users access to a place's settings) ---
+
+async function loadSharedAccess() {
+ if (!currentPlaceId) return;
+ const data = await api(`/dashboard/places/${encodeURIComponent(currentPlaceId)}/access`);
+ renderSharedAccessList(data.grants || []);
+}
+
+function renderSharedAccessList(grants) {
+ const list = document.getElementById('shared-access-list');
+ const empty = document.getElementById('shared-access-empty');
+ list.innerHTML = '';
+
+ if (grants.length === 0) {
+ empty.classList.remove('hidden');
+ return;
+ }
+ empty.classList.add('hidden');
+
+ grants.forEach((grant) => {
+ const li = document.createElement('li');
+ li.className = 'acl-entry';
+ li.innerHTML = `
+ ${escapeHtml(grant.username)}
+ Revoke
+ `;
+ li.querySelector('.shared-access-revoke-btn').addEventListener('click', () => revokeSharedAccess(grant.userId));
+ list.appendChild(li);
+ });
+}
+
+async function revokeSharedAccess(userId) {
+ if (!currentPlaceId) return;
+ if (!confirm('Revoke this user\'s access to the place?')) return;
+
+ await api(`/dashboard/places/${encodeURIComponent(currentPlaceId)}/access/${encodeURIComponent(userId)}`, {
+ method: 'DELETE'
+ });
+ await loadSharedAccess();
+}
+
+document.getElementById('add-shared-access-form').addEventListener('submit', async (e) => {
+ e.preventDefault();
+ if (!currentPlaceId) return;
+
+ const username = document.getElementById('shared-access-username').value.trim();
+ if (!username) return;
+
+ const result = await api(`/dashboard/places/${encodeURIComponent(currentPlaceId)}/access`, {
+ method: 'POST',
+ body: JSON.stringify({ username })
+ });
+
+ if (result.success) {
+ document.getElementById('shared-access-username').value = '';
+ await loadSharedAccess();
+ } else {
+ alert(result.message || 'Failed to grant access');
+ }
+});
+
// --- Scan logs viewing ---
let currentLogsReaderId = null;
diff --git a/routes/admin.js b/routes/admin.js
new file mode 100644
index 0000000..9fbe5be
--- /dev/null
+++ b/routes/admin.js
@@ -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 });
+ });
+ });
+ });
+ });
+});
diff --git a/routes/auth.js b/routes/auth.js
index e7a84b3..b12b112 100644
--- a/routes/auth.js
+++ b/routes/auth.js
@@ -1,6 +1,7 @@
const express = require('express');
const bcrypt = require('bcryptjs');
const router = express.Router();
+const { ROLES } = require('../lib/auth');
let db;
@@ -30,21 +31,32 @@ router.post('/register', (req, res) => {
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) {
- 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" });
}
- 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) {
- 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" });
}
- req.session.userId = this.lastID;
- req.session.username = username;
- res.json({ success: true, user: { id: this.lastID, username } });
+ 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" });
+ }
+
+ 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.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) {
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 });
+ });
});
diff --git a/routes/dashboard.js b/routes/dashboard.js
index c1301f9..0154728 100644
--- a/routes/dashboard.js
+++ b/routes/dashboard.js
@@ -1,5 +1,6 @@
const express = require('express');
const router = express.Router();
+const { requireAuth, ROLES } = require('../lib/auth');
let db;
@@ -8,16 +9,11 @@ module.exports = (dbInit) => {
return router;
};
-function requireAuth(req, res, next) {
- if (!req.session || !req.session.userId) {
- return res.status(401).json({ success: false, message: "Not authenticated" });
- }
- next();
-}
-
-function loadOwnedPlace(req, res, next) {
+// Loads the place, granting access if the current user owns it, holds an elevated/admin role
+// (which bypasses ownership checks entirely), or has been explicitly granted shared access to it.
+function loadPlace(req, res, next) {
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) {
console.error('Failed to retrieve place:', err.message);
return res.status(500).json({ success: false, message: "Internal server error" });
@@ -25,22 +21,68 @@ function loadOwnedPlace(req, res, next) {
if (!place) {
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) => {
- db.all(`SELECT * FROM places WHERE ownerId = ?`, [req.session.userId], (err, places) => {
- if (err) {
- console.error('Failed to retrieve places:', err.message);
- return res.status(500).json({ success: false, message: "Internal server error" });
+ if (req.user.role >= ROLES.ELEVATED) {
+ return db.all(`SELECT * FROM places`, [], (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 });
+ });
+ }
+
+ 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
@@ -59,29 +101,29 @@ router.post('/places', (req, res) => {
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) {
console.error('Failed to create place:', err.message);
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)
-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) => {
if (err) {
console.error('Failed to retrieve access points:', err.message);
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
-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 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)
-router.delete('/places/:placeId', loadOwnedPlace, (req, res) => {
+// Delete a place (and its readers/acl entries/access groups/scan logs/shared access grants)
+router.delete('/places/:placeId', loadPlace, requireOwnerOrBypass, (req, res) => {
db.all(`SELECT id FROM access_points WHERE placeId = ?`, [req.place.id], (err, accessPoints) => {
if (err) {
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" });
}
- 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) {
- 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" });
}
- 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
-router.post('/places/:placeId/access-points', loadOwnedPlace, (req, res) => {
+router.post('/places/:placeId/access-points', loadPlace, (req, res) => {
const { id, name } = req.body || {};
if (!id || !name) {
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
-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 name = req.body.name !== undefined ? req.body.name : ap.name;
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
-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) => {
if (err) {
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)
-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);
db.all(
`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
-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) => {
if (err) {
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
-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 || {};
if (type === undefined || !data) {
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
-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) => {
if (err) {
console.error('Failed to delete ACL entry:', err.message);
@@ -322,7 +371,7 @@ function loadOwnedAccessGroup(req, res, next) {
}
// 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) => {
if (err) {
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
-router.post('/places/:placeId/access-groups', loadOwnedPlace, (req, res) => {
+router.post('/places/:placeId/access-groups', loadPlace, (req, res) => {
const { name } = req.body || {};
if (!name) {
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
-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;
if (!name) {
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)
-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) => {
if (err) {
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
-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) => {
if (err) {
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
-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 || {};
if (type === undefined || !data) {
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
-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) => {
if (err) {
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 });
+ });
+});
+