notXCS/public/js/dashboard.js
2026-07-03 16:10:46 -06:00

1334 lines
44 KiB
JavaScript

let currentPlaceId = null;
let places = [];
let accessGroups = [];
let currentUser = null;
let currentOrgId = null;
let orgs = [];
let currentOrgRole = null;
let currentOrgBypass = false;
const ROLE_LABELS = {
1: 'User',
2: 'Elevated',
3: 'Admin',
4: 'Superadmin'
};
const ORG_ROLE_LABELS = {
1: 'View only',
2: 'Edit',
3: 'Admin'
};
async function api(path, options = {}) {
const res = await fetch(path, {
headers: { 'Content-Type': 'application/json' },
...options
});
const data = await res.json().catch(() => ({ success: false, message: 'Invalid response from server' }));
if (res.status === 401) {
window.location.href = '/login';
throw new Error('Not authenticated');
}
return data;
}
async function init() {
const me = await api('/auth/me');
if (!me.success) {
window.location.href = '/login';
return;
}
currentUser = me.user;
document.getElementById('username-display').textContent = me.user.username;
document.getElementById('role-badge').textContent = ROLE_LABELS[me.user.role] || 'User';
document.getElementById('admin-link').classList.toggle('hidden', me.user.role < 3);
document.getElementById('add-place-custom-fields').classList.toggle('hidden', me.user.role < 3);
document.getElementById('set-api-key-form').classList.toggle('hidden', me.user.role < 3);
await loadLockdownBanner();
await loadOrgs();
}
async function loadLockdownBanner() {
const data = await api('/dashboard/lockdown');
const banner = document.getElementById('lockdown-banner');
if (banner) {
banner.classList.toggle('hidden', !(data.success && data.active));
}
}
// --- Organizations ---
const LAST_ORG_STORAGE_KEY = 'notxcs_last_org_id';
async function loadOrgs() {
const data = await api('/dashboard/orgs');
orgs = data.orgs || [];
renderOrgSelect();
if (orgs.length === 0) {
currentOrgId = null;
currentOrgRole = null;
currentOrgBypass = false;
updateOrgGatedUI();
return;
}
const stored = parseInt(localStorage.getItem(LAST_ORG_STORAGE_KEY), 10);
const initialOrg = orgs.find(o => o.id === stored) || orgs[0];
await selectOrg(initialOrg.id);
}
function renderOrgSelect() {
const select = document.getElementById('org-select');
select.innerHTML = '';
orgs.forEach((org) => {
const option = document.createElement('option');
option.value = org.id;
option.textContent = org.name;
if (org.id === currentOrgId) option.selected = true;
select.appendChild(option);
});
}
async function selectOrg(orgId) {
currentOrgId = orgId;
const org = orgs.find(o => o.id === orgId);
currentOrgRole = org ? org.role : null;
currentOrgBypass = org ? org.bypass : false;
localStorage.setItem(LAST_ORG_STORAGE_KEY, String(orgId));
renderOrgSelect();
updateOrgGatedUI();
currentPlaceId = null;
document.getElementById('place-view').classList.add('hidden');
document.getElementById('no-place').classList.remove('hidden');
await loadPlaces();
}
// Shows/hides UI that depends on the caller's role within the current organization: Edit is needed
// to add places, Admin is needed to manage the organization itself (rename/members/delete).
function updateOrgGatedUI() {
const canEdit = currentOrgRole >= 2;
const canAdmin = currentOrgRole >= 3;
document.getElementById('add-place-card').classList.toggle('hidden', !canEdit);
document.getElementById('manage-org-btn').classList.toggle('hidden', !canAdmin || !currentOrgId);
}
document.getElementById('org-select').addEventListener('change', (e) => {
selectOrg(parseInt(e.target.value, 10));
});
document.getElementById('new-org-btn').addEventListener('click', () => {
document.getElementById('new-org-form').reset();
document.getElementById('new-org-modal').classList.remove('hidden');
});
document.getElementById('new-org-close-btn').addEventListener('click', () => {
document.getElementById('new-org-modal').classList.add('hidden');
});
document.getElementById('new-org-modal').addEventListener('click', (e) => {
if (e.target.id === 'new-org-modal') document.getElementById('new-org-modal').classList.add('hidden');
});
document.getElementById('new-org-form').addEventListener('submit', async (e) => {
e.preventDefault();
const name = document.getElementById('new-org-name').value.trim();
if (!name) return;
const result = await api('/dashboard/orgs', {
method: 'POST',
body: JSON.stringify({ name })
});
if (result.success) {
document.getElementById('new-org-modal').classList.add('hidden');
await loadOrgs();
await selectOrg(result.org.id);
} else {
alert(result.message || 'Failed to create organization');
}
});
// --- Organization management modal (rename, members, delete) ---
let orgMembers = [];
async function openOrgModal() {
if (!currentOrgId) return;
const org = orgs.find(o => o.id === currentOrgId);
document.getElementById('org-modal-name').textContent = org ? org.name : '';
document.getElementById('org-rename-input').value = org ? org.name : '';
document.getElementById('org-add-member-form').reset();
document.getElementById('org-modal').classList.remove('hidden');
await loadOrgMembers();
}
function closeOrgModal() {
document.getElementById('org-modal').classList.add('hidden');
}
document.getElementById('manage-org-btn').addEventListener('click', openOrgModal);
document.getElementById('org-modal-close-btn').addEventListener('click', closeOrgModal);
document.getElementById('org-modal').addEventListener('click', (e) => {
if (e.target.id === 'org-modal') closeOrgModal();
});
document.getElementById('org-rename-form').addEventListener('submit', async (e) => {
e.preventDefault();
if (!currentOrgId) return;
const name = document.getElementById('org-rename-input').value.trim();
if (!name) return;
const result = await api(`/dashboard/orgs/${encodeURIComponent(currentOrgId)}`, {
method: 'PUT',
body: JSON.stringify({ name })
});
if (result.success) {
await loadOrgs();
document.getElementById('org-modal-name').textContent = name;
} else {
alert(result.message || 'Failed to rename organization');
}
});
async function loadOrgMembers() {
if (!currentOrgId) return;
const data = await api(`/dashboard/orgs/${encodeURIComponent(currentOrgId)}/members`);
orgMembers = data.members || [];
renderOrgMembersList();
}
function renderOrgMembersList() {
const list = document.getElementById('org-members-list');
const empty = document.getElementById('org-members-empty');
list.innerHTML = '';
if (orgMembers.length === 0) {
empty.classList.remove('hidden');
return;
}
empty.classList.add('hidden');
const org = orgs.find(o => o.id === currentOrgId);
const isOwner = (userId) => org && userId === org.ownerId;
orgMembers.forEach((member) => {
const li = document.createElement('li');
li.className = 'acl-entry';
if (isOwner(member.userId)) {
li.innerHTML = `
<span class="acl-description">${escapeHtml(member.username)}</span>
<span class="role-pill">Admin (owner)</span>
`;
list.appendChild(li);
return;
}
const roleOptions = Object.entries(ORG_ROLE_LABELS)
.map(([value, label]) => `<option value="${value}" ${parseInt(value, 10) === member.role ? 'selected' : ''}>${label}</option>`)
.join('');
li.innerHTML = `
<span class="acl-description">${escapeHtml(member.username)}</span>
<select class="org-member-role-select">${roleOptions}</select>
<button class="secondary org-member-save-btn">Save</button>
<button class="danger org-member-remove-btn">Remove</button>
`;
li.querySelector('.org-member-save-btn').addEventListener('click', () => saveOrgMemberRole(member.userId, li));
li.querySelector('.org-member-remove-btn').addEventListener('click', () => removeOrgMember(member.userId, member.username));
list.appendChild(li);
});
}
async function saveOrgMemberRole(userId, li) {
const role = parseInt(li.querySelector('.org-member-role-select').value, 10);
const result = await api(`/dashboard/orgs/${encodeURIComponent(currentOrgId)}/members/${encodeURIComponent(userId)}`, {
method: 'PUT',
body: JSON.stringify({ role })
});
if (result.success) {
await loadOrgMembers();
} else {
alert(result.message || 'Failed to update member role');
}
}
async function removeOrgMember(userId, username) {
if (!confirm(`Remove ${username} from this organization?`)) return;
const result = await api(`/dashboard/orgs/${encodeURIComponent(currentOrgId)}/members/${encodeURIComponent(userId)}`, {
method: 'DELETE'
});
if (result.success) {
await loadOrgMembers();
} else {
alert(result.message || 'Failed to remove member');
}
}
document.getElementById('org-add-member-form').addEventListener('submit', async (e) => {
e.preventDefault();
if (!currentOrgId) return;
const username = document.getElementById('org-member-username').value.trim();
const role = parseInt(document.getElementById('org-member-role').value, 10);
if (!username) return;
const result = await api(`/dashboard/orgs/${encodeURIComponent(currentOrgId)}/members`, {
method: 'POST',
body: JSON.stringify({ username, role })
});
if (result.success) {
document.getElementById('org-add-member-form').reset();
await loadOrgMembers();
} else {
alert(result.message || 'Failed to add member');
}
});
document.getElementById('org-delete-btn').addEventListener('click', async () => {
if (!currentOrgId) return;
if (!confirm('Delete this organization? It must own no places.')) return;
const result = await api(`/dashboard/orgs/${encodeURIComponent(currentOrgId)}`, { method: 'DELETE' });
if (result.success) {
closeOrgModal();
await loadOrgs();
} else {
alert(result.message || 'Failed to delete organization');
}
});
async function loadPlaces() {
if (!currentOrgId) {
places = [];
renderPlaceList();
return;
}
const data = await api(`/dashboard/places?orgId=${encodeURIComponent(currentOrgId)}`);
places = data.places || [];
renderPlaceList();
}
function renderPlaceList() {
const list = document.getElementById('place-list');
list.innerHTML = '';
places.forEach((place) => {
const li = document.createElement('li');
li.className = place.id === currentPlaceId ? 'active' : '';
const row = document.createElement('div');
row.className = 'place-list-row';
const idSpan = document.createElement('span');
idSpan.className = 'place-list-id';
idSpan.textContent = place.name || place.id;
row.appendChild(idSpan);
if (place.lockdown) {
const lockdownBadge = document.createElement('span');
lockdownBadge.className = 'badge off';
lockdownBadge.textContent = 'Locked down';
row.appendChild(lockdownBadge);
}
li.appendChild(row);
li.addEventListener('click', () => selectPlace(place.id));
list.appendChild(li);
});
}
let currentPlaceCanEdit = false;
async function selectPlace(placeId) {
currentPlaceId = placeId;
renderPlaceList();
const data = await api(`/dashboard/places/${encodeURIComponent(placeId)}`);
if (!data.success) return;
document.getElementById('no-place').classList.add('hidden');
document.getElementById('place-view').classList.remove('hidden');
document.getElementById('place-title').textContent = data.place.name || placeId;
document.getElementById('place-id-display').value = placeId;
document.getElementById('place-name-custom').value = data.place.name || '';
const apiKeyInput = document.getElementById('place-api-key-display');
apiKeyInput.value = data.place.apiKey || '';
apiKeyInput.type = 'password';
document.getElementById('toggle-api-key-btn').textContent = 'Show';
document.getElementById('place-api-key-custom').value = '';
populateWebhookForm(data.place);
currentPlaceCanEdit = currentOrgRole >= 2;
const canEdit = currentPlaceCanEdit;
document.getElementById('delete-place-btn').classList.toggle('hidden', !canEdit);
document.getElementById('move-org-card').classList.toggle('hidden', !canEdit);
document.getElementById('set-place-name-form').querySelector('button').disabled = !canEdit;
document.getElementById('regenerate-api-key-btn').disabled = !canEdit;
document.getElementById('add-reader-form').querySelector('button').disabled = !canEdit;
document.getElementById('add-access-group-form').querySelector('button').disabled = !canEdit;
setWebhookFormEditable(canEdit);
if (canEdit) {
populateMoveOrgSelect();
}
updatePlaceLockdownUI(!!data.place.lockdown);
renderReaders(data.accessPoints || []);
await loadAccessGroups();
}
function populateMoveOrgSelect() {
const select = document.getElementById('move-org-select');
select.innerHTML = '';
orgs.filter(o => o.role >= 2 && o.id !== currentOrgId).forEach((org) => {
const option = document.createElement('option');
option.value = org.id;
option.textContent = org.name;
select.appendChild(option);
});
document.getElementById('move-org-form').querySelector('button').disabled = select.options.length === 0;
}
document.getElementById('move-org-form').addEventListener('submit', async (e) => {
e.preventDefault();
if (!currentPlaceId) return;
const destOrgId = parseInt(document.getElementById('move-org-select').value, 10);
if (!destOrgId) return;
if (!confirm('Move this place to the selected organization? Members of the current organization will lose access to it.')) return;
const result = await api(`/dashboard/places/${encodeURIComponent(currentPlaceId)}/move`, {
method: 'POST',
body: JSON.stringify({ orgId: destOrgId })
});
if (result.success) {
currentPlaceId = null;
document.getElementById('place-view').classList.add('hidden');
document.getElementById('no-place').classList.remove('hidden');
await loadPlaces();
} else {
alert(result.message || 'Failed to move place');
}
});
function updatePlaceLockdownUI(active) {
const engageBtn = document.getElementById('engage-place-lockdown-btn');
const liftBtn = document.getElementById('lift-place-lockdown-btn');
const banner = document.getElementById('place-lockdown-banner');
if (active) {
engageBtn.classList.add('hidden');
liftBtn.classList.toggle('hidden', !currentPlaceCanEdit);
banner.classList.remove('hidden');
} else {
engageBtn.classList.toggle('hidden', !currentPlaceCanEdit);
liftBtn.classList.add('hidden');
banner.classList.add('hidden');
}
const place = places.find((p) => p.id === currentPlaceId);
if (place) {
place.lockdown = active ? 1 : 0;
renderPlaceList();
}
}
function renderReaders(accessPoints) {
const grid = document.getElementById('readers-grid');
const empty = document.getElementById('readers-empty');
grid.innerHTML = '';
if (accessPoints.length === 0) {
empty.classList.remove('hidden');
return;
}
empty.classList.add('hidden');
accessPoints.forEach((ap) => {
const card = document.createElement('div');
card.className = 'reader-card';
card.innerHTML = `
<div class="reader-name">${escapeHtml(ap.name)}</div>
<div class="reader-id">${escapeHtml(ap.id)}</div>
<div class="field">
<span>Enabled</span>
<input type="checkbox" data-field="enabled" ${ap.enabled ? 'checked' : ''} ${currentPlaceCanEdit ? '' : 'disabled'}>
</div>
<div class="field">
<span>Armed</span>
<input type="checkbox" data-field="armState" ${ap.armState ? 'checked' : ''} ${currentPlaceCanEdit ? '' : 'disabled'}>
</div>
<div class="field">
<span>Unlock time (s)</span>
<input type="number" data-field="unlockTime" value="${ap.unlockTime}" min="0" ${currentPlaceCanEdit ? '' : 'disabled'}>
</div>
<div class="actions">
${currentPlaceCanEdit ? '<button class="secondary save-btn">Save</button>' : ''}
<button class="secondary acl-btn">Manage access</button>
<button class="secondary logs-btn">View logs</button>
${currentPlaceCanEdit ? '<button class="danger delete-btn">Delete</button>' : ''}
</div>
`;
if (currentPlaceCanEdit) {
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);
});
}
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = 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)} <span class="roblox-username">(${escapeHtml(user.name)})</span>`;
}
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;
const unlockTime = parseInt(card.querySelector('[data-field="unlockTime"]').value, 10) || 0;
await api(`/dashboard/places/${encodeURIComponent(currentPlaceId)}/access-points/${encodeURIComponent(readerId)}`, {
method: 'PUT',
body: JSON.stringify({ enabled, armState, unlockTime })
});
selectPlace(currentPlaceId);
}
async function deleteReader(readerId) {
if (!confirm(`Delete reader "${readerId}"?`)) return;
await api(`/dashboard/places/${encodeURIComponent(currentPlaceId)}/access-points/${encodeURIComponent(readerId)}`, {
method: 'DELETE'
});
selectPlace(currentPlaceId);
}
document.getElementById('add-place-form').addEventListener('submit', async (e) => {
e.preventDefault();
if (!currentOrgId) return;
const id = document.getElementById('place-id').value.trim();
const apiKey = document.getElementById('place-api-key').value.trim();
const name = document.getElementById('place-name').value.trim();
const body = { orgId: currentOrgId };
if (id) body.id = id;
if (apiKey) body.apiKey = apiKey;
if (name) body.name = name;
const data = await api('/dashboard/places', {
method: 'POST',
body: JSON.stringify(body)
});
if (data.success) {
document.getElementById('place-id').value = '';
document.getElementById('place-api-key').value = '';
document.getElementById('place-name').value = '';
await loadPlaces();
selectPlace(data.place.id);
} else {
alert(data.message || 'Failed to add place');
}
});
document.getElementById('add-reader-form').addEventListener('submit', async (e) => {
e.preventDefault();
if (!currentPlaceId) return;
const name = document.getElementById('reader-name').value.trim();
if (!name) return;
const data = await api(`/dashboard/places/${encodeURIComponent(currentPlaceId)}/access-points`, {
method: 'POST',
body: JSON.stringify({ name })
});
if (data.success) {
document.getElementById('reader-name').value = '';
selectPlace(currentPlaceId);
} else {
alert(data.message || 'Failed to add reader');
}
});
document.getElementById('delete-place-btn').addEventListener('click', async () => {
if (!currentPlaceId) return;
if (!confirm(`Delete place "${currentPlaceId}" and all of its readers?`)) return;
await api(`/dashboard/places/${encodeURIComponent(currentPlaceId)}`, { method: 'DELETE' });
currentPlaceId = null;
document.getElementById('place-view').classList.add('hidden');
document.getElementById('no-place').classList.remove('hidden');
await loadPlaces();
});
document.getElementById('engage-place-lockdown-btn').addEventListener('click', async () => {
if (!currentPlaceId) return;
if (!confirm('Engage lockdown for this place? This disables every reader here until lifted.')) return;
const result = await api(`/dashboard/places/${encodeURIComponent(currentPlaceId)}/lockdown`, { method: 'POST' });
if (result.success) {
updatePlaceLockdownUI(true);
} else {
alert(result.message || 'Failed to engage lockdown');
}
});
document.getElementById('lift-place-lockdown-btn').addEventListener('click', async () => {
if (!currentPlaceId) return;
const result = await api(`/dashboard/places/${encodeURIComponent(currentPlaceId)}/lockdown`, { method: 'DELETE' });
if (result.success) {
updatePlaceLockdownUI(false);
} else {
alert(result.message || 'Failed to lift lockdown');
}
});
document.getElementById('download-kit-btn').addEventListener('click', async () => {
if (!currentPlaceId) return;
const btn = document.getElementById('download-kit-btn');
const originalLabel = btn.textContent;
btn.textContent = 'Downloading...';
btn.disabled = true;
try {
const res = await fetch(`/dashboard/places/${encodeURIComponent(currentPlaceId)}/kit`);
if (res.status === 401) {
window.location.href = '/login';
return;
}
if (!res.ok) {
const data = await res.json().catch(() => ({}));
alert(data.message || 'Failed to download kit');
return;
}
const blob = await res.blob();
const disposition = res.headers.get('Content-Disposition') || '';
const match = disposition.match(/filename="([^"]+)"/);
const filename = match ? match[1] : `${currentPlaceId}.rbxmx`;
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
} catch (err) {
alert('Failed to download kit');
} finally {
btn.textContent = originalLabel;
btn.disabled = false;
}
});
document.getElementById('toggle-api-key-btn').addEventListener('click', () => {
const input = document.getElementById('place-api-key-display');
const btn = document.getElementById('toggle-api-key-btn');
const revealed = input.type === 'text';
input.type = revealed ? 'password' : 'text';
btn.textContent = revealed ? 'Show' : 'Hide';
});
document.getElementById('copy-api-key-btn').addEventListener('click', async () => {
const value = document.getElementById('place-api-key-display').value;
if (!value) return;
const btn = document.getElementById('copy-api-key-btn');
const originalLabel = btn.textContent;
try {
await navigator.clipboard.writeText(value);
btn.textContent = 'Copied!';
} catch (err) {
btn.textContent = 'Copy failed';
}
setTimeout(() => { btn.textContent = originalLabel; }, 1500);
});
document.getElementById('regenerate-api-key-btn').addEventListener('click', async () => {
if (!currentPlaceId) return;
if (!confirm('Regenerate the API key for this place? The old key will stop working immediately.')) return;
const data = await api(`/dashboard/places/${encodeURIComponent(currentPlaceId)}/regenerate-key`, { method: 'POST' });
if (data.success) {
document.getElementById('place-api-key-display').value = data.apiKey;
} else {
alert(data.message || 'Failed to regenerate API key');
}
});
document.getElementById('set-place-name-form').addEventListener('submit', async (e) => {
e.preventDefault();
if (!currentPlaceId) return;
const name = document.getElementById('place-name-custom').value.trim();
const data = await api(`/dashboard/places/${encodeURIComponent(currentPlaceId)}`, {
method: 'PUT',
body: JSON.stringify({ name })
});
if (data.success) {
await loadPlaces();
selectPlace(currentPlaceId);
} else {
alert(data.message || 'Failed to set place name');
}
});
document.getElementById('set-api-key-form').addEventListener('submit', async (e) => {
e.preventDefault();
if (!currentPlaceId) return;
const apiKey = document.getElementById('place-api-key-custom').value.trim();
const data = await api(`/dashboard/places/${encodeURIComponent(currentPlaceId)}`, {
method: 'PUT',
body: JSON.stringify({ apiKey })
});
if (data.success) {
document.getElementById('place-api-key-custom').value = '';
selectPlace(currentPlaceId);
} else {
alert(data.message || 'Failed to set API key');
}
});
// --- Discord webhook settings ---
function populateWebhookForm(place) {
document.getElementById('webhook-url').value = place.webhookUrl || '';
document.getElementById('webhook-content').value = place.webhookContent || '';
document.getElementById('webhook-access-granted').checked = !!place.webhookAccessGranted;
document.getElementById('webhook-access-denied').checked = !!place.webhookAccessDenied;
document.getElementById('webhook-reader-arm').checked = !!place.webhookReaderArm;
document.getElementById('webhook-reader-enable').checked = !!place.webhookReaderEnable;
}
function setWebhookFormEditable(canEdit) {
const form = document.getElementById('webhook-form');
form.querySelectorAll('input').forEach((input) => { input.disabled = !canEdit; });
form.querySelector('button[type="submit"]').disabled = !canEdit;
document.getElementById('webhook-test-btn').disabled = !canEdit;
}
document.getElementById('webhook-form').addEventListener('submit', async (e) => {
e.preventDefault();
if (!currentPlaceId) return;
const body = {
webhookUrl: document.getElementById('webhook-url').value.trim(),
webhookContent: document.getElementById('webhook-content').value.trim(),
webhookAccessGranted: document.getElementById('webhook-access-granted').checked,
webhookAccessDenied: document.getElementById('webhook-access-denied').checked,
webhookReaderArm: document.getElementById('webhook-reader-arm').checked,
webhookReaderEnable: document.getElementById('webhook-reader-enable').checked
};
const result = await api(`/dashboard/places/${encodeURIComponent(currentPlaceId)}/webhook`, {
method: 'PUT',
body: JSON.stringify(body)
});
if (result.success) {
await loadPlaces();
} else {
alert(result.message || 'Failed to save webhook settings');
}
});
document.getElementById('webhook-test-btn').addEventListener('click', async () => {
if (!currentPlaceId) return;
const btn = document.getElementById('webhook-test-btn');
const originalLabel = btn.textContent;
btn.textContent = 'Sending...';
btn.disabled = true;
try {
const result = await api(`/dashboard/places/${encodeURIComponent(currentPlaceId)}/webhook/test`, {
method: 'POST',
body: JSON.stringify({
webhookUrl: document.getElementById('webhook-url').value.trim(),
webhookContent: document.getElementById('webhook-content').value.trim()
})
});
if (result.success) {
btn.textContent = 'Sent!';
} else {
alert(result.message || 'Failed to send test webhook');
btn.textContent = originalLabel;
}
} finally {
btn.disabled = !currentPlaceCanEdit;
setTimeout(() => { btn.textContent = originalLabel; }, 1500);
}
});
document.getElementById('logout-btn').addEventListener('click', async () => {
await api('/auth/logout', { method: 'POST' });
window.location.href = '/login';
});
// --- ACL (allowed persons / credentials) management ---
let currentAclReaderId = null;
const ACL_TYPE_LABELS = {
0: 'User ID',
1: 'Card number',
2: 'Group exact rank',
3: 'Group min rank',
4: 'Allow all',
5: 'Access group'
};
function aclEntryDescription(entry) {
switch (entry.type) {
case 0:
return `User ID: ${formatRobloxUserId(entry.data)}`;
case 1:
return `Card number: ${escapeHtml(entry.data)}`;
case 2: {
const [group, rank] = entry.data.split(':');
return `Group ${escapeHtml(group)}, exact rank ${escapeHtml(rank)}`;
}
case 3: {
const [group, rank] = entry.data.split(':');
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: ${escapeHtml(group ? group.name : entry.data)}`;
}
default:
return escapeHtml(entry.data);
}
}
function updateAclFormFields() {
const type = parseInt(document.getElementById('acl-type').value, 10);
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) {
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');
} 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();
async function openAclModal(readerId, readerName) {
currentAclReaderId = readerId;
document.getElementById('acl-reader-name').textContent = 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();
}
function closeAclModal() {
currentAclReaderId = null;
document.getElementById('acl-modal').classList.add('hidden');
}
async function loadAcl() {
if (!currentAclReaderId) return;
const data = await api(`/dashboard/places/${encodeURIComponent(currentPlaceId)}/access-points/${encodeURIComponent(currentAclReaderId)}/acl`);
const entries = data.acl || [];
await resolveRobloxUsernames(entries.filter(e => e.type === 0).map(e => e.data));
renderAclList(entries);
}
function renderAclList(entries) {
const list = document.getElementById('acl-list');
const empty = document.getElementById('acl-empty');
list.innerHTML = '';
if (entries.length === 0) {
empty.classList.remove('hidden');
return;
}
empty.classList.add('hidden');
entries.forEach((entry) => {
const li = document.createElement('li');
li.className = 'acl-entry';
li.innerHTML = `
<span class="badge type-badge">${escapeHtml(ACL_TYPE_LABELS[entry.type] || 'Unknown')}</span>
<span class="acl-description">${aclEntryDescription(entry)}</span>
${currentPlaceCanEdit ? '<button class="danger acl-delete-btn">Remove</button>' : ''}
`;
if (currentPlaceCanEdit) {
li.querySelector('.acl-delete-btn').addEventListener('click', () => deleteAclEntry(entry.id));
}
list.appendChild(li);
});
}
async function deleteAclEntry(aclId) {
if (!currentAclReaderId) return;
if (!confirm('Remove this entry?')) return;
await api(`/dashboard/places/${encodeURIComponent(currentPlaceId)}/access-points/${encodeURIComponent(currentAclReaderId)}/acl/${encodeURIComponent(aclId)}`, {
method: 'DELETE'
});
await loadAcl();
}
document.getElementById('add-acl-form').addEventListener('submit', async (e) => {
e.preventDefault();
if (!currentAclReaderId) return;
const type = parseInt(document.getElementById('acl-type').value, 10);
let data;
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) {
const groupId = document.getElementById('acl-group-id').value.trim();
const rank = document.getElementById('acl-group-rank').value.trim();
if (!groupId || !rank) return;
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`, {
method: 'POST',
body: JSON.stringify({ type, data })
});
if (result.success) {
document.getElementById('add-acl-form').reset();
updateAclFormFields();
await loadAcl();
} else {
alert(result.message || 'Failed to add entry');
}
});
document.getElementById('acl-close-btn').addEventListener('click', closeAclModal);
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>
${currentPlaceCanEdit ? '<button class="danger group-delete-btn">Delete</button>' : ''}
`;
li.querySelector('.group-manage-btn').addEventListener('click', () => openGroupModal(group.id, group.name));
if (currentPlaceCanEdit) {
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();
document.getElementById('add-group-member-form').classList.toggle('hidden', !currentPlaceCanEdit);
document.getElementById('group-member-data-hint').classList.add('hidden');
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`);
const members = data.members || [];
await resolveRobloxUsernames(members.filter(m => m.type === 0).map(m => m.data));
renderGroupMembersList(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">${aclEntryDescription(member)}</span>
${currentPlaceCanEdit ? '<button class="danger group-member-delete-btn">Remove</button>' : ''}
`;
if (currentPlaceCanEdit) {
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) {
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) {
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`);
const logs = data.logs || [];
await resolveRobloxUsernames(logs.map(l => l.userId));
renderLogsList(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: ${escapeHtml(log.cardNumbers)}` : '';
const user = log.userId ? formatRobloxUserId(log.userId) : 'unknown';
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: ${user}${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();