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/migrations/0003_access_groups_and_logs.sql b/migrations/0003_access_groups_and_logs.sql
new file mode 100644
index 0000000..bfa021f
--- /dev/null
+++ b/migrations/0003_access_groups_and_logs.sql
@@ -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);
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..7ba063c
--- /dev/null
+++ b/public/css/style.css
@@ -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;
+}
diff --git a/public/dashboard.html b/public/dashboard.html
new file mode 100644
index 0000000..51c430e
--- /dev/null
+++ b/public/dashboard.html
@@ -0,0 +1,194 @@
+
+
+
+
+
+ Dashboard - NOTXCS
+
+
+
+
+
+
+
+
+ Select or create a place from the sidebar to manage its readers.
+
+
+
+
+
+
+
+
+
+
+
+
Readers
+
No readers yet for this place.
+
+
+
+
+
Add access group
+
Access groups (e.g. "Staff") bundle multiple users/cards/rank rules so they can be assigned to multiple readers at once.
+
+
+
+
+
Access groups
+
No access groups yet for this place.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Entries
+
No entries yet. Access will be denied for everyone until an entry is added.
+
+
+
+
+
+
+
+
+
+
+
Recent scans
+
No scans have been recorded for this reader yet.
+
+
+
+
+
+
+
+
+
+
+
+
+
Members
+
No members yet. This group grants no access until members are added.
+
+
+
+
+
+
+
+
+
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..93fa45d
--- /dev/null
+++ b/public/js/dashboard.js
@@ -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 = `
+ ${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));
+ 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 = `
+ ${escapeHtml(ACL_TYPE_LABELS[entry.type] || 'Unknown')}
+ ${escapeHtml(aclEntryDescription(entry))}
+
+ `;
+ 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 = `
+ Group
+ ${escapeHtml(group.name)}
+
+
+ `;
+ 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 = `
+ ${escapeHtml(ACL_TYPE_LABELS[member.type] || 'Unknown')}
+ ${escapeHtml(aclEntryDescription(member))}
+
+ `;
+ 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 = `
+ ${granted ? 'Granted' : 'Denied'}
+ ${escapeHtml(log.scannedAt)} — user: ${escapeHtml(log.userId || 'unknown')}${cards}
+ `;
+ 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();
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/preview.html b/public/preview.html
new file mode 100644
index 0000000..ad94c15
--- /dev/null
+++ b/public/preview.html
@@ -0,0 +1,35 @@
+
+
+
+
+
+Readers
+
+
+
Test A
+
1111
+
Enabled
+
Armed
+
Unlock time (s)
+
+
+
+
+
+
+
+
+
Test B
+
2222
+
Enabled
+
Armed
+
Unlock time (s)
+
+
+
+
+
+
+
+
+
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/api/v1/frontend.js b/routes/api/v1/frontend.js
deleted file mode 100644
index e69de29..0000000
diff --git a/routes/api/v1/ingame.js b/routes/api/v1/ingame.js
index 1d69c63..d1ba8c7 100644
--- a/routes/api/v1/ingame.js
+++ b/routes/api/v1/ingame.js
@@ -113,47 +113,78 @@ router.get('/:placeId/:accessPointId/onScan', async (req, res) => {
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(() => []);
userGroups = userGroups.map(g => ({group: g.group.id, rank: g.role.rank}));
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
- // Check user ID, then group ranks, then card numbers.
- for (const entry of aclEntries) {
+
+ // Types: 0 = User ID; 1 = Card Number; 2 = Group Exact Rank; 3 = Group Min Rank; 4 = Allow All; 5 = Access Group
+ function matchesEntry(entry) {
switch (entry.type) {
case 0: // User ID
- if (entry.data == userId) {
- granted = true;
- }
- break;
+ return entry.data == userId;
case 1: // Card Number
- if (cardNumbers.includes(entry.data)) {
- granted = true;
- }
- break;
+ return cardNumbers.includes(entry.data);
case 2: { // Group Exact Rank
const [group, rank] = entry.data.split(':');
- if (userGroups.some(g => g.group == group && g.rank == rank)) {
- granted = true;
- }
- break;
+ return userGroups.some(g => g.group == group && g.rank == rank);
}
case 3: { // Group Min Rank
const [group, rank] = entry.data.split(':');
- if (userGroups.some(g => g.group == group && g.rank >= rank)) {
- granted = true;
- }
- break;
+ return userGroups.some(g => g.group == group && g.rank >= rank);
}
case 4: // Allow All
- granted = true;
- break;
+ return true;
+ case 5: // Access Group - granted if any credential in the group matches.
+ return (groupMembersById[entry.data] || []).some(matchesEntry);
+ default:
+ return false;
}
-
- if (granted) break;
}
+ 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;
+ 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 ? {
success: true,
grant_type: 'user_scan',
diff --git a/routes/auth.js b/routes/auth.js
new file mode 100644
index 0000000..e7a84b3
--- /dev/null
+++ b/routes/auth.js
@@ -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 } });
+});
+
diff --git a/routes/dashboard.js b/routes/dashboard.js
new file mode 100644
index 0000000..c1301f9
--- /dev/null
+++ b/routes/dashboard.js
@@ -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 });
+ });
+});
+