Add access groups (staff-style credential bundles) and scan logging
This commit is contained in:
parent
b53fb16398
commit
44ad3addf0
11
migrations/0003_access_groups_and_logs.sql
Normal file
11
migrations/0003_access_groups_and_logs.sql
Normal 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);
|
||||
|
|
@ -58,6 +58,24 @@
|
|||
<div id="readers-empty" class="empty-state hidden">No readers yet for this place.</div>
|
||||
<div class="readers-grid" id="readers-grid"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Add access group</h3>
|
||||
<p style="margin-top:-8px;color:var(--muted);font-size:13px;">Access groups (e.g. "Staff") bundle multiple users/cards/rank rules so they can be assigned to multiple readers at once.</p>
|
||||
<form id="add-access-group-form" class="inline-form">
|
||||
<div class="field-group">
|
||||
<label for="access-group-name">Name</label>
|
||||
<input type="text" id="access-group-name" required>
|
||||
</div>
|
||||
<button type="submit" class="primary">Add group</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Access groups</h3>
|
||||
<div id="access-groups-empty" class="empty-state hidden">No access groups yet for this place.</div>
|
||||
<ul class="acl-list" id="access-groups-list"></ul>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
|
@ -80,6 +98,7 @@
|
|||
<option value="2">Group exact rank</option>
|
||||
<option value="3">Group minimum rank</option>
|
||||
<option value="4">Allow all</option>
|
||||
<option value="5">Access group</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field-group" id="acl-data-group">
|
||||
|
|
@ -94,6 +113,10 @@
|
|||
<label for="acl-group-rank">Rank</label>
|
||||
<input type="number" id="acl-group-rank" min="0">
|
||||
</div>
|
||||
<div class="field-group hidden" id="acl-access-group-group">
|
||||
<label for="acl-access-group">Access group</label>
|
||||
<select id="acl-access-group"></select>
|
||||
</div>
|
||||
<button type="submit" class="primary">Add entry</button>
|
||||
</form>
|
||||
</div>
|
||||
|
|
@ -106,6 +129,66 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div id="logs-modal" class="modal-overlay hidden">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h2>Scan logs — <span id="logs-reader-name"></span></h2>
|
||||
<button class="secondary" id="logs-close-btn">Close</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Recent scans</h3>
|
||||
<div id="logs-empty" class="empty-state hidden">No scans have been recorded for this reader yet.</div>
|
||||
<ul class="acl-list" id="logs-list"></ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="group-modal" class="modal-overlay hidden">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h2>Access group members — <span id="group-name"></span></h2>
|
||||
<button class="secondary" id="group-close-btn">Close</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Add member</h3>
|
||||
<form id="add-group-member-form" class="inline-form">
|
||||
<div class="field-group">
|
||||
<label for="group-member-type">Type</label>
|
||||
<select id="group-member-type">
|
||||
<option value="0">User ID</option>
|
||||
<option value="1">Card number</option>
|
||||
<option value="2">Group exact rank</option>
|
||||
<option value="3">Group minimum rank</option>
|
||||
<option value="4">Allow all</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field-group" id="group-member-data-group">
|
||||
<label for="group-member-data">User ID</label>
|
||||
<input type="text" id="group-member-data">
|
||||
</div>
|
||||
<div class="field-group hidden" id="group-member-group-id-group">
|
||||
<label for="group-member-group-id">Group ID</label>
|
||||
<input type="text" id="group-member-group-id">
|
||||
</div>
|
||||
<div class="field-group hidden" id="group-member-group-rank-group">
|
||||
<label for="group-member-group-rank">Rank</label>
|
||||
<input type="number" id="group-member-group-rank" min="0">
|
||||
</div>
|
||||
<button type="submit" class="primary">Add member</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Members</h3>
|
||||
<div id="group-members-empty" class="empty-state hidden">No members yet. This group grants no access until members are added.</div>
|
||||
<ul class="acl-list" id="group-members-list"></ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/dashboard.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
|||
<div class="actions">
|
||||
<button class="secondary save-btn">Save</button>
|
||||
<button class="secondary acl-btn">Manage access</button>
|
||||
<button class="secondary logs-btn">View logs</button>
|
||||
<button class="danger delete-btn">Delete</button>
|
||||
</div>
|
||||
`;
|
||||
|
|
@ -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 = `
|
||||
<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();
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue