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/public/dashboard.html b/public/dashboard.html index 1a9e1fe..51c430e 100644 --- a/public/dashboard.html +++ b/public/dashboard.html @@ -58,6 +58,24 @@
+ +
+

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

+ + +
@@ -80,6 +98,7 @@ +
@@ -94,6 +113,10 @@
+ @@ -106,6 +129,66 @@ + + + + + diff --git a/public/js/dashboard.js b/public/js/dashboard.js index e7d6003..a81928a 100644 --- a/public/js/dashboard.js +++ b/public/js/dashboard.js @@ -1,5 +1,6 @@ let currentPlaceId = null; let places = []; +let accessGroups = []; async function api(path, options = {}) { const res = await fetch(path, { @@ -56,6 +57,7 @@ async function selectPlace(placeId) { document.getElementById('place-title').textContent = placeId; renderReaders(data.accessPoints || []); + await loadAccessGroups(); } function renderReaders(accessPoints) { @@ -90,6 +92,7 @@ function renderReaders(accessPoints) {
+
`; @@ -97,6 +100,7 @@ function renderReaders(accessPoints) { 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); }); @@ -196,7 +200,8 @@ const ACL_TYPE_LABELS = { 1: 'Card number', 2: 'Group exact rank', 3: 'Group min rank', - 4: 'Allow all' + 4: 'Allow all', + 5: 'Access group' }; function aclEntryDescription(entry) { @@ -215,6 +220,10 @@ function aclEntryDescription(entry) { } 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; } @@ -225,12 +234,14 @@ function updateAclFormFields() { 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) { @@ -244,10 +255,24 @@ function updateAclFormFields() { } 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(); @@ -322,6 +347,9 @@ document.getElementById('add-acl-form').addEventListener('submit', async (e) => 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`, { @@ -343,4 +371,245 @@ 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: ${log.cardNumbers}` : ''; + li.innerHTML = ` + ${granted ? 'Granted' : 'Denied'} + ${escapeHtml(log.scannedAt)} — user: ${escapeHtml(log.userId || 'unknown')}${escapeHtml(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/routes/api/v1/ingame.js b/routes/api/v1/ingame.js index 1d69c63..38f1af7 100644 --- a/routes/api/v1/ingame.js +++ b/routes/api/v1/ingame.js @@ -113,47 +113,74 @@ 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) { + 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/dashboard.js b/routes/dashboard.js index 8676ed2..1807a3d 100644 --- a/routes/dashboard.js +++ b/routes/dashboard.js @@ -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) => { db.all(`SELECT id FROM access_points WHERE placeId = ?`, [req.place.id], (err, accessPoints) => { if (err) { @@ -109,7 +109,10 @@ router.delete('/places/:placeId', loadOwnedPlace, (req, res) => { const placeholders = apIds.map(() => '?').join(','); db.run(`DELETE FROM acl WHERE accessPoint IN (${placeholders})`, apIds, (err) => { if (err) return cb(err); - db.run(`DELETE FROM access_points WHERE placeId = ?`, [req.place.id], cb); + 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" }); } - 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) { - 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" }); } - 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); 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) { - 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" }); } - 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 router.get('/places/:placeId/access-points/:accessPointId/acl', loadOwnedPlace, loadOwnedAccessPoint, (req, res) => { 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 }); + }); +}); +