1334 lines
44 KiB
JavaScript
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();
|