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

+
+ +
+

Create user

+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ +
+

Users

+ +
    +
    +
    +
    + + + + 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.

    +
    +
    + + +
    + +
    + + +
    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 => ``).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)} + + + + + `; + + 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)} + + `; + 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 }); + }); +}); +