diff --git a/lib/roblox.js b/lib/roblox.js
new file mode 100644
index 0000000..5d4cf17
--- /dev/null
+++ b/lib/roblox.js
@@ -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 };
diff --git a/public/css/style.css b/public/css/style.css
index a869c86..dec7aa6 100644
--- a/public/css/style.css
+++ b/public/css/style.css
@@ -434,6 +434,26 @@ button.danger {
font-size: 14px;
}
+.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 {
display: none !important;
}
diff --git a/public/dashboard/index.html b/public/dashboard/index.html
index 79c273d..7811d6e 100644
--- a/public/dashboard/index.html
+++ b/public/dashboard/index.html
@@ -206,7 +206,8 @@
-
+
+
@@ -269,7 +270,8 @@
-
+
+
diff --git a/public/js/dashboard.js b/public/js/dashboard.js
index d01a2c5..4684117 100644
--- a/public/js/dashboard.js
+++ b/public/js/dashboard.js
@@ -500,6 +500,85 @@ function escapeHtml(str) {
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)} (${escapeHtml(user.name)})`;
+ }
+ return escapeHtml(userId);
+}
+
+// Wires up a User ID input so it accepts either a plain numeric id or a Roblox username: on blur,
+// if the current value isn't purely digits, it's treated as a username, resolved to an id via the
+// Roblox API, and the input is rewritten to hold the id (with a status hint shown either way).
+function setupUserIdField(inputId, hintId) {
+ const input = document.getElementById(inputId);
+ const hint = document.getElementById(hintId);
+ if (!input || !hint) return;
+
+ input.addEventListener('blur', async () => {
+ const value = input.value.trim();
+ hint.classList.add('hidden');
+ if (!value || /^\d+$/.test(value)) return; // Blank, or already a plain numeric id - nothing to resolve.
+
+ hint.textContent = 'Looking up username...';
+ hint.className = 'field-hint';
+ hint.classList.remove('hidden');
+
+ const userId = await lookupRobloxUsername(value);
+ 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 "${value}"`;
+ hint.className = 'field-hint error';
+ }
+ });
+}
+
+setupUserIdField('acl-data', 'acl-data-hint');
+setupUserIdField('group-member-data', 'group-member-data-hint');
+
+
async function saveReader(readerId, card) {
const enabled = card.querySelector('[data-field="enabled"]').checked;
const armState = card.querySelector('[data-field="armState"]').checked ? 1 : 0;
@@ -807,25 +886,25 @@ const ACL_TYPE_LABELS = {
function aclEntryDescription(entry) {
switch (entry.type) {
case 0:
- return `User ID: ${entry.data}`;
+ return `User ID: ${formatRobloxUserId(entry.data)}`;
case 1:
- return `Card number: ${entry.data}`;
+ return `Card number: ${escapeHtml(entry.data)}`;
case 2: {
const [group, rank] = entry.data.split(':');
- return `Group ${group}, exact rank ${rank}`;
+ return `Group ${escapeHtml(group)}, exact rank ${escapeHtml(rank)}`;
}
case 3: {
const [group, rank] = entry.data.split(':');
- return `Group ${group}, rank ${rank}+`;
+ return `Group ${escapeHtml(group)}, rank ${escapeHtml(rank)}+`;
}
case 4:
return 'Everyone';
case 5: {
const group = accessGroups.find(g => String(g.id) === String(entry.data));
- return `Access group: ${group ? group.name : entry.data}`;
+ return `Access group: ${escapeHtml(group ? group.name : entry.data)}`;
}
default:
- return entry.data;
+ return escapeHtml(entry.data);
}
}
@@ -882,6 +961,7 @@ async function openAclModal(readerId, readerName) {
document.getElementById('acl-modal').classList.remove('hidden');
document.getElementById('add-acl-form').reset();
document.getElementById('add-acl-form').classList.toggle('hidden', !currentPlaceCanEdit);
+ document.getElementById('acl-data-hint').classList.add('hidden');
updateAclFormFields();
await loadAcl();
}
@@ -894,7 +974,9 @@ function closeAclModal() {
async function loadAcl() {
if (!currentAclReaderId) return;
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) {
@@ -913,7 +995,7 @@ function renderAclList(entries) {
li.className = 'acl-entry';
li.innerHTML = `
${escapeHtml(ACL_TYPE_LABELS[entry.type] || 'Unknown')}
- ${escapeHtml(aclEntryDescription(entry))}
+ ${aclEntryDescription(entry)}
${currentPlaceCanEdit ? '' : ''}
`;
if (currentPlaceCanEdit) {
@@ -940,7 +1022,18 @@ document.getElementById('add-acl-form').addEventListener('submit', async (e) =>
const type = parseInt(document.getElementById('acl-type').value, 10);
let data;
- if (type === 0 || type === 1) {
+ if (type === 0) {
+ data = document.getElementById('acl-data').value.trim();
+ if (!data) return;
+ if (!/^\d+$/.test(data)) {
+ const userId = await lookupRobloxUsername(data);
+ if (!userId) {
+ alert(`No Roblox user found with username "${data}"`);
+ return;
+ }
+ data = userId;
+ }
+ } else if (type === 1) {
data = document.getElementById('acl-data').value.trim();
if (!data) return;
} else if (type === 2 || type === 3) {
@@ -1080,6 +1173,7 @@ async function openGroupModal(groupId, groupName) {
document.getElementById('group-modal').classList.remove('hidden');
document.getElementById('add-group-member-form').reset();
document.getElementById('add-group-member-form').classList.toggle('hidden', !currentPlaceCanEdit);
+ document.getElementById('group-member-data-hint').classList.add('hidden');
updateGroupMemberFormFields();
await loadGroupMembers();
}
@@ -1092,7 +1186,9 @@ function closeGroupModal() {
async function loadGroupMembers() {
if (!currentGroupId) return;
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) {
@@ -1111,7 +1207,7 @@ function renderGroupMembersList(members) {
li.className = 'acl-entry';
li.innerHTML = `
${escapeHtml(ACL_TYPE_LABELS[member.type] || 'Unknown')}
- ${escapeHtml(aclEntryDescription(member))}
+ ${aclEntryDescription(member)}
${currentPlaceCanEdit ? '' : ''}
`;
if (currentPlaceCanEdit) {
@@ -1138,7 +1234,18 @@ document.getElementById('add-group-member-form').addEventListener('submit', asyn
const type = parseInt(document.getElementById('group-member-type').value, 10);
let data;
- if (type === 0 || type === 1) {
+ if (type === 0) {
+ data = document.getElementById('group-member-data').value.trim();
+ if (!data) return;
+ if (!/^\d+$/.test(data)) {
+ const userId = await lookupRobloxUsername(data);
+ if (!userId) {
+ alert(`No Roblox user found with username "${data}"`);
+ return;
+ }
+ data = userId;
+ }
+ } else if (type === 1) {
data = document.getElementById('group-member-data').value.trim();
if (!data) return;
} else if (type === 2 || type === 3) {
@@ -1188,7 +1295,9 @@ function closeLogsModal() {
async function loadLogs() {
if (!currentLogsReaderId) return;
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) {
@@ -1207,9 +1316,10 @@ function renderLogsList(logs) {
li.className = 'acl-entry';
const granted = !!log.granted;
const cards = log.cardNumbers ? `, cards: ${escapeHtml(log.cardNumbers)}` : '';
+ const user = log.userId ? formatRobloxUserId(log.userId) : 'unknown';
li.innerHTML = `
${granted ? 'Granted' : 'Denied'}
- ${escapeHtml(log.scannedAt)} — user: ${escapeHtml(log.userId || 'unknown')}${cards}
+ ${escapeHtml(log.scannedAt)} — user: ${user}${cards}
`;
list.appendChild(li);
});
diff --git a/routes/api/v1/ingame.js b/routes/api/v1/ingame.js
index f6824ea..893a390 100644
--- a/routes/api/v1/ingame.js
+++ b/routes/api/v1/ingame.js
@@ -2,12 +2,21 @@ let db;
const express = require('express');
const router = express.Router();
const { sendPlaceWebhook } = require('../../../lib/webhooks');
+const { getUserById } = require('../../../lib/roblox');
module.exports = (dbInit) => {
db = dbInit;
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) => {
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);
}
);
- sendPlaceWebhook(place, 'access_denied', {
- Reader: ap.name,
- 'User ID': userId || 'unknown',
- 'Card(s)': cardNumbers.join(', ') || undefined,
- Reason: globalLockdownActive ? 'Site-wide lockdown active' : 'Place lockdown active'
+ formatUserIdForWebhook(userId).then((userLabel) => {
+ sendPlaceWebhook(place, 'access_denied', {
+ Reader: ap.name,
+ 'User ID': userLabel,
+ 'Card(s)': cardNumbers.join(', ') || undefined,
+ Reason: globalLockdownActive ? 'Site-wide lockdown active' : 'Place lockdown active'
+ });
});
return res.json({
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);
}
);
- sendPlaceWebhook(place, responseCode, {
- Reader: ap.name,
- 'User ID': userId || 'unknown',
- 'Card(s)': cardNumbers.join(', ') || undefined
+ formatUserIdForWebhook(userId).then((userLabel) => {
+ sendPlaceWebhook(place, responseCode, {
+ Reader: ap.name,
+ 'User ID': userLabel,
+ 'Card(s)': cardNumbers.join(', ') || undefined
+ });
});
const responseData = granted ? {
diff --git a/routes/dashboard.js b/routes/dashboard.js
index 20362f3..1726247 100644
--- a/routes/dashboard.js
+++ b/routes/dashboard.js
@@ -6,6 +6,7 @@ const { requireAuth, ROLES, ORG_ROLES } = require('../lib/auth');
const { generatePlaceId, generateApiKey, generateReaderId } = require('../lib/generators');
const { getEffectiveOrgRole } = require('../lib/orgs');
const { sendPlaceWebhook, sendTestWebhook } = require('../lib/webhooks');
+const { getUserByUsername, getUserById, getUsersByIds } = require('../lib/roblox');
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 ---
// List organizations the current user belongs to, plus (for platform elevated/admin/superadmin