Merge pull request #1 from ChrisChrome/copilot/create-basic-user-system

Creating user system and dashboard for reader management
This commit is contained in:
Christopher Cookman 2026-07-02 08:59:28 -06:00 committed by GitHub
commit 877f48e4d0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 1968 additions and 25 deletions

View file

@ -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);

View file

@ -0,0 +1,11 @@
CREATE TABLE IF NOT EXISTS access_groups (id INTEGER PRIMARY KEY AUTOINCREMENT, placeId TEXT NOT NULL, name TEXT NOT NULL, FOREIGN KEY(placeId) REFERENCES places(id));
CREATE TABLE IF NOT EXISTS access_group_members (id INTEGER PRIMARY KEY AUTOINCREMENT, groupId INTEGER NOT NULL, type INTEGER NOT NULL DEFAULT 0, data TEXT NOT NULL, FOREIGN KEY(groupId) REFERENCES access_groups(id));
CREATE TABLE IF NOT EXISTS scan_logs (id INTEGER PRIMARY KEY AUTOINCREMENT, placeId TEXT NOT NULL, accessPoint TEXT NOT NULL, userId TEXT, cardNumbers TEXT, granted INTEGER NOT NULL DEFAULT 0, responseCode TEXT, scannedAt DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY(placeId) REFERENCES places(id), FOREIGN KEY(accessPoint) REFERENCES access_points(id));
CREATE INDEX IF NOT EXISTS idx_access_group_members_groupId ON access_group_members(groupId);
CREATE INDEX IF NOT EXISTS idx_scan_logs_accessPoint ON scan_logs(accessPoint);
CREATE INDEX IF NOT EXISTS idx_scan_logs_placeId ON scan_logs(placeId);

10
package-lock.json generated
View file

@ -9,6 +9,7 @@
"version": "1.0.0", "version": "1.0.0",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"bcryptjs": "^3.0.3",
"colors": "^1.4.0", "colors": "^1.4.0",
"dotenv": "^17.3.1", "dotenv": "^17.3.1",
"express": "^5.2.1", "express": "^5.2.1",
@ -178,6 +179,15 @@
], ],
"license": "MIT" "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": { "node_modules/bindings": {
"version": "1.5.0", "version": "1.5.0",
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",

View file

@ -1,5 +1,6 @@
{ {
"dependencies": { "dependencies": {
"bcryptjs": "^3.0.3",
"colors": "^1.4.0", "colors": "^1.4.0",
"dotenv": "^17.3.1", "dotenv": "^17.3.1",
"express": "^5.2.1", "express": "^5.2.1",

379
public/css/style.css Normal file
View file

@ -0,0 +1,379 @@
: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;
min-width: 0;
}
.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;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
.reader-card .actions button {
flex: 1 1 calc(50% - 4px);
padding: 10px 8px;
white-space: nowrap;
}
.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;
}
.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;
}

194
public/dashboard.html Normal file
View file

@ -0,0 +1,194 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dashboard - NOTXCS</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<div class="app-shell">
<aside class="sidebar">
<h2>NOTXCS</h2>
<div class="user-info">Signed in as <strong id="username-display"></strong></div>
<button class="secondary" id="logout-btn" style="margin-bottom: 16px;">Log out</button>
<ul class="place-list" id="place-list"></ul>
<div class="card" style="margin-top: 16px;">
<h3 style="margin-top:0;font-size:14px;">Add place</h3>
<form id="add-place-form">
<label for="place-id">Place ID</label>
<input type="text" id="place-id" required>
<label for="place-api-key">API Key</label>
<input type="text" id="place-api-key" required>
<button type="submit" class="primary">Add place</button>
</form>
</div>
</aside>
<main class="main">
<div id="no-place" class="empty-state">
Select or create a place from the sidebar to manage its readers.
</div>
<div id="place-view" class="hidden">
<div class="main-header">
<h1 id="place-title"></h1>
<button class="danger" id="delete-place-btn">Delete place</button>
</div>
<div class="card">
<h3>Add reader</h3>
<form id="add-reader-form" class="inline-form">
<div class="field-group">
<label for="reader-id">Reader ID</label>
<input type="text" id="reader-id" required>
</div>
<div class="field-group">
<label for="reader-name">Name</label>
<input type="text" id="reader-name" required>
</div>
<button type="submit" class="primary">Add reader</button>
</form>
</div>
<div class="card">
<h3>Readers</h3>
<div id="readers-empty" class="empty-state hidden">No readers yet for this place.</div>
<div class="readers-grid" id="readers-grid"></div>
</div>
<div class="card">
<h3>Add access group</h3>
<p style="margin-top:-8px;color:var(--muted);font-size:13px;">Access groups (e.g. "Staff") bundle multiple users/cards/rank rules so they can be assigned to multiple readers at once.</p>
<form id="add-access-group-form" class="inline-form">
<div class="field-group">
<label for="access-group-name">Name</label>
<input type="text" id="access-group-name" required>
</div>
<button type="submit" class="primary">Add group</button>
</form>
</div>
<div class="card">
<h3>Access groups</h3>
<div id="access-groups-empty" class="empty-state hidden">No access groups yet for this place.</div>
<ul class="acl-list" id="access-groups-list"></ul>
</div>
</div>
</main>
</div>
<div id="acl-modal" class="modal-overlay hidden">
<div class="modal">
<div class="modal-header">
<h2>Allowed persons &amp; credentials — <span id="acl-reader-name"></span></h2>
<button class="secondary" id="acl-close-btn">Close</button>
</div>
<div class="card">
<h3>Add entry</h3>
<form id="add-acl-form" class="inline-form">
<div class="field-group">
<label for="acl-type">Type</label>
<select id="acl-type">
<option value="0">User ID</option>
<option value="1">Card number</option>
<option value="2">Group exact rank</option>
<option value="3">Group minimum rank</option>
<option value="4">Allow all</option>
<option value="5">Access group</option>
</select>
</div>
<div class="field-group" id="acl-data-group">
<label for="acl-data">User ID</label>
<input type="text" id="acl-data">
</div>
<div class="field-group hidden" id="acl-group-id-group">
<label for="acl-group-id">Group ID</label>
<input type="text" id="acl-group-id">
</div>
<div class="field-group hidden" id="acl-group-rank-group">
<label for="acl-group-rank">Rank</label>
<input type="number" id="acl-group-rank" min="0">
</div>
<div class="field-group hidden" id="acl-access-group-group">
<label for="acl-access-group">Access group</label>
<select id="acl-access-group"></select>
</div>
<button type="submit" class="primary">Add entry</button>
</form>
</div>
<div class="card">
<h3>Entries</h3>
<div id="acl-empty" class="empty-state hidden">No entries yet. Access will be denied for everyone until an entry is added.</div>
<ul class="acl-list" id="acl-list"></ul>
</div>
</div>
</div>
<div id="logs-modal" class="modal-overlay hidden">
<div class="modal">
<div class="modal-header">
<h2>Scan logs — <span id="logs-reader-name"></span></h2>
<button class="secondary" id="logs-close-btn">Close</button>
</div>
<div class="card">
<h3>Recent scans</h3>
<div id="logs-empty" class="empty-state hidden">No scans have been recorded for this reader yet.</div>
<ul class="acl-list" id="logs-list"></ul>
</div>
</div>
</div>
<div id="group-modal" class="modal-overlay hidden">
<div class="modal">
<div class="modal-header">
<h2>Access group members — <span id="group-name"></span></h2>
<button class="secondary" id="group-close-btn">Close</button>
</div>
<div class="card">
<h3>Add member</h3>
<form id="add-group-member-form" class="inline-form">
<div class="field-group">
<label for="group-member-type">Type</label>
<select id="group-member-type">
<option value="0">User ID</option>
<option value="1">Card number</option>
<option value="2">Group exact rank</option>
<option value="3">Group minimum rank</option>
<option value="4">Allow all</option>
</select>
</div>
<div class="field-group" id="group-member-data-group">
<label for="group-member-data">User ID</label>
<input type="text" id="group-member-data">
</div>
<div class="field-group hidden" id="group-member-group-id-group">
<label for="group-member-group-id">Group ID</label>
<input type="text" id="group-member-group-id">
</div>
<div class="field-group hidden" id="group-member-group-rank-group">
<label for="group-member-group-rank">Rank</label>
<input type="number" id="group-member-group-rank" min="0">
</div>
<button type="submit" class="primary">Add member</button>
</form>
</div>
<div class="card">
<h3>Members</h3>
<div id="group-members-empty" class="empty-state hidden">No members yet. This group grants no access until members are added.</div>
<ul class="acl-list" id="group-members-list"></ul>
</div>
</div>
</div>
<script src="/js/dashboard.js"></script>
</body>
</html>

14
public/index.html Normal file
View file

@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>NOTXCS</title>
<script>
fetch('/auth/me').then(r => r.json()).then(data => {
window.location.replace(data.success ? '/dashboard.html' : '/login.html');
}).catch(() => window.location.replace('/login.html'));
</script>
</head>
<body>
</body>
</html>

615
public/js/dashboard.js Normal file
View file

@ -0,0 +1,615 @@
let currentPlaceId = null;
let places = [];
let accessGroups = [];
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 || []);
await loadAccessGroups();
}
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 = `
<div class="reader-name">${escapeHtml(ap.name)}</div>
<div class="reader-id">${escapeHtml(ap.id)}</div>
<div class="field">
<span>Enabled</span>
<input type="checkbox" data-field="enabled" ${ap.enabled ? 'checked' : ''}>
</div>
<div class="field">
<span>Armed</span>
<input type="checkbox" data-field="armState" ${ap.armState ? 'checked' : ''}>
</div>
<div class="field">
<span>Unlock time (s)</span>
<input type="number" data-field="unlockTime" value="${ap.unlockTime}" min="0">
</div>
<div class="actions">
<button class="secondary save-btn">Save</button>
<button class="secondary acl-btn">Manage access</button>
<button class="secondary logs-btn">View logs</button>
<button class="danger delete-btn">Delete</button>
</div>
`;
card.querySelector('.save-btn').addEventListener('click', () => saveReader(ap.id, card));
card.querySelector('.delete-btn').addEventListener('click', () => deleteReader(ap.id));
card.querySelector('.acl-btn').addEventListener('click', () => openAclModal(ap.id, ap.name));
card.querySelector('.logs-btn').addEventListener('click', () => openLogsModal(ap.id, ap.name));
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';
});
// --- ACL (allowed persons / credentials) management ---
let currentAclReaderId = null;
const ACL_TYPE_LABELS = {
0: 'User ID',
1: 'Card number',
2: 'Group exact rank',
3: 'Group min rank',
4: 'Allow all',
5: 'Access group'
};
function aclEntryDescription(entry) {
switch (entry.type) {
case 0:
return `User ID: ${entry.data}`;
case 1:
return `Card number: ${entry.data}`;
case 2: {
const [group, rank] = entry.data.split(':');
return `Group ${group}, exact rank ${rank}`;
}
case 3: {
const [group, rank] = entry.data.split(':');
return `Group ${group}, rank ${rank}+`;
}
case 4:
return 'Everyone';
case 5: {
const group = accessGroups.find(g => String(g.id) === String(entry.data));
return `Access group: ${group ? group.name : entry.data}`;
}
default:
return entry.data;
}
}
function updateAclFormFields() {
const type = parseInt(document.getElementById('acl-type').value, 10);
const dataGroup = document.getElementById('acl-data-group');
const groupIdGroup = document.getElementById('acl-group-id-group');
const groupRankGroup = document.getElementById('acl-group-rank-group');
const accessGroupGroup = document.getElementById('acl-access-group-group');
const dataLabel = dataGroup.querySelector('label');
const dataInput = document.getElementById('acl-data');
dataGroup.classList.add('hidden');
groupIdGroup.classList.add('hidden');
groupRankGroup.classList.add('hidden');
accessGroupGroup.classList.add('hidden');
dataInput.required = false;
if (type === 0) {
dataLabel.textContent = 'User ID';
dataGroup.classList.remove('hidden');
dataInput.required = true;
} else if (type === 1) {
dataLabel.textContent = 'Card number';
dataGroup.classList.remove('hidden');
dataInput.required = true;
} else if (type === 2 || type === 3) {
groupIdGroup.classList.remove('hidden');
groupRankGroup.classList.remove('hidden');
} else if (type === 5) {
accessGroupGroup.classList.remove('hidden');
populateAccessGroupSelect();
}
// type === 4 (Allow all) needs no extra input
}
function populateAccessGroupSelect() {
const select = document.getElementById('acl-access-group');
select.innerHTML = '';
accessGroups.forEach((group) => {
const option = document.createElement('option');
option.value = group.id;
option.textContent = group.name;
select.appendChild(option);
});
}
document.getElementById('acl-type').addEventListener('change', updateAclFormFields);
updateAclFormFields();
async function openAclModal(readerId, readerName) {
currentAclReaderId = readerId;
document.getElementById('acl-reader-name').textContent = readerName;
document.getElementById('acl-modal').classList.remove('hidden');
document.getElementById('add-acl-form').reset();
updateAclFormFields();
await loadAcl();
}
function closeAclModal() {
currentAclReaderId = null;
document.getElementById('acl-modal').classList.add('hidden');
}
async function loadAcl() {
if (!currentAclReaderId) return;
const data = await api(`/dashboard/places/${encodeURIComponent(currentPlaceId)}/access-points/${encodeURIComponent(currentAclReaderId)}/acl`);
renderAclList(data.acl || []);
}
function renderAclList(entries) {
const list = document.getElementById('acl-list');
const empty = document.getElementById('acl-empty');
list.innerHTML = '';
if (entries.length === 0) {
empty.classList.remove('hidden');
return;
}
empty.classList.add('hidden');
entries.forEach((entry) => {
const li = document.createElement('li');
li.className = 'acl-entry';
li.innerHTML = `
<span class="badge type-badge">${escapeHtml(ACL_TYPE_LABELS[entry.type] || 'Unknown')}</span>
<span class="acl-description">${escapeHtml(aclEntryDescription(entry))}</span>
<button class="danger acl-delete-btn">Remove</button>
`;
li.querySelector('.acl-delete-btn').addEventListener('click', () => deleteAclEntry(entry.id));
list.appendChild(li);
});
}
async function deleteAclEntry(aclId) {
if (!currentAclReaderId) return;
if (!confirm('Remove this entry?')) return;
await api(`/dashboard/places/${encodeURIComponent(currentPlaceId)}/access-points/${encodeURIComponent(currentAclReaderId)}/acl/${encodeURIComponent(aclId)}`, {
method: 'DELETE'
});
await loadAcl();
}
document.getElementById('add-acl-form').addEventListener('submit', async (e) => {
e.preventDefault();
if (!currentAclReaderId) return;
const type = parseInt(document.getElementById('acl-type').value, 10);
let data;
if (type === 0 || type === 1) {
data = document.getElementById('acl-data').value.trim();
if (!data) return;
} else if (type === 2 || type === 3) {
const groupId = document.getElementById('acl-group-id').value.trim();
const rank = document.getElementById('acl-group-rank').value.trim();
if (!groupId || !rank) return;
data = `${groupId}:${rank}`;
} else if (type === 4) {
data = '*';
} else if (type === 5) {
data = document.getElementById('acl-access-group').value;
if (!data) return;
}
const result = await api(`/dashboard/places/${encodeURIComponent(currentPlaceId)}/access-points/${encodeURIComponent(currentAclReaderId)}/acl`, {
method: 'POST',
body: JSON.stringify({ type, data })
});
if (result.success) {
document.getElementById('add-acl-form').reset();
updateAclFormFields();
await loadAcl();
} else {
alert(result.message || 'Failed to add entry');
}
});
document.getElementById('acl-close-btn').addEventListener('click', closeAclModal);
document.getElementById('acl-modal').addEventListener('click', (e) => {
if (e.target.id === 'acl-modal') closeAclModal();
});
// --- Access groups (bundles of users/cards/rank rules assignable to multiple readers) ---
async function loadAccessGroups() {
if (!currentPlaceId) return;
const data = await api(`/dashboard/places/${encodeURIComponent(currentPlaceId)}/access-groups`);
accessGroups = data.groups || [];
renderAccessGroupsList();
}
function renderAccessGroupsList() {
const list = document.getElementById('access-groups-list');
const empty = document.getElementById('access-groups-empty');
list.innerHTML = '';
if (accessGroups.length === 0) {
empty.classList.remove('hidden');
return;
}
empty.classList.add('hidden');
accessGroups.forEach((group) => {
const li = document.createElement('li');
li.className = 'acl-entry';
li.innerHTML = `
<span class="badge type-badge">Group</span>
<span class="acl-description">${escapeHtml(group.name)}</span>
<button class="secondary group-manage-btn">Manage members</button>
<button class="danger group-delete-btn">Delete</button>
`;
li.querySelector('.group-manage-btn').addEventListener('click', () => openGroupModal(group.id, group.name));
li.querySelector('.group-delete-btn').addEventListener('click', () => deleteAccessGroup(group.id));
list.appendChild(li);
});
}
async function deleteAccessGroup(groupId) {
if (!confirm('Delete this access group? It will be removed from any readers it was assigned to.')) return;
await api(`/dashboard/places/${encodeURIComponent(currentPlaceId)}/access-groups/${encodeURIComponent(groupId)}`, {
method: 'DELETE'
});
await loadAccessGroups();
}
document.getElementById('add-access-group-form').addEventListener('submit', async (e) => {
e.preventDefault();
if (!currentPlaceId) return;
const name = document.getElementById('access-group-name').value.trim();
if (!name) return;
const result = await api(`/dashboard/places/${encodeURIComponent(currentPlaceId)}/access-groups`, {
method: 'POST',
body: JSON.stringify({ name })
});
if (result.success) {
document.getElementById('access-group-name').value = '';
await loadAccessGroups();
} else {
alert(result.message || 'Failed to add access group');
}
});
// --- Access group members management ---
let currentGroupId = null;
function updateGroupMemberFormFields() {
const type = parseInt(document.getElementById('group-member-type').value, 10);
const dataGroup = document.getElementById('group-member-data-group');
const groupIdGroup = document.getElementById('group-member-group-id-group');
const groupRankGroup = document.getElementById('group-member-group-rank-group');
const dataLabel = dataGroup.querySelector('label');
const dataInput = document.getElementById('group-member-data');
dataGroup.classList.add('hidden');
groupIdGroup.classList.add('hidden');
groupRankGroup.classList.add('hidden');
dataInput.required = false;
if (type === 0) {
dataLabel.textContent = 'User ID';
dataGroup.classList.remove('hidden');
dataInput.required = true;
} else if (type === 1) {
dataLabel.textContent = 'Card number';
dataGroup.classList.remove('hidden');
dataInput.required = true;
} else if (type === 2 || type === 3) {
groupIdGroup.classList.remove('hidden');
groupRankGroup.classList.remove('hidden');
}
// type === 4 (Allow all) needs no extra input
}
document.getElementById('group-member-type').addEventListener('change', updateGroupMemberFormFields);
updateGroupMemberFormFields();
async function openGroupModal(groupId, groupName) {
currentGroupId = groupId;
document.getElementById('group-name').textContent = groupName;
document.getElementById('group-modal').classList.remove('hidden');
document.getElementById('add-group-member-form').reset();
updateGroupMemberFormFields();
await loadGroupMembers();
}
function closeGroupModal() {
currentGroupId = null;
document.getElementById('group-modal').classList.add('hidden');
}
async function loadGroupMembers() {
if (!currentGroupId) return;
const data = await api(`/dashboard/places/${encodeURIComponent(currentPlaceId)}/access-groups/${encodeURIComponent(currentGroupId)}/members`);
renderGroupMembersList(data.members || []);
}
function renderGroupMembersList(members) {
const list = document.getElementById('group-members-list');
const empty = document.getElementById('group-members-empty');
list.innerHTML = '';
if (members.length === 0) {
empty.classList.remove('hidden');
return;
}
empty.classList.add('hidden');
members.forEach((member) => {
const li = document.createElement('li');
li.className = 'acl-entry';
li.innerHTML = `
<span class="badge type-badge">${escapeHtml(ACL_TYPE_LABELS[member.type] || 'Unknown')}</span>
<span class="acl-description">${escapeHtml(aclEntryDescription(member))}</span>
<button class="danger group-member-delete-btn">Remove</button>
`;
li.querySelector('.group-member-delete-btn').addEventListener('click', () => deleteGroupMember(member.id));
list.appendChild(li);
});
}
async function deleteGroupMember(memberId) {
if (!currentGroupId) return;
if (!confirm('Remove this member?')) return;
await api(`/dashboard/places/${encodeURIComponent(currentPlaceId)}/access-groups/${encodeURIComponent(currentGroupId)}/members/${encodeURIComponent(memberId)}`, {
method: 'DELETE'
});
await loadGroupMembers();
}
document.getElementById('add-group-member-form').addEventListener('submit', async (e) => {
e.preventDefault();
if (!currentGroupId) return;
const type = parseInt(document.getElementById('group-member-type').value, 10);
let data;
if (type === 0 || type === 1) {
data = document.getElementById('group-member-data').value.trim();
if (!data) return;
} else if (type === 2 || type === 3) {
const groupId = document.getElementById('group-member-group-id').value.trim();
const rank = document.getElementById('group-member-group-rank').value.trim();
if (!groupId || !rank) return;
data = `${groupId}:${rank}`;
} else if (type === 4) {
data = '*';
}
const result = await api(`/dashboard/places/${encodeURIComponent(currentPlaceId)}/access-groups/${encodeURIComponent(currentGroupId)}/members`, {
method: 'POST',
body: JSON.stringify({ type, data })
});
if (result.success) {
document.getElementById('add-group-member-form').reset();
updateGroupMemberFormFields();
await loadGroupMembers();
} else {
alert(result.message || 'Failed to add member');
}
});
document.getElementById('group-close-btn').addEventListener('click', closeGroupModal);
document.getElementById('group-modal').addEventListener('click', (e) => {
if (e.target.id === 'group-modal') closeGroupModal();
});
// --- Scan logs viewing ---
let currentLogsReaderId = null;
async function openLogsModal(readerId, readerName) {
currentLogsReaderId = readerId;
document.getElementById('logs-reader-name').textContent = readerName;
document.getElementById('logs-modal').classList.remove('hidden');
await loadLogs();
}
function closeLogsModal() {
currentLogsReaderId = null;
document.getElementById('logs-modal').classList.add('hidden');
}
async function loadLogs() {
if (!currentLogsReaderId) return;
const data = await api(`/dashboard/places/${encodeURIComponent(currentPlaceId)}/access-points/${encodeURIComponent(currentLogsReaderId)}/logs`);
renderLogsList(data.logs || []);
}
function renderLogsList(logs) {
const list = document.getElementById('logs-list');
const empty = document.getElementById('logs-empty');
list.innerHTML = '';
if (logs.length === 0) {
empty.classList.remove('hidden');
return;
}
empty.classList.add('hidden');
logs.forEach((log) => {
const li = document.createElement('li');
li.className = 'acl-entry';
const granted = !!log.granted;
const cards = log.cardNumbers ? `, cards: ${escapeHtml(log.cardNumbers)}` : '';
li.innerHTML = `
<span class="badge type-badge" style="background:${granted ? 'var(--success)' : 'var(--danger)'};">${granted ? 'Granted' : 'Denied'}</span>
<span class="acl-description">${escapeHtml(log.scannedAt)} user: ${escapeHtml(log.userId || 'unknown')}${cards}</span>
`;
list.appendChild(li);
});
}
document.getElementById('logs-close-btn').addEventListener('click', closeLogsModal);
document.getElementById('logs-modal').addEventListener('click', (e) => {
if (e.target.id === 'logs-modal') closeLogsModal();
});
init();

28
public/js/login.js Normal file
View file

@ -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');
}
});

28
public/js/register.js Normal file
View file

@ -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');
}
});

29
public/login.html Normal file
View file

@ -0,0 +1,29 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login - NOTXCS</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<div class="auth-wrapper">
<div class="auth-card">
<h1>Welcome back</h1>
<p class="subtitle">Sign in to manage your readers and settings.</p>
<div id="error" class="error"></div>
<form id="login-form">
<label for="username">Username</label>
<input type="text" id="username" name="username" autocomplete="username" required>
<label for="password">Password</label>
<input type="password" id="password" name="password" autocomplete="current-password" required>
<button type="submit" class="primary">Log in</button>
</form>
<p class="switch-link">Don't have an account? <a href="/register.html">Register</a></p>
</div>
</div>
<script src="/js/login.js"></script>
</body>
</html>

35
public/preview.html Normal file
View file

@ -0,0 +1,35 @@
<!DOCTYPE html>
<html><head><link rel="stylesheet" href="http://localhost:8931/css/style.css">
<style>body{background:#0b0d11;padding:20px;color:#fff}</style>
</head>
<body>
<h3 style="color:#eee">Readers</h3>
<div class="readers-grid" style="max-width:700px">
<div class="reader-card">
<div class="reader-name">Test A</div>
<div class="reader-id">1111</div>
<div class="field"><span>Enabled</span><input type="checkbox" checked></div>
<div class="field"><span>Armed</span><input type="checkbox"></div>
<div class="field"><span>Unlock time (s)</span><input type="number" value="8"></div>
<div class="actions">
<button class="secondary">Save</button>
<button class="secondary">Manage access</button>
<button class="secondary">View logs</button>
<button class="danger">Delete</button>
</div>
</div>
<div class="reader-card">
<div class="reader-name">Test B</div>
<div class="reader-id">2222</div>
<div class="field"><span>Enabled</span><input type="checkbox" checked></div>
<div class="field"><span>Armed</span><input type="checkbox"></div>
<div class="field"><span>Unlock time (s)</span><input type="number" value="8"></div>
<div class="actions">
<button class="secondary">Save</button>
<button class="secondary">Manage access</button>
<button class="secondary">View logs</button>
<button class="danger">Delete</button>
</div>
</div>
</div>
</body></html>

29
public/register.html Normal file
View file

@ -0,0 +1,29 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Register - NOTXCS</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<div class="auth-wrapper">
<div class="auth-card">
<h1>Create an account</h1>
<p class="subtitle">Register to start managing your readers.</p>
<div id="error" class="error"></div>
<form id="register-form">
<label for="username">Username</label>
<input type="text" id="username" name="username" autocomplete="username" minlength="3" required>
<label for="password">Password</label>
<input type="password" id="password" name="password" autocomplete="new-password" minlength="6" required>
<button type="submit" class="primary">Register</button>
</form>
<p class="switch-link">Already have an account? <a href="/login.html">Log in</a></p>
</div>
</div>
<script src="/js/register.js"></script>
</body>
</html>

View file

@ -113,47 +113,78 @@ router.get('/:placeId/:accessPointId/onScan', async (req, res) => {
return res.status(500).json({ success: false, message: "Internal server error" }); return res.status(500).json({ success: false, message: "Internal server error" });
} }
// Access group entries (type 5) reference an access_groups.id in `data`. Resolve their
// members up front so the group's users/cards/ranks are checked just like direct entries.
const groupIds = [...new Set(aclEntries.filter(e => e.type === 5).map(e => e.data))];
let groupMembersById = {};
if (groupIds.length > 0) {
const placeholders = groupIds.map(() => '?').join(',');
groupMembersById = await new Promise((resolve) => {
db.all(`SELECT * FROM access_group_members WHERE groupId IN (${placeholders})`, groupIds, (err, members) => {
if (err) {
console.error('Failed to retrieve access group members:', err.message);
return resolve({});
}
const byGroup = {};
for (const member of members) {
// Groups can only contain direct credentials (types 0-4); the API rejects
// nested groups on write, but skip any type 5 here too as defense-in-depth
// against infinite recursion if the database is ever modified directly.
if (member.type === 5) continue;
if (!byGroup[member.groupId]) byGroup[member.groupId] = [];
byGroup[member.groupId].push(member);
}
resolve(byGroup);
});
});
}
let userGroups = await fetch(`https://groups.roblox.com/v1/users/${userId}/groups/roles`).then(r => r.json()).then(data => data.data || []).catch(() => []); let userGroups = await fetch(`https://groups.roblox.com/v1/users/${userId}/groups/roles`).then(r => r.json()).then(data => data.data || []).catch(() => []);
userGroups = userGroups.map(g => ({group: g.group.id, rank: g.role.rank})); userGroups = userGroups.map(g => ({group: g.group.id, rank: g.role.rank}));
console.log('User groups:', userGroups); console.log('User groups:', userGroups);
let grant_type;
let granted = false; // Types: 0 = User ID; 1 = Card Number; 2 = Group Exact Rank; 3 = Group Min Rank; 4 = Allow All; 5 = Access Group
// Types: 0 = User ID; 1 = Card Number; 2 = Group Exact Rank; 3 = Group Min Rank, 4 = Allow All function matchesEntry(entry) {
// Check user ID, then group ranks, then card numbers.
for (const entry of aclEntries) {
switch (entry.type) { switch (entry.type) {
case 0: // User ID case 0: // User ID
if (entry.data == userId) { return entry.data == userId;
granted = true;
}
break;
case 1: // Card Number case 1: // Card Number
if (cardNumbers.includes(entry.data)) { return cardNumbers.includes(entry.data);
granted = true;
}
break;
case 2: { // Group Exact Rank case 2: { // Group Exact Rank
const [group, rank] = entry.data.split(':'); const [group, rank] = entry.data.split(':');
if (userGroups.some(g => g.group == group && g.rank == rank)) { return userGroups.some(g => g.group == group && g.rank == rank);
granted = true;
}
break;
} }
case 3: { // Group Min Rank case 3: { // Group Min Rank
const [group, rank] = entry.data.split(':'); const [group, rank] = entry.data.split(':');
if (userGroups.some(g => g.group == group && g.rank >= rank)) { return userGroups.some(g => g.group == group && g.rank >= rank);
granted = true;
}
break;
} }
case 4: // Allow All case 4: // Allow All
return true;
case 5: // Access Group - granted if any credential in the group matches.
return (groupMembersById[entry.data] || []).some(matchesEntry);
default:
return false;
}
}
let granted = false;
// Check every entry (users, cards, group ranks, then access groups made up of the above).
for (const entry of aclEntries) {
if (matchesEntry(entry)) {
granted = true; granted = true;
break; break;
} }
if (granted) break;
} }
const responseCode = granted ? 'access_granted' : 'access_denied';
db.run(
`INSERT INTO scan_logs (placeId, accessPoint, userId, cardNumbers, granted, responseCode) VALUES (?, ?, ?, ?, ?, ?)`,
[placeId, accessPointId, userId || null, cardNumbers.join(','), granted ? 1 : 0, responseCode],
(err) => {
if (err) console.error('Failed to record scan log:', err.message);
}
);
const responseData = granted ? { const responseData = granted ? {
success: true, success: true,
grant_type: 'user_scan', grant_type: 'user_scan',

104
routes/auth.js Normal file
View file

@ -0,0 +1,104 @@
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" });
}
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) 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) {
return res.status(401).json({ success: false, message: "Invalid username or password" });
}
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 } });
});
});
});
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 } });
});

432
routes/dashboard.js Normal file
View file

@ -0,0 +1,432 @@
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/access groups/scan logs)
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();
// 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);
db.run(`DELETE FROM scan_logs 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.all(`SELECT id FROM access_groups WHERE placeId = ?`, [req.place.id], (err, groups) => {
if (err) {
console.error('Failed to retrieve access groups for place:', err.message);
return res.status(500).json({ success: false, message: "Internal server error" });
}
const groupIds = groups.map(g => g.id);
const deleteGroups = (cb) => {
if (groupIds.length === 0) return cb();
const placeholders = groupIds.map(() => '?').join(',');
db.run(`DELETE FROM access_group_members WHERE groupId IN (${placeholders})`, groupIds, (err) => {
if (err) return cb(err);
db.run(`DELETE FROM access_groups WHERE placeId = ?`, [req.place.id], cb);
});
};
deleteGroups((err) => {
if (err) {
console.error('Failed to delete access groups 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 scan_logs WHERE accessPoint = ?`, [req.accessPoint.id], (err) => {
if (err) {
console.error('Failed to delete scan logs 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 scan logs for a reader (most recent first)
router.get('/places/:placeId/access-points/:accessPointId/logs', loadOwnedPlace, 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 ?`,
[req.accessPoint.id, limit],
(err, logs) => {
if (err) {
console.error('Failed to retrieve scan logs:', err.message);
return res.status(500).json({ success: false, message: "Internal server error" });
}
res.json({ success: true, logs });
}
);
});
// 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 });
});
});
function loadOwnedAccessGroup(req, res, next) {
db.get(`SELECT * FROM access_groups WHERE id = ? AND placeId = ?`, [req.params.groupId, req.place.id], (err, group) => {
if (err) {
console.error('Failed to retrieve access group:', err.message);
return res.status(500).json({ success: false, message: "Internal server error" });
}
if (!group) {
return res.status(404).json({ success: false, message: "Access group not found" });
}
req.accessGroup = group;
next();
});
}
// List access groups for a place
router.get('/places/:placeId/access-groups', loadOwnedPlace, (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);
return res.status(500).json({ success: false, message: "Internal server error" });
}
res.json({ success: true, groups });
});
});
// Create an access group (e.g. "Staff") for a place
router.post('/places/:placeId/access-groups', loadOwnedPlace, (req, res) => {
const { name } = req.body || {};
if (!name) {
return res.status(400).json({ success: false, message: "Access group name is required" });
}
db.run(`INSERT INTO access_groups (placeId, name) VALUES (?, ?)`, [req.place.id, name], function (err) {
if (err) {
console.error('Failed to create access group:', err.message);
return res.status(500).json({ success: false, message: "Internal server error" });
}
res.json({ success: true, group: { id: this.lastID, placeId: req.place.id, name } });
});
});
// Rename an access group
router.put('/places/:placeId/access-groups/:groupId', loadOwnedPlace, 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" });
}
db.run(`UPDATE access_groups SET name = ? WHERE id = ?`, [name, req.accessGroup.id], (err) => {
if (err) {
console.error('Failed to update access group:', err.message);
return res.status(500).json({ success: false, message: "Internal server error" });
}
res.json({ success: true });
});
});
// Delete an access group (and its members, and any ACL entries referencing it)
router.delete('/places/:placeId/access-groups/:groupId', loadOwnedPlace, 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);
return res.status(500).json({ success: false, message: "Internal server error" });
}
// Access group ACL entries store the group id in `data` with type 5.
db.run(`DELETE FROM acl WHERE type = 5 AND data = ?`, [String(req.accessGroup.id)], (err) => {
if (err) {
console.error('Failed to delete ACL entries referencing access group:', err.message);
return res.status(500).json({ success: false, message: "Internal server error" });
}
db.run(`DELETE FROM access_groups WHERE id = ?`, [req.accessGroup.id], (err) => {
if (err) {
console.error('Failed to delete access group:', err.message);
return res.status(500).json({ success: false, message: "Internal server error" });
}
res.json({ success: true });
});
});
});
});
// List members (creds) of an access group
router.get('/places/:placeId/access-groups/:groupId/members', loadOwnedPlace, 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);
return res.status(500).json({ success: false, message: "Internal server error" });
}
res.json({ success: true, members });
});
});
// 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) => {
const { type, data } = req.body || {};
if (type === undefined || !data) {
return res.status(400).json({ success: false, message: "Member type and data are required" });
}
// Groups cannot contain other groups, to avoid nested/circular resolution.
if (parseInt(type, 10) === 5) {
return res.status(400).json({ success: false, message: "An access group cannot contain another access group, to prevent circular/nested resolution" });
}
db.run(`INSERT INTO access_group_members (groupId, type, data) VALUES (?, ?, ?)`, [req.accessGroup.id, type, data], function (err) {
if (err) {
console.error('Failed to add access group member:', err.message);
return res.status(500).json({ success: false, message: "Internal server error" });
}
res.json({ success: true, id: this.lastID });
});
});
// Remove a member from an access group
router.delete('/places/:placeId/access-groups/:groupId/members/:memberId', loadOwnedPlace, 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);
return res.status(500).json({ success: false, message: "Internal server error" });
}
res.json({ success: true });
});
});