From 633b0a79a015d0b62ee0ad735b02454802eb4619 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 2 Jul 2026 02:12:59 +0000 Subject: [PATCH] Add user system, login/register pages, and reader management dashboard --- migrations/0002_add_users.sql | 3 + package-lock.json | 10 ++ package.json | 1 + public/css/style.css | 306 ++++++++++++++++++++++++++++++++++ public/dashboard.html | 66 ++++++++ public/index.html | 14 ++ public/js/dashboard.js | 188 +++++++++++++++++++++ public/js/login.js | 28 ++++ public/js/register.js | 28 ++++ public/login.html | 29 ++++ public/register.html | 29 ++++ routes/auth.js | 87 ++++++++++ routes/dashboard.js | 259 ++++++++++++++++++++++++++++ 13 files changed, 1048 insertions(+) create mode 100644 migrations/0002_add_users.sql create mode 100644 public/css/style.css create mode 100644 public/dashboard.html create mode 100644 public/index.html create mode 100644 public/js/dashboard.js create mode 100644 public/js/login.js create mode 100644 public/js/register.js create mode 100644 public/login.html create mode 100644 public/register.html create mode 100644 routes/auth.js create mode 100644 routes/dashboard.js diff --git a/migrations/0002_add_users.sql b/migrations/0002_add_users.sql new file mode 100644 index 0000000..3fb69d0 --- /dev/null +++ b/migrations/0002_add_users.sql @@ -0,0 +1,3 @@ +CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL, password TEXT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP); + +ALTER TABLE places ADD COLUMN ownerId INTEGER REFERENCES users(id); diff --git a/package-lock.json b/package-lock.json index c8e2cfd..f6335c5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "bcryptjs": "^3.0.3", "colors": "^1.4.0", "dotenv": "^17.3.1", "express": "^5.2.1", @@ -178,6 +179,15 @@ ], "license": "MIT" }, + "node_modules/bcryptjs": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", + "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", + "license": "BSD-3-Clause", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, "node_modules/bindings": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", diff --git a/package.json b/package.json index bab30d4..82e4cbe 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,6 @@ { "dependencies": { + "bcryptjs": "^3.0.3", "colors": "^1.4.0", "dotenv": "^17.3.1", "express": "^5.2.1", diff --git a/public/css/style.css b/public/css/style.css new file mode 100644 index 0000000..9c6c743 --- /dev/null +++ b/public/css/style.css @@ -0,0 +1,306 @@ +:root { + --bg: #0f1115; + --panel: #171a21; + --border: #262b36; + --text: #e6e9ef; + --muted: #8b93a7; + --accent: #4f7cff; + --accent-hover: #6a90ff; + --danger: #e5484d; + --success: #3ecf8e; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + background: var(--bg); + color: var(--text); +} + +.auth-wrapper { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; +} + +.auth-card { + width: 100%; + max-width: 380px; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 12px; + padding: 32px; +} + +.auth-card h1 { + margin: 0 0 4px; + font-size: 22px; +} + +.auth-card p.subtitle { + margin: 0 0 24px; + color: var(--muted); + font-size: 14px; +} + +label { + display: block; + font-size: 13px; + margin-bottom: 6px; + color: var(--muted); +} + +input { + width: 100%; + padding: 10px 12px; + margin-bottom: 16px; + border-radius: 8px; + border: 1px solid var(--border); + background: #0f1115; + color: var(--text); + font-size: 14px; +} + +input:focus { + outline: none; + border-color: var(--accent); +} + +button { + cursor: pointer; + border: none; + border-radius: 8px; + font-size: 14px; + font-weight: 600; + padding: 10px 16px; +} + +button.primary { + width: 100%; + background: var(--accent); + color: #fff; +} + +button.primary:hover { + background: var(--accent-hover); +} + +button.secondary { + background: transparent; + border: 1px solid var(--border); + color: var(--text); +} + +button.danger { + background: var(--danger); + color: #fff; +} + +.error { + background: rgba(229, 72, 77, 0.12); + border: 1px solid rgba(229, 72, 77, 0.4); + color: #ff9a9c; + padding: 8px 12px; + border-radius: 8px; + font-size: 13px; + margin-bottom: 16px; + display: none; +} + +.error.visible { + display: block; +} + +.switch-link { + text-align: center; + margin-top: 16px; + font-size: 13px; + color: var(--muted); +} + +.switch-link a { + color: var(--accent); + text-decoration: none; +} + +/* Dashboard */ +.app-shell { + display: flex; + min-height: 100vh; +} + +.sidebar { + width: 260px; + background: var(--panel); + border-right: 1px solid var(--border); + padding: 24px 16px; + display: flex; + flex-direction: column; +} + +.sidebar h2 { + font-size: 16px; + margin: 0 0 24px; +} + +.sidebar .user-info { + color: var(--muted); + font-size: 13px; + margin-bottom: 8px; +} + +.place-list { + list-style: none; + margin: 0; + padding: 0; + flex: 1; + overflow-y: auto; +} + +.place-list li { + padding: 10px 12px; + border-radius: 8px; + cursor: pointer; + font-size: 14px; + margin-bottom: 4px; + color: var(--muted); +} + +.place-list li:hover { + background: rgba(255, 255, 255, 0.04); +} + +.place-list li.active { + background: rgba(79, 124, 255, 0.15); + color: var(--text); +} + +.main { + flex: 1; + padding: 32px 40px; + overflow-y: auto; +} + +.main-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 24px; +} + +.main-header h1 { + margin: 0; + font-size: 20px; +} + +.card { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 12px; + padding: 20px; + margin-bottom: 20px; +} + +.card h3 { + margin-top: 0; +} + +.readers-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: 16px; +} + +.reader-card { + background: #0f1115; + border: 1px solid var(--border); + border-radius: 10px; + padding: 16px; +} + +.reader-card .reader-name { + font-weight: 600; + margin-bottom: 4px; +} + +.reader-card .reader-id { + color: var(--muted); + font-size: 12px; + margin-bottom: 12px; +} + +.reader-card .field { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; + font-size: 13px; +} + +.reader-card .field input[type="number"], +.reader-card .field select { + width: 90px; + margin: 0; + padding: 6px 8px; +} + +.reader-card .actions { + display: flex; + gap: 8px; + margin-top: 12px; +} + +.badge { + display: inline-block; + padding: 2px 8px; + border-radius: 999px; + font-size: 11px; + font-weight: 600; +} + +.badge.on { + background: rgba(62, 207, 142, 0.15); + color: var(--success); +} + +.badge.off { + background: rgba(229, 72, 77, 0.15); + color: var(--danger); +} + +.empty-state { + color: var(--muted); + font-size: 14px; + text-align: center; + padding: 40px 0; +} + +.inline-form { + display: flex; + gap: 8px; + flex-wrap: wrap; + align-items: flex-end; +} + +.inline-form .field-group { + flex: 1; + min-width: 140px; +} + +.inline-form .field-group label { + margin-bottom: 4px; +} + +.inline-form input { + margin-bottom: 0; +} + +.hidden { + display: none !important; +} diff --git a/public/dashboard.html b/public/dashboard.html new file mode 100644 index 0000000..4d0bb5a --- /dev/null +++ b/public/dashboard.html @@ -0,0 +1,66 @@ + + + + + + Dashboard - NOTXCS + + + +
+ + +
+
+ Select or create a place from the sidebar to manage its readers. +
+ + +
+
+ + + diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..1738f41 --- /dev/null +++ b/public/index.html @@ -0,0 +1,14 @@ + + + + + NOTXCS + + + + + diff --git a/public/js/dashboard.js b/public/js/dashboard.js new file mode 100644 index 0000000..f8f63a8 --- /dev/null +++ b/public/js/dashboard.js @@ -0,0 +1,188 @@ +let currentPlaceId = null; +let places = []; + +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.html'; + throw new Error('Not authenticated'); + } + return data; +} + +async function init() { + const me = await api('/auth/me'); + if (!me.success) { + window.location.href = '/login.html'; + return; + } + document.getElementById('username-display').textContent = me.user.username; + + await loadPlaces(); +} + +async function loadPlaces() { + const data = await api('/dashboard/places'); + places = data.places || []; + renderPlaceList(); +} + +function renderPlaceList() { + const list = document.getElementById('place-list'); + list.innerHTML = ''; + + places.forEach((place) => { + const li = document.createElement('li'); + li.textContent = place.id; + li.className = place.id === currentPlaceId ? 'active' : ''; + li.addEventListener('click', () => selectPlace(place.id)); + list.appendChild(li); + }); +} + +async function selectPlace(placeId) { + currentPlaceId = placeId; + renderPlaceList(); + + const data = await api(`/dashboard/places/${encodeURIComponent(placeId)}`); + if (!data.success) return; + + document.getElementById('no-place').classList.add('hidden'); + document.getElementById('place-view').classList.remove('hidden'); + document.getElementById('place-title').textContent = placeId; + + renderReaders(data.accessPoints || []); +} + +function renderReaders(accessPoints) { + const grid = document.getElementById('readers-grid'); + const empty = document.getElementById('readers-empty'); + grid.innerHTML = ''; + + if (accessPoints.length === 0) { + empty.classList.remove('hidden'); + return; + } + empty.classList.add('hidden'); + + accessPoints.forEach((ap) => { + const card = document.createElement('div'); + card.className = 'reader-card'; + card.innerHTML = ` +
${escapeHtml(ap.name)}
+
${escapeHtml(ap.id)}
+
+ Enabled + +
+
+ Armed + +
+
+ Unlock time (s) + +
+
+ + +
+ `; + + card.querySelector('.save-btn').addEventListener('click', () => saveReader(ap.id, card)); + card.querySelector('.delete-btn').addEventListener('click', () => deleteReader(ap.id)); + + grid.appendChild(card); + }); +} + +function escapeHtml(str) { + const div = document.createElement('div'); + div.textContent = str; + return div.innerHTML; +} + +async function saveReader(readerId, card) { + const enabled = card.querySelector('[data-field="enabled"]').checked; + const armState = card.querySelector('[data-field="armState"]').checked ? 1 : 0; + const unlockTime = parseInt(card.querySelector('[data-field="unlockTime"]').value, 10) || 0; + + await api(`/dashboard/places/${encodeURIComponent(currentPlaceId)}/access-points/${encodeURIComponent(readerId)}`, { + method: 'PUT', + body: JSON.stringify({ enabled, armState, unlockTime }) + }); + selectPlace(currentPlaceId); +} + +async function deleteReader(readerId) { + if (!confirm(`Delete reader "${readerId}"?`)) return; + await api(`/dashboard/places/${encodeURIComponent(currentPlaceId)}/access-points/${encodeURIComponent(readerId)}`, { + method: 'DELETE' + }); + selectPlace(currentPlaceId); +} + +document.getElementById('add-place-form').addEventListener('submit', async (e) => { + e.preventDefault(); + const id = document.getElementById('place-id').value.trim(); + const apiKey = document.getElementById('place-api-key').value.trim(); + if (!id || !apiKey) return; + + const data = await api('/dashboard/places', { + method: 'POST', + body: JSON.stringify({ id, apiKey }) + }); + + if (data.success) { + document.getElementById('place-id').value = ''; + document.getElementById('place-api-key').value = ''; + await loadPlaces(); + selectPlace(id); + } else { + alert(data.message || 'Failed to add place'); + } +}); + +document.getElementById('add-reader-form').addEventListener('submit', async (e) => { + e.preventDefault(); + if (!currentPlaceId) return; + + const id = document.getElementById('reader-id').value.trim(); + const name = document.getElementById('reader-name').value.trim(); + if (!id || !name) return; + + const data = await api(`/dashboard/places/${encodeURIComponent(currentPlaceId)}/access-points`, { + method: 'POST', + body: JSON.stringify({ id, name }) + }); + + if (data.success) { + document.getElementById('reader-id').value = ''; + document.getElementById('reader-name').value = ''; + selectPlace(currentPlaceId); + } else { + alert(data.message || 'Failed to add reader'); + } +}); + +document.getElementById('delete-place-btn').addEventListener('click', async () => { + if (!currentPlaceId) return; + if (!confirm(`Delete place "${currentPlaceId}" and all of its readers?`)) return; + + await api(`/dashboard/places/${encodeURIComponent(currentPlaceId)}`, { method: 'DELETE' }); + currentPlaceId = null; + document.getElementById('place-view').classList.add('hidden'); + document.getElementById('no-place').classList.remove('hidden'); + await loadPlaces(); +}); + +document.getElementById('logout-btn').addEventListener('click', async () => { + await api('/auth/logout', { method: 'POST' }); + window.location.href = '/login.html'; +}); + +init(); diff --git a/public/js/login.js b/public/js/login.js new file mode 100644 index 0000000..00dd1d0 --- /dev/null +++ b/public/js/login.js @@ -0,0 +1,28 @@ +document.getElementById('login-form').addEventListener('submit', async (e) => { + e.preventDefault(); + const errorEl = document.getElementById('error'); + errorEl.classList.remove('visible'); + + const username = document.getElementById('username').value.trim(); + const password = document.getElementById('password').value; + + try { + const res = await fetch('/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }) + }); + const data = await res.json(); + + if (!data.success) { + errorEl.textContent = data.message || 'Login failed'; + errorEl.classList.add('visible'); + return; + } + + window.location.href = '/dashboard.html'; + } catch (err) { + errorEl.textContent = 'Unable to reach the server. Please try again.'; + errorEl.classList.add('visible'); + } +}); diff --git a/public/js/register.js b/public/js/register.js new file mode 100644 index 0000000..370c434 --- /dev/null +++ b/public/js/register.js @@ -0,0 +1,28 @@ +document.getElementById('register-form').addEventListener('submit', async (e) => { + e.preventDefault(); + const errorEl = document.getElementById('error'); + errorEl.classList.remove('visible'); + + const username = document.getElementById('username').value.trim(); + const password = document.getElementById('password').value; + + try { + const res = await fetch('/auth/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }) + }); + const data = await res.json(); + + if (!data.success) { + errorEl.textContent = data.message || 'Registration failed'; + errorEl.classList.add('visible'); + return; + } + + window.location.href = '/dashboard.html'; + } catch (err) { + errorEl.textContent = 'Unable to reach the server. Please try again.'; + errorEl.classList.add('visible'); + } +}); diff --git a/public/login.html b/public/login.html new file mode 100644 index 0000000..d9c7eb2 --- /dev/null +++ b/public/login.html @@ -0,0 +1,29 @@ + + + + + + Login - NOTXCS + + + +
+
+

Welcome back

+

Sign in to manage your readers and settings.

+
+
+ + + + + + + +
+ +
+
+ + + diff --git a/public/register.html b/public/register.html new file mode 100644 index 0000000..0de45cb --- /dev/null +++ b/public/register.html @@ -0,0 +1,29 @@ + + + + + + Register - NOTXCS + + + +
+
+

Create an account

+

Register to start managing your readers.

+
+
+ + + + + + + +
+ +
+
+ + + diff --git a/routes/auth.js b/routes/auth.js new file mode 100644 index 0000000..275ebda --- /dev/null +++ b/routes/auth.js @@ -0,0 +1,87 @@ +const express = require('express'); +const bcrypt = require('bcryptjs'); +const router = express.Router(); + +let db; + +module.exports = (dbInit) => { + db = dbInit; + return router; +}; + +router.post('/register', (req, res) => { + const { username, password } = req.body || {}; + + 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" }); + } + + 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" }); + } + + const hash = bcrypt.hashSync(password, 10); + db.run(`INSERT INTO users (username, password) VALUES (?, ?)`, [username, hash], 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 } }); + }); + }); +}); + +router.post('/login', (req, res) => { + const { username, password } = req.body || {}; + + if (!username || !password) { + return res.status(400).json({ success: false, message: "Username and password are required" }); + } + + db.get(`SELECT * FROM users WHERE username = ?`, [username], (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 || !bcrypt.compareSync(password, user.password)) { + return res.status(401).json({ success: false, message: "Invalid username or password" }); + } + + req.session.userId = user.id; + req.session.username = user.username; + res.json({ success: true, user: { id: user.id, username: user.username } }); + }); +}); + +router.post('/logout', (req, res) => { + req.session.destroy((err) => { + if (err) { + console.error('Failed to destroy session:', err.message); + return res.status(500).json({ success: false, message: "Internal server error" }); + } + res.clearCookie('connect.sid'); + res.json({ success: true }); + }); +}); + +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 } }); +}); + diff --git a/routes/dashboard.js b/routes/dashboard.js new file mode 100644 index 0000000..1cf7b2f --- /dev/null +++ b/routes/dashboard.js @@ -0,0 +1,259 @@ +const express = require('express'); +const router = express.Router(); + +let db; + +module.exports = (dbInit) => { + db = 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) { + const { placeId } = req.params; + db.get(`SELECT * FROM places WHERE id = ? AND ownerId = ?`, [placeId, req.session.userId], (err, place) => { + if (err) { + console.error('Failed to retrieve place:', err.message); + return res.status(500).json({ success: false, message: "Internal server error" }); + } + if (!place) { + return res.status(404).json({ success: false, message: "Place not found" }); + } + req.place = place; + next(); + }); +} + +router.use(requireAuth); + +// List places owned by the current user +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" }); + } + res.json({ success: true, places }); + }); +}); + +// Create a new place +router.post('/places', (req, res) => { + const { id, apiKey, settings } = req.body || {}; + if (!id || !apiKey) { + return res.status(400).json({ success: false, message: "Place id and apiKey are required" }); + } + + db.get(`SELECT id FROM places WHERE id = ?`, [id], (err, existing) => { + if (err) { + console.error('Failed to check for existing place:', err.message); + return res.status(500).json({ success: false, message: "Internal server error" }); + } + if (existing) { + 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) => { + 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 } }); + }); + }); +}); + +// Get a single place with its access points (readers) +router.get('/places/:placeId', loadOwnedPlace, (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 }); + }); +}); + +// Update a place's apiKey/settings +router.put('/places/:placeId', loadOwnedPlace, (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; + + db.run(`UPDATE places SET apiKey = ?, settings = ? WHERE id = ?`, [apiKey, settings, req.place.id], (err) => { + if (err) { + console.error('Failed to update place:', err.message); + return res.status(500).json({ success: false, message: "Internal server error" }); + } + res.json({ success: true }); + }); +}); + +// Delete a place (and its readers/acl entries) +router.delete('/places/:placeId', loadOwnedPlace, (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); + return res.status(500).json({ success: false, message: "Internal server error" }); + } + + const apIds = accessPoints.map(ap => ap.id); + const deleteAclAndAp = (cb) => { + if (apIds.length === 0) return cb(); + const placeholders = apIds.map(() => '?').join(','); + db.run(`DELETE FROM acl WHERE accessPoint IN (${placeholders})`, apIds, (err) => { + if (err) return cb(err); + db.run(`DELETE FROM access_points WHERE placeId = ?`, [req.place.id], cb); + }); + }; + + deleteAclAndAp((err) => { + if (err) { + console.error('Failed to delete readers for place:', err.message); + return res.status(500).json({ success: false, message: "Internal server error" }); + } + + 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 }); + }); + }); + }); +}); + +function loadOwnedAccessPoint(req, res, next) { + db.get(`SELECT * FROM access_points WHERE id = ? AND placeId = ?`, [req.params.accessPointId, req.place.id], (err, ap) => { + if (err) { + console.error('Failed to retrieve reader:', err.message); + return res.status(500).json({ success: false, message: "Internal server error" }); + } + if (!ap) { + return res.status(404).json({ success: false, message: "Reader not found" }); + } + req.accessPoint = ap; + next(); + }); +} + +// Create a new reader (access point) for a place +router.post('/places/:placeId/access-points', loadOwnedPlace, (req, res) => { + const { id, name } = req.body || {}; + if (!id || !name) { + return res.status(400).json({ success: false, message: "Reader id and name are required" }); + } + + const enabled = req.body.enabled !== undefined ? (req.body.enabled ? 1 : 0) : 1; + const unlockTime = req.body.unlockTime !== undefined ? req.body.unlockTime : 8; + const armState = req.body.armState !== undefined ? req.body.armState : 1; + + db.get(`SELECT id FROM access_points WHERE id = ?`, [id], (err, existing) => { + if (err) { + console.error('Failed to check for existing reader:', err.message); + return res.status(500).json({ success: false, message: "Internal server error" }); + } + if (existing) { + return res.status(409).json({ success: false, message: "A reader with that id already exists" }); + } + + db.run( + `INSERT INTO access_points (id, placeId, name, enabled, unlockTime, armState) VALUES (?, ?, ?, ?, ?, ?)`, + [id, req.place.id, name, enabled, unlockTime, armState], + (err) => { + if (err) { + console.error('Failed to create reader:', err.message); + return res.status(500).json({ success: false, message: "Internal server error" }); + } + res.json({ success: true }); + } + ); + }); +}); + +// Update a reader's settings +router.put('/places/:placeId/access-points/:accessPointId', loadOwnedPlace, 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; + const unlockTime = req.body.unlockTime !== undefined ? req.body.unlockTime : ap.unlockTime; + const armState = req.body.armState !== undefined ? req.body.armState : ap.armState; + const readyData = req.body.readyData !== undefined ? req.body.readyData : ap.readyData; + const disarmedData = req.body.disarmedData !== undefined ? req.body.disarmedData : ap.disarmedData; + const grantedData = req.body.grantedData !== undefined ? req.body.grantedData : ap.grantedData; + const deniedData = req.body.deniedData !== undefined ? req.body.deniedData : ap.deniedData; + + db.run( + `UPDATE access_points SET name = ?, enabled = ?, unlockTime = ?, armState = ?, readyData = ?, disarmedData = ?, grantedData = ?, deniedData = ? WHERE id = ?`, + [name, enabled, unlockTime, armState, readyData, disarmedData, grantedData, deniedData, ap.id], + (err) => { + if (err) { + console.error('Failed to update reader:', err.message); + return res.status(500).json({ success: false, message: "Internal server error" }); + } + res.json({ success: true }); + } + ); +}); + +// Delete a reader +router.delete('/places/:placeId/access-points/:accessPointId', loadOwnedPlace, 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); + return res.status(500).json({ success: false, message: "Internal server error" }); + } + db.run(`DELETE FROM access_points WHERE id = ?`, [req.accessPoint.id], (err) => { + if (err) { + console.error('Failed to delete reader:', err.message); + return res.status(500).json({ success: false, message: "Internal server error" }); + } + res.json({ success: true }); + }); + }); +}); + +// List ACL entries for a reader +router.get('/places/:placeId/access-points/:accessPointId/acl', loadOwnedPlace, 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); + return res.status(500).json({ success: false, message: "Internal server error" }); + } + res.json({ success: true, acl: entries }); + }); +}); + +// Add an ACL entry for a reader +router.post('/places/:placeId/access-points/:accessPointId/acl', loadOwnedPlace, 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" }); + } + + db.run(`INSERT INTO acl (accessPoint, type, data) VALUES (?, ?, ?)`, [req.accessPoint.id, type, data], function (err) { + if (err) { + console.error('Failed to create ACL entry:', err.message); + return res.status(500).json({ success: false, message: "Internal server error" }); + } + res.json({ success: true, id: this.lastID }); + }); +}); + +// Delete an ACL entry +router.delete('/places/:placeId/access-points/:accessPointId/acl/:aclId', loadOwnedPlace, 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); + return res.status(500).json({ success: false, message: "Internal server error" }); + } + res.json({ success: true }); + }); +}); +