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

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

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