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 1/9] 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.
+
+
+
+
+
+
+
+
+
+
+
+
Readers
+
No readers yet for this place.
+
+
+
+
+
+
+
+
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.
+
+
+
Don't have an account? Register
+
+
+
+
+
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.
+
+
+
Already have an account? Log in
+
+
+
+
+
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 });
+ });
+});
+
From c46fc7960d52b11bb08a6884117815ca54ce30f1 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 2 Jul 2026 02:14:45 +0000
Subject: [PATCH 2/9] Use async bcrypt operations and clarify SQL placeholder
construction
---
routes/auth.js | 37 +++++++++++++++++++++++++++----------
routes/dashboard.js | 1 +
2 files changed, 28 insertions(+), 10 deletions(-)
diff --git a/routes/auth.js b/routes/auth.js
index 275ebda..e7a84b3 100644
--- a/routes/auth.js
+++ b/routes/auth.js
@@ -30,16 +30,22 @@ router.post('/register', (req, res) => {
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) {
+ 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) 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 } });
+ });
});
});
});
@@ -57,13 +63,24 @@ router.post('/login', (req, res) => {
return res.status(500).json({ success: false, message: "Internal server error" });
}
- if (!user || !bcrypt.compareSync(password, user.password)) {
+ if (!user) {
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 } });
+ bcrypt.compare(password, user.password, (err, matches) => {
+ if (err) {
+ console.error('Failed to verify password:', err.message);
+ return res.status(500).json({ success: false, message: "Internal server error" });
+ }
+
+ if (!matches) {
+ 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 } });
+ });
});
});
diff --git a/routes/dashboard.js b/routes/dashboard.js
index 1cf7b2f..8676ed2 100644
--- a/routes/dashboard.js
+++ b/routes/dashboard.js
@@ -105,6 +105,7 @@ router.delete('/places/:placeId', loadOwnedPlace, (req, res) => {
const apIds = accessPoints.map(ap => ap.id);
const deleteAclAndAp = (cb) => {
if (apIds.length === 0) return cb();
+ // Build a `?` placeholder per id so all values stay parameterized; apIds never contains raw user input here.
const placeholders = apIds.map(() => '?').join(',');
db.run(`DELETE FROM acl WHERE accessPoint IN (${placeholders})`, apIds, (err) => {
if (err) return cb(err);
From 5961cba54610f65fa80334ddb38ba03e620fd59c Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 2 Jul 2026 02:17:56 +0000
Subject: [PATCH 3/9] Remove empty unused frontend.js stub causing route load
error
---
routes/api/v1/frontend.js | 0
1 file changed, 0 insertions(+), 0 deletions(-)
delete mode 100644 routes/api/v1/frontend.js
diff --git a/routes/api/v1/frontend.js b/routes/api/v1/frontend.js
deleted file mode 100644
index e69de29..0000000
From 67c2252f430a0f2b93412cd1729c3c5f4f7523ef Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 2 Jul 2026 02:38:23 +0000
Subject: [PATCH 4/9] Add ACL management UI for readers (users, card numbers,
groups, allow-all)
---
public/css/style.css | 65 +++++++++++++++++
public/dashboard.html | 45 ++++++++++++
public/js/dashboard.js | 158 +++++++++++++++++++++++++++++++++++++++++
3 files changed, 268 insertions(+)
diff --git a/public/css/style.css b/public/css/style.css
index 9c6c743..19b030f 100644
--- a/public/css/style.css
+++ b/public/css/style.css
@@ -304,3 +304,68 @@ button.danger {
.hidden {
display: none !important;
}
+
+.modal-overlay {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.6);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 24px;
+ z-index: 100;
+}
+
+.modal {
+ width: 100%;
+ max-width: 560px;
+ max-height: 85vh;
+ overflow-y: auto;
+ background: var(--bg);
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ padding: 24px;
+}
+
+.modal-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ margin-bottom: 20px;
+}
+
+.modal-header h2 {
+ margin: 0;
+ font-size: 18px;
+}
+
+.acl-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+
+.acl-entry {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 10px 0;
+ border-bottom: 1px solid var(--border);
+ font-size: 14px;
+}
+
+.acl-entry:last-child {
+ border-bottom: none;
+}
+
+.acl-entry .acl-description {
+ flex: 1;
+ color: var(--text);
+}
+
+.badge.type-badge {
+ background: rgba(79, 124, 255, 0.15);
+ color: var(--accent);
+ white-space: nowrap;
+}
diff --git a/public/dashboard.html b/public/dashboard.html
index 4d0bb5a..1a9e1fe 100644
--- a/public/dashboard.html
+++ b/public/dashboard.html
@@ -61,6 +61,51 @@
+
+
+
+
+
+
+
+
+
Entries
+
No entries yet. Access will be denied for everyone until an entry is added.
+
+
+
+
+