Add access groups (staff-style credential bundles) and scan logging

This commit is contained in:
copilot-swe-agent[bot] 2026-07-02 14:44:39 +00:00 committed by GitHub
parent b53fb16398
commit 44ad3addf0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 596 additions and 34 deletions

View file

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

View file

@ -58,6 +58,24 @@
<div id="readers-empty" class="empty-state hidden">No readers yet for this place.</div> <div id="readers-empty" class="empty-state hidden">No readers yet for this place.</div>
<div class="readers-grid" id="readers-grid"></div> <div class="readers-grid" id="readers-grid"></div>
</div> </div>
<div class="card">
<h3>Add access group</h3>
<p style="margin-top:-8px;color:var(--muted);font-size:13px;">Access groups (e.g. "Staff") bundle multiple users/cards/rank rules so they can be assigned to multiple readers at once.</p>
<form id="add-access-group-form" class="inline-form">
<div class="field-group">
<label for="access-group-name">Name</label>
<input type="text" id="access-group-name" required>
</div>
<button type="submit" class="primary">Add group</button>
</form>
</div>
<div class="card">
<h3>Access groups</h3>
<div id="access-groups-empty" class="empty-state hidden">No access groups yet for this place.</div>
<ul class="acl-list" id="access-groups-list"></ul>
</div>
</div> </div>
</main> </main>
</div> </div>
@ -80,6 +98,7 @@
<option value="2">Group exact rank</option> <option value="2">Group exact rank</option>
<option value="3">Group minimum rank</option> <option value="3">Group minimum rank</option>
<option value="4">Allow all</option> <option value="4">Allow all</option>
<option value="5">Access group</option>
</select> </select>
</div> </div>
<div class="field-group" id="acl-data-group"> <div class="field-group" id="acl-data-group">
@ -94,6 +113,10 @@
<label for="acl-group-rank">Rank</label> <label for="acl-group-rank">Rank</label>
<input type="number" id="acl-group-rank" min="0"> <input type="number" id="acl-group-rank" min="0">
</div> </div>
<div class="field-group hidden" id="acl-access-group-group">
<label for="acl-access-group">Access group</label>
<select id="acl-access-group"></select>
</div>
<button type="submit" class="primary">Add entry</button> <button type="submit" class="primary">Add entry</button>
</form> </form>
</div> </div>
@ -106,6 +129,66 @@
</div> </div>
</div> </div>
<div id="logs-modal" class="modal-overlay hidden">
<div class="modal">
<div class="modal-header">
<h2>Scan logs — <span id="logs-reader-name"></span></h2>
<button class="secondary" id="logs-close-btn">Close</button>
</div>
<div class="card">
<h3>Recent scans</h3>
<div id="logs-empty" class="empty-state hidden">No scans have been recorded for this reader yet.</div>
<ul class="acl-list" id="logs-list"></ul>
</div>
</div>
</div>
<div id="group-modal" class="modal-overlay hidden">
<div class="modal">
<div class="modal-header">
<h2>Access group members — <span id="group-name"></span></h2>
<button class="secondary" id="group-close-btn">Close</button>
</div>
<div class="card">
<h3>Add member</h3>
<form id="add-group-member-form" class="inline-form">
<div class="field-group">
<label for="group-member-type">Type</label>
<select id="group-member-type">
<option value="0">User ID</option>
<option value="1">Card number</option>
<option value="2">Group exact rank</option>
<option value="3">Group minimum rank</option>
<option value="4">Allow all</option>
</select>
</div>
<div class="field-group" id="group-member-data-group">
<label for="group-member-data">User ID</label>
<input type="text" id="group-member-data">
</div>
<div class="field-group hidden" id="group-member-group-id-group">
<label for="group-member-group-id">Group ID</label>
<input type="text" id="group-member-group-id">
</div>
<div class="field-group hidden" id="group-member-group-rank-group">
<label for="group-member-group-rank">Rank</label>
<input type="number" id="group-member-group-rank" min="0">
</div>
<button type="submit" class="primary">Add member</button>
</form>
</div>
<div class="card">
<h3>Members</h3>
<div id="group-members-empty" class="empty-state hidden">No members yet. This group grants no access until members are added.</div>
<ul class="acl-list" id="group-members-list"></ul>
</div>
</div>
</div>
<script src="/js/dashboard.js"></script> <script src="/js/dashboard.js"></script>
</body> </body>
</html> </html>

View file

@ -1,5 +1,6 @@
let currentPlaceId = null; let currentPlaceId = null;
let places = []; let places = [];
let accessGroups = [];
async function api(path, options = {}) { async function api(path, options = {}) {
const res = await fetch(path, { const res = await fetch(path, {
@ -56,6 +57,7 @@ async function selectPlace(placeId) {
document.getElementById('place-title').textContent = placeId; document.getElementById('place-title').textContent = placeId;
renderReaders(data.accessPoints || []); renderReaders(data.accessPoints || []);
await loadAccessGroups();
} }
function renderReaders(accessPoints) { function renderReaders(accessPoints) {
@ -90,6 +92,7 @@ function renderReaders(accessPoints) {
<div class="actions"> <div class="actions">
<button class="secondary save-btn">Save</button> <button class="secondary save-btn">Save</button>
<button class="secondary acl-btn">Manage access</button> <button class="secondary acl-btn">Manage access</button>
<button class="secondary logs-btn">View logs</button>
<button class="danger delete-btn">Delete</button> <button class="danger delete-btn">Delete</button>
</div> </div>
`; `;
@ -97,6 +100,7 @@ function renderReaders(accessPoints) {
card.querySelector('.save-btn').addEventListener('click', () => saveReader(ap.id, card)); card.querySelector('.save-btn').addEventListener('click', () => saveReader(ap.id, card));
card.querySelector('.delete-btn').addEventListener('click', () => deleteReader(ap.id)); card.querySelector('.delete-btn').addEventListener('click', () => deleteReader(ap.id));
card.querySelector('.acl-btn').addEventListener('click', () => openAclModal(ap.id, ap.name)); 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); grid.appendChild(card);
}); });
@ -196,7 +200,8 @@ const ACL_TYPE_LABELS = {
1: 'Card number', 1: 'Card number',
2: 'Group exact rank', 2: 'Group exact rank',
3: 'Group min rank', 3: 'Group min rank',
4: 'Allow all' 4: 'Allow all',
5: 'Access group'
}; };
function aclEntryDescription(entry) { function aclEntryDescription(entry) {
@ -215,6 +220,10 @@ function aclEntryDescription(entry) {
} }
case 4: case 4:
return 'Everyone'; return 'Everyone';
case 5: {
const group = accessGroups.find(g => String(g.id) === String(entry.data));
return `Access group: ${group ? group.name : entry.data}`;
}
default: default:
return entry.data; return entry.data;
} }
@ -225,12 +234,14 @@ function updateAclFormFields() {
const dataGroup = document.getElementById('acl-data-group'); const dataGroup = document.getElementById('acl-data-group');
const groupIdGroup = document.getElementById('acl-group-id-group'); const groupIdGroup = document.getElementById('acl-group-id-group');
const groupRankGroup = document.getElementById('acl-group-rank-group'); const groupRankGroup = document.getElementById('acl-group-rank-group');
const accessGroupGroup = document.getElementById('acl-access-group-group');
const dataLabel = dataGroup.querySelector('label'); const dataLabel = dataGroup.querySelector('label');
const dataInput = document.getElementById('acl-data'); const dataInput = document.getElementById('acl-data');
dataGroup.classList.add('hidden'); dataGroup.classList.add('hidden');
groupIdGroup.classList.add('hidden'); groupIdGroup.classList.add('hidden');
groupRankGroup.classList.add('hidden'); groupRankGroup.classList.add('hidden');
accessGroupGroup.classList.add('hidden');
dataInput.required = false; dataInput.required = false;
if (type === 0) { if (type === 0) {
@ -244,10 +255,24 @@ function updateAclFormFields() {
} else if (type === 2 || type === 3) { } else if (type === 2 || type === 3) {
groupIdGroup.classList.remove('hidden'); groupIdGroup.classList.remove('hidden');
groupRankGroup.classList.remove('hidden'); groupRankGroup.classList.remove('hidden');
} else if (type === 5) {
accessGroupGroup.classList.remove('hidden');
populateAccessGroupSelect();
} }
// type === 4 (Allow all) needs no extra input // 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); document.getElementById('acl-type').addEventListener('change', updateAclFormFields);
updateAclFormFields(); updateAclFormFields();
@ -322,6 +347,9 @@ document.getElementById('add-acl-form').addEventListener('submit', async (e) =>
data = `${groupId}:${rank}`; data = `${groupId}:${rank}`;
} else if (type === 4) { } else if (type === 4) {
data = '*'; 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`, { const result = await api(`/dashboard/places/${encodeURIComponent(currentPlaceId)}/access-points/${encodeURIComponent(currentAclReaderId)}/acl`, {
@ -343,4 +371,245 @@ document.getElementById('acl-modal').addEventListener('click', (e) => {
if (e.target.id === 'acl-modal') closeAclModal(); if (e.target.id === 'acl-modal') closeAclModal();
}); });
// --- Access groups (bundles of users/cards/rank rules assignable to multiple readers) ---
async function loadAccessGroups() {
if (!currentPlaceId) return;
const data = await api(`/dashboard/places/${encodeURIComponent(currentPlaceId)}/access-groups`);
accessGroups = data.groups || [];
renderAccessGroupsList();
}
function renderAccessGroupsList() {
const list = document.getElementById('access-groups-list');
const empty = document.getElementById('access-groups-empty');
list.innerHTML = '';
if (accessGroups.length === 0) {
empty.classList.remove('hidden');
return;
}
empty.classList.add('hidden');
accessGroups.forEach((group) => {
const li = document.createElement('li');
li.className = 'acl-entry';
li.innerHTML = `
<span class="badge type-badge">Group</span>
<span class="acl-description">${escapeHtml(group.name)}</span>
<button class="secondary group-manage-btn">Manage members</button>
<button class="danger group-delete-btn">Delete</button>
`;
li.querySelector('.group-manage-btn').addEventListener('click', () => openGroupModal(group.id, group.name));
li.querySelector('.group-delete-btn').addEventListener('click', () => deleteAccessGroup(group.id));
list.appendChild(li);
});
}
async function deleteAccessGroup(groupId) {
if (!confirm('Delete this access group? It will be removed from any readers it was assigned to.')) return;
await api(`/dashboard/places/${encodeURIComponent(currentPlaceId)}/access-groups/${encodeURIComponent(groupId)}`, {
method: 'DELETE'
});
await loadAccessGroups();
}
document.getElementById('add-access-group-form').addEventListener('submit', async (e) => {
e.preventDefault();
if (!currentPlaceId) return;
const name = document.getElementById('access-group-name').value.trim();
if (!name) return;
const result = await api(`/dashboard/places/${encodeURIComponent(currentPlaceId)}/access-groups`, {
method: 'POST',
body: JSON.stringify({ name })
});
if (result.success) {
document.getElementById('access-group-name').value = '';
await loadAccessGroups();
} else {
alert(result.message || 'Failed to add access group');
}
});
// --- Access group members management ---
let currentGroupId = null;
function updateGroupMemberFormFields() {
const type = parseInt(document.getElementById('group-member-type').value, 10);
const dataGroup = document.getElementById('group-member-data-group');
const groupIdGroup = document.getElementById('group-member-group-id-group');
const groupRankGroup = document.getElementById('group-member-group-rank-group');
const dataLabel = dataGroup.querySelector('label');
const dataInput = document.getElementById('group-member-data');
dataGroup.classList.add('hidden');
groupIdGroup.classList.add('hidden');
groupRankGroup.classList.add('hidden');
dataInput.required = false;
if (type === 0) {
dataLabel.textContent = 'User ID';
dataGroup.classList.remove('hidden');
dataInput.required = true;
} else if (type === 1) {
dataLabel.textContent = 'Card number';
dataGroup.classList.remove('hidden');
dataInput.required = true;
} else if (type === 2 || type === 3) {
groupIdGroup.classList.remove('hidden');
groupRankGroup.classList.remove('hidden');
}
// type === 4 (Allow all) needs no extra input
}
document.getElementById('group-member-type').addEventListener('change', updateGroupMemberFormFields);
updateGroupMemberFormFields();
async function openGroupModal(groupId, groupName) {
currentGroupId = groupId;
document.getElementById('group-name').textContent = groupName;
document.getElementById('group-modal').classList.remove('hidden');
document.getElementById('add-group-member-form').reset();
updateGroupMemberFormFields();
await loadGroupMembers();
}
function closeGroupModal() {
currentGroupId = null;
document.getElementById('group-modal').classList.add('hidden');
}
async function loadGroupMembers() {
if (!currentGroupId) return;
const data = await api(`/dashboard/places/${encodeURIComponent(currentPlaceId)}/access-groups/${encodeURIComponent(currentGroupId)}/members`);
renderGroupMembersList(data.members || []);
}
function renderGroupMembersList(members) {
const list = document.getElementById('group-members-list');
const empty = document.getElementById('group-members-empty');
list.innerHTML = '';
if (members.length === 0) {
empty.classList.remove('hidden');
return;
}
empty.classList.add('hidden');
members.forEach((member) => {
const li = document.createElement('li');
li.className = 'acl-entry';
li.innerHTML = `
<span class="badge type-badge">${escapeHtml(ACL_TYPE_LABELS[member.type] || 'Unknown')}</span>
<span class="acl-description">${escapeHtml(aclEntryDescription(member))}</span>
<button class="danger group-member-delete-btn">Remove</button>
`;
li.querySelector('.group-member-delete-btn').addEventListener('click', () => deleteGroupMember(member.id));
list.appendChild(li);
});
}
async function deleteGroupMember(memberId) {
if (!currentGroupId) return;
if (!confirm('Remove this member?')) return;
await api(`/dashboard/places/${encodeURIComponent(currentPlaceId)}/access-groups/${encodeURIComponent(currentGroupId)}/members/${encodeURIComponent(memberId)}`, {
method: 'DELETE'
});
await loadGroupMembers();
}
document.getElementById('add-group-member-form').addEventListener('submit', async (e) => {
e.preventDefault();
if (!currentGroupId) return;
const type = parseInt(document.getElementById('group-member-type').value, 10);
let data;
if (type === 0 || type === 1) {
data = document.getElementById('group-member-data').value.trim();
if (!data) return;
} else if (type === 2 || type === 3) {
const groupId = document.getElementById('group-member-group-id').value.trim();
const rank = document.getElementById('group-member-group-rank').value.trim();
if (!groupId || !rank) return;
data = `${groupId}:${rank}`;
} else if (type === 4) {
data = '*';
}
const result = await api(`/dashboard/places/${encodeURIComponent(currentPlaceId)}/access-groups/${encodeURIComponent(currentGroupId)}/members`, {
method: 'POST',
body: JSON.stringify({ type, data })
});
if (result.success) {
document.getElementById('add-group-member-form').reset();
updateGroupMemberFormFields();
await loadGroupMembers();
} else {
alert(result.message || 'Failed to add member');
}
});
document.getElementById('group-close-btn').addEventListener('click', closeGroupModal);
document.getElementById('group-modal').addEventListener('click', (e) => {
if (e.target.id === 'group-modal') closeGroupModal();
});
// --- Scan logs viewing ---
let currentLogsReaderId = null;
async function openLogsModal(readerId, readerName) {
currentLogsReaderId = readerId;
document.getElementById('logs-reader-name').textContent = readerName;
document.getElementById('logs-modal').classList.remove('hidden');
await loadLogs();
}
function closeLogsModal() {
currentLogsReaderId = null;
document.getElementById('logs-modal').classList.add('hidden');
}
async function loadLogs() {
if (!currentLogsReaderId) return;
const data = await api(`/dashboard/places/${encodeURIComponent(currentPlaceId)}/access-points/${encodeURIComponent(currentLogsReaderId)}/logs`);
renderLogsList(data.logs || []);
}
function renderLogsList(logs) {
const list = document.getElementById('logs-list');
const empty = document.getElementById('logs-empty');
list.innerHTML = '';
if (logs.length === 0) {
empty.classList.remove('hidden');
return;
}
empty.classList.add('hidden');
logs.forEach((log) => {
const li = document.createElement('li');
li.className = 'acl-entry';
const granted = !!log.granted;
const cards = log.cardNumbers ? `, cards: ${log.cardNumbers}` : '';
li.innerHTML = `
<span class="badge type-badge" style="background:${granted ? 'var(--success)' : 'var(--danger)'};">${granted ? 'Granted' : 'Denied'}</span>
<span class="acl-description">${escapeHtml(log.scannedAt)} user: ${escapeHtml(log.userId || 'unknown')}${escapeHtml(cards)}</span>
`;
list.appendChild(li);
});
}
document.getElementById('logs-close-btn').addEventListener('click', closeLogsModal);
document.getElementById('logs-modal').addEventListener('click', (e) => {
if (e.target.id === 'logs-modal') closeLogsModal();
});
init(); init();

View file

@ -113,47 +113,74 @@ router.get('/:placeId/:accessPointId/onScan', async (req, res) => {
return res.status(500).json({ success: false, message: "Internal server error" }); return res.status(500).json({ success: false, message: "Internal server error" });
} }
// Access group entries (type 5) reference an access_groups.id in `data`. Resolve their
// members up front so the group's users/cards/ranks are checked just like direct entries.
const groupIds = [...new Set(aclEntries.filter(e => e.type === 5).map(e => e.data))];
let groupMembersById = {};
if (groupIds.length > 0) {
const placeholders = groupIds.map(() => '?').join(',');
groupMembersById = await new Promise((resolve) => {
db.all(`SELECT * FROM access_group_members WHERE groupId IN (${placeholders})`, groupIds, (err, members) => {
if (err) {
console.error('Failed to retrieve access group members:', err.message);
return resolve({});
}
const byGroup = {};
for (const member of members) {
if (!byGroup[member.groupId]) byGroup[member.groupId] = [];
byGroup[member.groupId].push(member);
}
resolve(byGroup);
});
});
}
let userGroups = await fetch(`https://groups.roblox.com/v1/users/${userId}/groups/roles`).then(r => r.json()).then(data => data.data || []).catch(() => []); let userGroups = await fetch(`https://groups.roblox.com/v1/users/${userId}/groups/roles`).then(r => r.json()).then(data => data.data || []).catch(() => []);
userGroups = userGroups.map(g => ({group: g.group.id, rank: g.role.rank})); userGroups = userGroups.map(g => ({group: g.group.id, rank: g.role.rank}));
console.log('User groups:', userGroups); console.log('User groups:', userGroups);
let grant_type;
let granted = false; // Types: 0 = User ID; 1 = Card Number; 2 = Group Exact Rank; 3 = Group Min Rank; 4 = Allow All; 5 = Access Group
// Types: 0 = User ID; 1 = Card Number; 2 = Group Exact Rank; 3 = Group Min Rank, 4 = Allow All function matchesEntry(entry) {
// Check user ID, then group ranks, then card numbers.
for (const entry of aclEntries) {
switch (entry.type) { switch (entry.type) {
case 0: // User ID case 0: // User ID
if (entry.data == userId) { return entry.data == userId;
granted = true;
}
break;
case 1: // Card Number case 1: // Card Number
if (cardNumbers.includes(entry.data)) { return cardNumbers.includes(entry.data);
granted = true;
}
break;
case 2: { // Group Exact Rank case 2: { // Group Exact Rank
const [group, rank] = entry.data.split(':'); const [group, rank] = entry.data.split(':');
if (userGroups.some(g => g.group == group && g.rank == rank)) { return userGroups.some(g => g.group == group && g.rank == rank);
granted = true;
}
break;
} }
case 3: { // Group Min Rank case 3: { // Group Min Rank
const [group, rank] = entry.data.split(':'); const [group, rank] = entry.data.split(':');
if (userGroups.some(g => g.group == group && g.rank >= rank)) { return userGroups.some(g => g.group == group && g.rank >= rank);
granted = true;
}
break;
} }
case 4: // Allow All case 4: // Allow All
granted = true; return true;
break; 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 ? { const responseData = granted ? {
success: true, success: true,
grant_type: 'user_scan', grant_type: 'user_scan',

View file

@ -94,7 +94,7 @@ router.put('/places/:placeId', loadOwnedPlace, (req, res) => {
}); });
}); });
// Delete a place (and its readers/acl entries) // Delete a place (and its readers/acl entries/access groups/scan logs)
router.delete('/places/:placeId', loadOwnedPlace, (req, res) => { router.delete('/places/:placeId', loadOwnedPlace, (req, res) => {
db.all(`SELECT id FROM access_points WHERE placeId = ?`, [req.place.id], (err, accessPoints) => { db.all(`SELECT id FROM access_points WHERE placeId = ?`, [req.place.id], (err, accessPoints) => {
if (err) { if (err) {
@ -109,7 +109,10 @@ router.delete('/places/:placeId', loadOwnedPlace, (req, res) => {
const placeholders = apIds.map(() => '?').join(','); const placeholders = apIds.map(() => '?').join(',');
db.run(`DELETE FROM acl WHERE accessPoint IN (${placeholders})`, apIds, (err) => { db.run(`DELETE FROM acl WHERE accessPoint IN (${placeholders})`, apIds, (err) => {
if (err) return cb(err); if (err) return cb(err);
db.run(`DELETE FROM access_points WHERE placeId = ?`, [req.place.id], cb); 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);
});
}); });
}; };
@ -119,12 +122,36 @@ router.delete('/places/:placeId', loadOwnedPlace, (req, res) => {
return res.status(500).json({ success: false, message: "Internal server error" }); return res.status(500).json({ success: false, message: "Internal server error" });
} }
db.run(`DELETE FROM places WHERE id = ?`, [req.place.id], (err) => { db.all(`SELECT id FROM access_groups WHERE placeId = ?`, [req.place.id], (err, groups) => {
if (err) { if (err) {
console.error('Failed to delete place:', err.message); console.error('Failed to retrieve access groups for place:', err.message);
return res.status(500).json({ success: false, message: "Internal server error" }); return res.status(500).json({ success: false, message: "Internal server error" });
} }
res.json({ success: true });
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 });
});
});
}); });
}); });
}); });
@ -210,16 +237,38 @@ router.delete('/places/:placeId/access-points/:accessPointId', loadOwnedPlace, l
console.error('Failed to delete ACL entries for reader:', err.message); console.error('Failed to delete ACL entries for reader:', err.message);
return res.status(500).json({ success: false, message: "Internal server error" }); return res.status(500).json({ success: false, message: "Internal server error" });
} }
db.run(`DELETE FROM access_points WHERE id = ?`, [req.accessPoint.id], (err) => { db.run(`DELETE FROM scan_logs WHERE accessPoint = ?`, [req.accessPoint.id], (err) => {
if (err) { if (err) {
console.error('Failed to delete reader:', err.message); console.error('Failed to delete scan logs for reader:', err.message);
return res.status(500).json({ success: false, message: "Internal server error" }); return res.status(500).json({ success: false, message: "Internal server error" });
} }
res.json({ success: true }); 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 // List ACL entries for a reader
router.get('/places/:placeId/access-points/:accessPointId/acl', loadOwnedPlace, loadOwnedAccessPoint, (req, res) => { router.get('/places/:placeId/access-points/:accessPointId/acl', loadOwnedPlace, loadOwnedAccessPoint, (req, res) => {
db.all(`SELECT * FROM acl WHERE accessPoint = ?`, [req.accessPoint.id], (err, entries) => { db.all(`SELECT * FROM acl WHERE accessPoint = ?`, [req.accessPoint.id], (err, entries) => {
@ -258,3 +307,126 @@ router.delete('/places/:placeId/access-points/:accessPointId/acl/:aclId', loadOw
}); });
}); });
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" });
}
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 });
});
});