Webhook support #2
147
lib/roblox.js
Normal file
147
lib/roblox.js
Normal file
|
|
@ -0,0 +1,147 @@
|
||||||
|
// Thin wrapper around Roblox's public Users API for resolving usernames <-> user ids. These
|
||||||
|
// endpoints require no authentication, so we just use the runtime's built-in fetch() - same
|
||||||
|
// pattern as lib/webhooks.js and the group-role lookup in routes/api/v1/ingame.js - rather than
|
||||||
|
// pulling in the heavier noblox.js package (which is mainly aimed at cookie-authenticated game
|
||||||
|
// automation, not simple id/username lookups).
|
||||||
|
//
|
||||||
|
// All lookups are cached in-memory (including negative/not-found results) for CACHE_TTL_MS, both
|
||||||
|
// to keep the dashboard snappy when the same ids show up repeatedly (ACL entries, access group
|
||||||
|
// members, scan logs) and to avoid tripping Roblox's rate limits.
|
||||||
|
|
||||||
|
const USERNAME_TO_ID_URL = 'https://users.roblox.com/v1/usernames/users';
|
||||||
|
const IDS_TO_USERS_URL = 'https://users.roblox.com/v1/users';
|
||||||
|
const singleUserUrl = (id) => `https://users.roblox.com/v1/users/${id}`;
|
||||||
|
|
||||||
|
const CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
||||||
|
const byId = new Map(); // id (string) -> { value: {id,name,displayName} | null, expires }
|
||||||
|
const byUsername = new Map(); // lowercased username -> same shape
|
||||||
|
|
||||||
|
function getCached(map, key) {
|
||||||
|
const entry = map.get(key);
|
||||||
|
if (!entry) return undefined;
|
||||||
|
if (Date.now() > entry.expires) {
|
||||||
|
map.delete(key);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return entry.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCached(map, key, value) {
|
||||||
|
map.set(key, { value, expires: Date.now() + CACHE_TTL_MS });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Caches a resolved user under both its id and username, so a lookup by either later hits cache.
|
||||||
|
function cacheUser(user) {
|
||||||
|
setCached(byId, String(user.id), user);
|
||||||
|
setCached(byUsername, user.name.toLowerCase(), user);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Splits an array into chunks of at most `size` items (Roblox's bulk endpoints are happy with
|
||||||
|
// large batches, but we chunk defensively so one call can't grow unbounded).
|
||||||
|
function chunk(arr, size) {
|
||||||
|
const out = [];
|
||||||
|
for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Looks up a single Roblox user by username. Returns { id, name, displayName } or null if no such
|
||||||
|
// user exists (or the lookup failed). Both hits and misses are cached.
|
||||||
|
async function getUserByUsername(username) {
|
||||||
|
const key = String(username || '').trim().toLowerCase();
|
||||||
|
if (!key) return null;
|
||||||
|
|
||||||
|
const cached = getCached(byUsername, key);
|
||||||
|
if (cached !== undefined) return cached;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(USERNAME_TO_ID_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ usernames: [username], excludeBannedUsers: false })
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`Roblox responded with ${res.status}`);
|
||||||
|
const body = await res.json();
|
||||||
|
const match = (body.data || [])[0];
|
||||||
|
const value = match ? { id: match.id, name: match.name, displayName: match.displayName } : null;
|
||||||
|
|
||||||
|
setCached(byUsername, key, value);
|
||||||
|
if (value) setCached(byId, String(value.id), value);
|
||||||
|
return value;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Roblox username lookup failed:', err.message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Looks up a single Roblox user by id. Returns { id, name, displayName } or null.
|
||||||
|
async function getUserById(userId) {
|
||||||
|
const key = String(userId || '').trim();
|
||||||
|
if (!key || Number.isNaN(Number(key))) return null;
|
||||||
|
|
||||||
|
const cached = getCached(byId, key);
|
||||||
|
if (cached !== undefined) return cached;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(singleUserUrl(key));
|
||||||
|
if (res.status === 404) {
|
||||||
|
setCached(byId, key, null);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!res.ok) throw new Error(`Roblox responded with ${res.status}`);
|
||||||
|
const body = await res.json();
|
||||||
|
const value = { id: body.id, name: body.name, displayName: body.displayName };
|
||||||
|
cacheUser(value);
|
||||||
|
return value;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Roblox user id lookup failed:', err.message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batch-resolves multiple Roblox user ids to usernames at once (e.g. for a whole page of ACL
|
||||||
|
// entries or scan logs), to avoid one request per row. Returns a plain object mapping id (string)
|
||||||
|
// -> { id, name, displayName }; ids that don't resolve are simply omitted from the result.
|
||||||
|
async function getUsersByIds(userIds) {
|
||||||
|
const ids = [...new Set((userIds || []).map(id => String(id).trim()).filter(id => id && !Number.isNaN(Number(id))))];
|
||||||
|
const result = {};
|
||||||
|
const uncached = [];
|
||||||
|
|
||||||
|
for (const id of ids) {
|
||||||
|
const cached = getCached(byId, id);
|
||||||
|
if (cached !== undefined) {
|
||||||
|
if (cached) result[id] = cached;
|
||||||
|
} else {
|
||||||
|
uncached.push(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const batch of chunk(uncached, 100)) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(IDS_TO_USERS_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ userIds: batch.map(Number), excludeBannedUsers: false })
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`Roblox responded with ${res.status}`);
|
||||||
|
const body = await res.json();
|
||||||
|
|
||||||
|
const found = new Set();
|
||||||
|
for (const user of body.data || []) {
|
||||||
|
const value = { id: user.id, name: user.name, displayName: user.displayName };
|
||||||
|
cacheUser(value);
|
||||||
|
result[String(user.id)] = value;
|
||||||
|
found.add(String(user.id));
|
||||||
|
}
|
||||||
|
// Cache misses too, so we don't keep re-requesting ids that don't exist.
|
||||||
|
for (const id of batch) {
|
||||||
|
if (!found.has(id)) setCached(byId, id, null);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Roblox batch user lookup failed:', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getUserByUsername, getUserById, getUsersByIds };
|
||||||
|
|
@ -434,6 +434,40 @@ button.danger {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.input-with-button {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-with-button input {
|
||||||
|
flex: 1;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-with-button button {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-hint {
|
||||||
|
display: block;
|
||||||
|
font-size: 12px;
|
||||||
|
margin: -12px 0 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-hint.success {
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-hint.error {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.roblox-username {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.hidden {
|
.hidden {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -206,7 +206,11 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="field-group" id="acl-data-group">
|
<div class="field-group" id="acl-data-group">
|
||||||
<label for="acl-data">User ID</label>
|
<label for="acl-data">User ID</label>
|
||||||
<input type="text" id="acl-data">
|
<div class="input-with-button">
|
||||||
|
<input type="text" id="acl-data">
|
||||||
|
<button type="button" class="secondary" id="acl-data-lookup-btn">Look up username</button>
|
||||||
|
</div>
|
||||||
|
<span class="field-hint hidden" id="acl-data-hint"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="field-group hidden" id="acl-group-id-group">
|
<div class="field-group hidden" id="acl-group-id-group">
|
||||||
<label for="acl-group-id">Group ID</label>
|
<label for="acl-group-id">Group ID</label>
|
||||||
|
|
@ -269,7 +273,11 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="field-group" id="group-member-data-group">
|
<div class="field-group" id="group-member-data-group">
|
||||||
<label for="group-member-data">User ID</label>
|
<label for="group-member-data">User ID</label>
|
||||||
<input type="text" id="group-member-data">
|
<div class="input-with-button">
|
||||||
|
<input type="text" id="group-member-data">
|
||||||
|
<button type="button" class="secondary" id="group-member-data-lookup-btn">Look up username</button>
|
||||||
|
</div>
|
||||||
|
<span class="field-hint hidden" id="group-member-data-hint"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="field-group hidden" id="group-member-group-id-group">
|
<div class="field-group hidden" id="group-member-group-id-group">
|
||||||
<label for="group-member-group-id">Group ID</label>
|
<label for="group-member-group-id">Group ID</label>
|
||||||
|
|
|
||||||
|
|
@ -500,6 +500,92 @@ function escapeHtml(str) {
|
||||||
return div.innerHTML;
|
return div.innerHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Roblox username <-> user id helpers ---
|
||||||
|
// A small in-memory cache so switching between modals/readers doesn't re-request the same ids
|
||||||
|
// over and over; the server itself also caches, but this avoids the round-trip entirely.
|
||||||
|
const robloxUserCache = new Map(); // id (string) -> { id, name, displayName } | null
|
||||||
|
|
||||||
|
// Looks up a Roblox username and returns its user id as a string, or null if not found/blank.
|
||||||
|
// Also caches the resolved user so any username shown right after doesn't need a second request.
|
||||||
|
async function lookupRobloxUsername(username) {
|
||||||
|
const trimmed = (username || '').trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
|
||||||
|
const result = await api(`/dashboard/roblox/lookup?username=${encodeURIComponent(trimmed)}`);
|
||||||
|
if (!result.success) return null;
|
||||||
|
robloxUserCache.set(String(result.user.id), result.user);
|
||||||
|
return String(result.user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batch-resolves Roblox user ids to usernames, filling robloxUserCache as it goes, then returns
|
||||||
|
// the cache so callers can look up whichever ids they need. Ids already cached are skipped.
|
||||||
|
async function resolveRobloxUsernames(userIds) {
|
||||||
|
const ids = [...new Set((userIds || []).map(String).filter(id => id && /^\d+$/.test(id)))];
|
||||||
|
const missing = ids.filter(id => !robloxUserCache.has(id));
|
||||||
|
|
||||||
|
if (missing.length > 0) {
|
||||||
|
const result = await api(`/dashboard/roblox/users?ids=${missing.map(encodeURIComponent).join(',')}`);
|
||||||
|
if (result.success) {
|
||||||
|
missing.forEach((id) => {
|
||||||
|
robloxUserCache.set(id, result.users[id] || null);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return robloxUserCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Renders a Roblox user id alongside its cached username (if any), e.g. "123456 (builderman)".
|
||||||
|
function formatRobloxUserId(userId) {
|
||||||
|
if (!userId) return escapeHtml(userId || 'unknown');
|
||||||
|
const user = robloxUserCache.get(String(userId));
|
||||||
|
if (user) {
|
||||||
|
return `${escapeHtml(userId)} <span class="roblox-username">(${escapeHtml(user.name)})</span>`;
|
||||||
|
}
|
||||||
|
return escapeHtml(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wires up a "Look up username" button next to a User ID input: on click, prompts for a username,
|
||||||
|
// resolves it via the Roblox API, fills the input with the id, and shows a status hint.
|
||||||
|
function setupUsernameLookupButton(buttonId, inputId, hintId) {
|
||||||
|
const button = document.getElementById(buttonId);
|
||||||
|
const input = document.getElementById(inputId);
|
||||||
|
const hint = document.getElementById(hintId);
|
||||||
|
if (!button || !input) return;
|
||||||
|
|
||||||
|
button.addEventListener('click', async () => {
|
||||||
|
const username = prompt('Enter a Roblox username to look up its user id:');
|
||||||
|
if (username === null) return;
|
||||||
|
if (!username.trim()) return;
|
||||||
|
|
||||||
|
const originalLabel = button.textContent;
|
||||||
|
button.textContent = 'Looking up...';
|
||||||
|
button.disabled = true;
|
||||||
|
hint.classList.add('hidden');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const userId = await lookupRobloxUsername(username);
|
||||||
|
if (userId) {
|
||||||
|
input.value = userId;
|
||||||
|
const user = robloxUserCache.get(userId);
|
||||||
|
hint.textContent = `Found: ${user.name} (id ${userId})`;
|
||||||
|
hint.className = 'field-hint success';
|
||||||
|
} else {
|
||||||
|
hint.textContent = `No Roblox user found with username "${username.trim()}"`;
|
||||||
|
hint.className = 'field-hint error';
|
||||||
|
}
|
||||||
|
hint.classList.remove('hidden');
|
||||||
|
} finally {
|
||||||
|
button.textContent = originalLabel;
|
||||||
|
button.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setupUsernameLookupButton('acl-data-lookup-btn', 'acl-data', 'acl-data-hint');
|
||||||
|
setupUsernameLookupButton('group-member-data-lookup-btn', 'group-member-data', 'group-member-data-hint');
|
||||||
|
|
||||||
|
|
||||||
async function saveReader(readerId, card) {
|
async function saveReader(readerId, card) {
|
||||||
const enabled = card.querySelector('[data-field="enabled"]').checked;
|
const enabled = card.querySelector('[data-field="enabled"]').checked;
|
||||||
const armState = card.querySelector('[data-field="armState"]').checked ? 1 : 0;
|
const armState = card.querySelector('[data-field="armState"]').checked ? 1 : 0;
|
||||||
|
|
@ -807,25 +893,25 @@ const ACL_TYPE_LABELS = {
|
||||||
function aclEntryDescription(entry) {
|
function aclEntryDescription(entry) {
|
||||||
switch (entry.type) {
|
switch (entry.type) {
|
||||||
case 0:
|
case 0:
|
||||||
return `User ID: ${entry.data}`;
|
return `User ID: ${formatRobloxUserId(entry.data)}`;
|
||||||
case 1:
|
case 1:
|
||||||
return `Card number: ${entry.data}`;
|
return `Card number: ${escapeHtml(entry.data)}`;
|
||||||
case 2: {
|
case 2: {
|
||||||
const [group, rank] = entry.data.split(':');
|
const [group, rank] = entry.data.split(':');
|
||||||
return `Group ${group}, exact rank ${rank}`;
|
return `Group ${escapeHtml(group)}, exact rank ${escapeHtml(rank)}`;
|
||||||
}
|
}
|
||||||
case 3: {
|
case 3: {
|
||||||
const [group, rank] = entry.data.split(':');
|
const [group, rank] = entry.data.split(':');
|
||||||
return `Group ${group}, rank ${rank}+`;
|
return `Group ${escapeHtml(group)}, rank ${escapeHtml(rank)}+`;
|
||||||
}
|
}
|
||||||
case 4:
|
case 4:
|
||||||
return 'Everyone';
|
return 'Everyone';
|
||||||
case 5: {
|
case 5: {
|
||||||
const group = accessGroups.find(g => String(g.id) === String(entry.data));
|
const group = accessGroups.find(g => String(g.id) === String(entry.data));
|
||||||
return `Access group: ${group ? group.name : entry.data}`;
|
return `Access group: ${escapeHtml(group ? group.name : entry.data)}`;
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
return entry.data;
|
return escapeHtml(entry.data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -882,6 +968,7 @@ async function openAclModal(readerId, readerName) {
|
||||||
document.getElementById('acl-modal').classList.remove('hidden');
|
document.getElementById('acl-modal').classList.remove('hidden');
|
||||||
document.getElementById('add-acl-form').reset();
|
document.getElementById('add-acl-form').reset();
|
||||||
document.getElementById('add-acl-form').classList.toggle('hidden', !currentPlaceCanEdit);
|
document.getElementById('add-acl-form').classList.toggle('hidden', !currentPlaceCanEdit);
|
||||||
|
document.getElementById('acl-data-hint').classList.add('hidden');
|
||||||
updateAclFormFields();
|
updateAclFormFields();
|
||||||
await loadAcl();
|
await loadAcl();
|
||||||
}
|
}
|
||||||
|
|
@ -894,7 +981,9 @@ function closeAclModal() {
|
||||||
async function loadAcl() {
|
async function loadAcl() {
|
||||||
if (!currentAclReaderId) return;
|
if (!currentAclReaderId) return;
|
||||||
const data = await api(`/dashboard/places/${encodeURIComponent(currentPlaceId)}/access-points/${encodeURIComponent(currentAclReaderId)}/acl`);
|
const data = await api(`/dashboard/places/${encodeURIComponent(currentPlaceId)}/access-points/${encodeURIComponent(currentAclReaderId)}/acl`);
|
||||||
renderAclList(data.acl || []);
|
const entries = data.acl || [];
|
||||||
|
await resolveRobloxUsernames(entries.filter(e => e.type === 0).map(e => e.data));
|
||||||
|
renderAclList(entries);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderAclList(entries) {
|
function renderAclList(entries) {
|
||||||
|
|
@ -913,7 +1002,7 @@ function renderAclList(entries) {
|
||||||
li.className = 'acl-entry';
|
li.className = 'acl-entry';
|
||||||
li.innerHTML = `
|
li.innerHTML = `
|
||||||
<span class="badge type-badge">${escapeHtml(ACL_TYPE_LABELS[entry.type] || 'Unknown')}</span>
|
<span class="badge type-badge">${escapeHtml(ACL_TYPE_LABELS[entry.type] || 'Unknown')}</span>
|
||||||
<span class="acl-description">${escapeHtml(aclEntryDescription(entry))}</span>
|
<span class="acl-description">${aclEntryDescription(entry)}</span>
|
||||||
${currentPlaceCanEdit ? '<button class="danger acl-delete-btn">Remove</button>' : ''}
|
${currentPlaceCanEdit ? '<button class="danger acl-delete-btn">Remove</button>' : ''}
|
||||||
`;
|
`;
|
||||||
if (currentPlaceCanEdit) {
|
if (currentPlaceCanEdit) {
|
||||||
|
|
@ -1080,6 +1169,7 @@ async function openGroupModal(groupId, groupName) {
|
||||||
document.getElementById('group-modal').classList.remove('hidden');
|
document.getElementById('group-modal').classList.remove('hidden');
|
||||||
document.getElementById('add-group-member-form').reset();
|
document.getElementById('add-group-member-form').reset();
|
||||||
document.getElementById('add-group-member-form').classList.toggle('hidden', !currentPlaceCanEdit);
|
document.getElementById('add-group-member-form').classList.toggle('hidden', !currentPlaceCanEdit);
|
||||||
|
document.getElementById('group-member-data-hint').classList.add('hidden');
|
||||||
updateGroupMemberFormFields();
|
updateGroupMemberFormFields();
|
||||||
await loadGroupMembers();
|
await loadGroupMembers();
|
||||||
}
|
}
|
||||||
|
|
@ -1092,7 +1182,9 @@ function closeGroupModal() {
|
||||||
async function loadGroupMembers() {
|
async function loadGroupMembers() {
|
||||||
if (!currentGroupId) return;
|
if (!currentGroupId) return;
|
||||||
const data = await api(`/dashboard/places/${encodeURIComponent(currentPlaceId)}/access-groups/${encodeURIComponent(currentGroupId)}/members`);
|
const data = await api(`/dashboard/places/${encodeURIComponent(currentPlaceId)}/access-groups/${encodeURIComponent(currentGroupId)}/members`);
|
||||||
renderGroupMembersList(data.members || []);
|
const members = data.members || [];
|
||||||
|
await resolveRobloxUsernames(members.filter(m => m.type === 0).map(m => m.data));
|
||||||
|
renderGroupMembersList(members);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderGroupMembersList(members) {
|
function renderGroupMembersList(members) {
|
||||||
|
|
@ -1111,7 +1203,7 @@ function renderGroupMembersList(members) {
|
||||||
li.className = 'acl-entry';
|
li.className = 'acl-entry';
|
||||||
li.innerHTML = `
|
li.innerHTML = `
|
||||||
<span class="badge type-badge">${escapeHtml(ACL_TYPE_LABELS[member.type] || 'Unknown')}</span>
|
<span class="badge type-badge">${escapeHtml(ACL_TYPE_LABELS[member.type] || 'Unknown')}</span>
|
||||||
<span class="acl-description">${escapeHtml(aclEntryDescription(member))}</span>
|
<span class="acl-description">${aclEntryDescription(member)}</span>
|
||||||
${currentPlaceCanEdit ? '<button class="danger group-member-delete-btn">Remove</button>' : ''}
|
${currentPlaceCanEdit ? '<button class="danger group-member-delete-btn">Remove</button>' : ''}
|
||||||
`;
|
`;
|
||||||
if (currentPlaceCanEdit) {
|
if (currentPlaceCanEdit) {
|
||||||
|
|
@ -1188,7 +1280,9 @@ function closeLogsModal() {
|
||||||
async function loadLogs() {
|
async function loadLogs() {
|
||||||
if (!currentLogsReaderId) return;
|
if (!currentLogsReaderId) return;
|
||||||
const data = await api(`/dashboard/places/${encodeURIComponent(currentPlaceId)}/access-points/${encodeURIComponent(currentLogsReaderId)}/logs`);
|
const data = await api(`/dashboard/places/${encodeURIComponent(currentPlaceId)}/access-points/${encodeURIComponent(currentLogsReaderId)}/logs`);
|
||||||
renderLogsList(data.logs || []);
|
const logs = data.logs || [];
|
||||||
|
await resolveRobloxUsernames(logs.map(l => l.userId));
|
||||||
|
renderLogsList(logs);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderLogsList(logs) {
|
function renderLogsList(logs) {
|
||||||
|
|
@ -1207,9 +1301,10 @@ function renderLogsList(logs) {
|
||||||
li.className = 'acl-entry';
|
li.className = 'acl-entry';
|
||||||
const granted = !!log.granted;
|
const granted = !!log.granted;
|
||||||
const cards = log.cardNumbers ? `, cards: ${escapeHtml(log.cardNumbers)}` : '';
|
const cards = log.cardNumbers ? `, cards: ${escapeHtml(log.cardNumbers)}` : '';
|
||||||
|
const user = log.userId ? formatRobloxUserId(log.userId) : 'unknown';
|
||||||
li.innerHTML = `
|
li.innerHTML = `
|
||||||
<span class="badge type-badge" style="background:${granted ? 'var(--success)' : 'var(--danger)'};">${granted ? 'Granted' : 'Denied'}</span>
|
<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')}${cards}</span>
|
<span class="acl-description">${escapeHtml(log.scannedAt)} — user: ${user}${cards}</span>
|
||||||
`;
|
`;
|
||||||
list.appendChild(li);
|
list.appendChild(li);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,21 @@ let db;
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const { sendPlaceWebhook } = require('../../../lib/webhooks');
|
const { sendPlaceWebhook } = require('../../../lib/webhooks');
|
||||||
|
const { getUserById } = require('../../../lib/roblox');
|
||||||
|
|
||||||
module.exports = (dbInit) => {
|
module.exports = (dbInit) => {
|
||||||
db = dbInit;
|
db = dbInit;
|
||||||
return router;
|
return router;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Formats a Roblox user id for display in a Discord embed field, appending the username if it can
|
||||||
|
// be resolved (e.g. "123456 (builderman)"). Falls back to just the id if the lookup fails/is unset.
|
||||||
|
async function formatUserIdForWebhook(userId) {
|
||||||
|
if (!userId) return 'unknown';
|
||||||
|
const user = await getUserById(userId);
|
||||||
|
return user ? `${userId} (${user.name})` : String(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
router.get('/', (req, res) => {
|
router.get('/', (req, res) => {
|
||||||
res.json({ success: true, message: "Welcome to the NOTXCS API!" });
|
res.json({ success: true, message: "Welcome to the NOTXCS API!" });
|
||||||
|
|
@ -140,11 +149,13 @@ router.get('/:placeId/:accessPointId/onScan', async (req, res) => {
|
||||||
if (err) console.error('Failed to record scan log:', err.message);
|
if (err) console.error('Failed to record scan log:', err.message);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
sendPlaceWebhook(place, 'access_denied', {
|
formatUserIdForWebhook(userId).then((userLabel) => {
|
||||||
Reader: ap.name,
|
sendPlaceWebhook(place, 'access_denied', {
|
||||||
'User ID': userId || 'unknown',
|
Reader: ap.name,
|
||||||
'Card(s)': cardNumbers.join(', ') || undefined,
|
'User ID': userLabel,
|
||||||
Reason: globalLockdownActive ? 'Site-wide lockdown active' : 'Place lockdown active'
|
'Card(s)': cardNumbers.join(', ') || undefined,
|
||||||
|
Reason: globalLockdownActive ? 'Site-wide lockdown active' : 'Place lockdown active'
|
||||||
|
});
|
||||||
});
|
});
|
||||||
return res.json({
|
return res.json({
|
||||||
success: true,
|
success: true,
|
||||||
|
|
@ -232,10 +243,12 @@ router.get('/:placeId/:accessPointId/onScan', async (req, res) => {
|
||||||
if (err) console.error('Failed to record scan log:', err.message);
|
if (err) console.error('Failed to record scan log:', err.message);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
sendPlaceWebhook(place, responseCode, {
|
formatUserIdForWebhook(userId).then((userLabel) => {
|
||||||
Reader: ap.name,
|
sendPlaceWebhook(place, responseCode, {
|
||||||
'User ID': userId || 'unknown',
|
Reader: ap.name,
|
||||||
'Card(s)': cardNumbers.join(', ') || undefined
|
'User ID': userLabel,
|
||||||
|
'Card(s)': cardNumbers.join(', ') || undefined
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const responseData = granted ? {
|
const responseData = granted ? {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ const { requireAuth, ROLES, ORG_ROLES } = require('../lib/auth');
|
||||||
const { generatePlaceId, generateApiKey, generateReaderId } = require('../lib/generators');
|
const { generatePlaceId, generateApiKey, generateReaderId } = require('../lib/generators');
|
||||||
const { getEffectiveOrgRole } = require('../lib/orgs');
|
const { getEffectiveOrgRole } = require('../lib/orgs');
|
||||||
const { sendPlaceWebhook, sendTestWebhook } = require('../lib/webhooks');
|
const { sendPlaceWebhook, sendTestWebhook } = require('../lib/webhooks');
|
||||||
|
const { getUserByUsername, getUserById, getUsersByIds } = require('../lib/roblox');
|
||||||
|
|
||||||
const KIT_TEMPLATE_PATH = path.join(__dirname, '..', 'xcs-template.rbxmx');
|
const KIT_TEMPLATE_PATH = path.join(__dirname, '..', 'xcs-template.rbxmx');
|
||||||
|
|
||||||
|
|
@ -123,6 +124,36 @@ router.get('/lockdown', (req, res) => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Roblox username/id lookups (used to resolve usernames typed into ACL/group forms to ids for
|
||||||
|
// storage, and to show usernames alongside ids wherever they're displayed) ---
|
||||||
|
|
||||||
|
// Resolves a Roblox username to its user id (?username=). Used by the "look up" button next to
|
||||||
|
// User ID fields in the dashboard, so users can type a username instead of hunting for the id.
|
||||||
|
router.get('/roblox/lookup', async (req, res) => {
|
||||||
|
const username = req.query.username;
|
||||||
|
if (!username) {
|
||||||
|
return res.status(400).json({ success: false, message: "A username is required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await getUserByUsername(username);
|
||||||
|
if (!user) {
|
||||||
|
return res.status(404).json({ success: false, message: "No Roblox user with that username was found" });
|
||||||
|
}
|
||||||
|
res.json({ success: true, user });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Batch-resolves Roblox user ids to usernames (?ids=1,2,3). Used to annotate ACL entries, access
|
||||||
|
// group members and scan logs with the corresponding username wherever a user id is shown.
|
||||||
|
router.get('/roblox/users', async (req, res) => {
|
||||||
|
const ids = (req.query.ids || '').split(',').map(s => s.trim()).filter(Boolean);
|
||||||
|
if (ids.length === 0) {
|
||||||
|
return res.json({ success: true, users: {} });
|
||||||
|
}
|
||||||
|
|
||||||
|
const users = await getUsersByIds(ids);
|
||||||
|
res.json({ success: true, users });
|
||||||
|
});
|
||||||
|
|
||||||
// --- Organizations ---
|
// --- Organizations ---
|
||||||
|
|
||||||
// List organizations the current user belongs to, plus (for platform elevated/admin/superadmin
|
// List organizations the current user belongs to, plus (for platform elevated/admin/superadmin
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue