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 = ` ${escapeHtml(member.username)} Admin (owner) `; list.appendChild(li); return; } const roleOptions = Object.entries(ORG_ROLE_LABELS) .map(([value, label]) => ``) .join(''); li.innerHTML = ` ${escapeHtml(member.username)} `; 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 = `
${escapeHtml(ap.name)}
${escapeHtml(ap.id)}
Enabled
Armed
Unlock time (s)
${currentPlaceCanEdit ? '' : ''} ${currentPlaceCanEdit ? '' : ''}
`; 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)} (${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; 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 = ` ${escapeHtml(ACL_TYPE_LABELS[entry.type] || 'Unknown')} ${aclEntryDescription(entry)} ${currentPlaceCanEdit ? '' : ''} `; 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 = ` Group ${escapeHtml(group.name)} ${currentPlaceCanEdit ? '' : ''} `; 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 = ` ${escapeHtml(ACL_TYPE_LABELS[member.type] || 'Unknown')} ${aclEntryDescription(member)} ${currentPlaceCanEdit ? '' : ''} `; 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 = ` ${granted ? 'Granted' : 'Denied'} ${escapeHtml(log.scannedAt)} — user: ${user}${cards} `; list.appendChild(li); }); } document.getElementById('logs-close-btn').addEventListener('click', closeLogsModal); document.getElementById('logs-modal').addEventListener('click', (e) => { if (e.target.id === 'logs-modal') closeLogsModal(); }); init();