document.addEventListener('DOMContentLoaded', () => { const addForm = document.getElementById('add-form'); const instancesList = document.getElementById('instances-list'); const refreshBtn = document.getElementById('refresh-btn'); const portInput = document.getElementById('port'); const ufwCmd = document.getElementById('ufw-cmd'); const iptablesCmd = document.getElementById('iptables-cmd'); const winCmd = document.getElementById('win-cmd'); const loginOverlay = document.getElementById('login-overlay'); const loginForm = document.getElementById('login-form'); const loginError = document.getElementById('login-error'); const logoutBtn = document.getElementById('logout-btn'); const mainNav = document.getElementById('main-nav'); const navAdmin = document.getElementById('nav-admin'); const usersList = document.getElementById('users-list'); const createUserForm = document.getElementById('create-user-form'); const changePasswordForm = document.getElementById('change-password-form'); const changeUsernameForm = document.getElementById('change-username-form'); const profileSuccess = document.getElementById('profile-success'); const helpBtn = document.getElementById('help-btn'); const helpModal = document.getElementById('help-modal'); const addInstanceModal = document.getElementById('add-instance-modal'); const showAddBtn = document.getElementById('show-add-btn'); const showAddUserBtn = document.getElementById('show-add-user-btn'); const addUserModal = document.getElementById('add-user-modal'); const modalTitle = document.querySelector('#add-instance-modal h2'); const secretInput = document.getElementById('secret'); let config = { firewall_host_ip: null }; let currentUser = null; let editId = null; let instancesData = []; const openModal = (modal) => modal.classList.add('active'); const closeModal = (modal) => { modal.classList.remove('active'); editId = null; addForm.reset(); modalTitle.textContent = 'Add Instance'; secretInput.placeholder = 'Base32 Secret or otpauth:// URL'; secretInput.required = true; }; document.querySelectorAll('.btn-close').forEach(btn => { btn.addEventListener('click', () => { closeModal(btn.closest('.modal')); }); }); window.addEventListener('click', (e) => { if (e.target.classList.contains('modal')) closeModal(e.target); }); helpBtn.addEventListener('click', () => openModal(helpModal)); showAddBtn.addEventListener('click', () => { editId = null; modalTitle.textContent = 'Add Instance'; secretInput.placeholder = 'Base32 Secret or otpauth:// URL'; secretInput.required = true; openModal(addInstanceModal); }); showAddUserBtn.addEventListener('click', () => openModal(addUserModal)); const showLogin = () => { loginOverlay.style.display = 'flex'; logoutBtn.style.display = 'none'; mainNav.style.display = 'none'; }; const hideLogin = () => { loginOverlay.style.display = 'none'; logoutBtn.style.display = 'flex'; mainNav.style.display = 'flex'; }; // Tab Switching document.querySelectorAll('.nav-item').forEach(item => { item.addEventListener('click', () => { document.querySelectorAll('.nav-item').forEach(i => i.classList.remove('active')); document.querySelectorAll('.tab-content').forEach(i => i.classList.remove('active')); item.classList.add('active'); document.getElementById(`${item.dataset.tab}-section`).classList.add('active'); if (item.dataset.tab === 'admin') fetchUsers(); if (item.dataset.tab === 'instances') fetchInstances(); }); }); const updateFirewallCmds = () => { if (!ufwCmd || !iptablesCmd || !winCmd) return; const port = portInput.value || '4646'; const hostIp = config.firewall_host_ip || window.location.hostname; ufwCmd.textContent = `sudo ufw allow from ${hostIp} to any port ${port} proto tcp`; iptablesCmd.textContent = `sudo iptables -I INPUT -p tcp -s ${hostIp} --dport ${port} -j ACCEPT`; winCmd.textContent = `New-NetFirewallRule -DisplayName "XIVLauncher OTP" -Direction Inbound -RemoteAddress ${hostIp} -LocalPort ${port} -Protocol TCP -Action Allow`; }; const fetchConfig = async () => { try { const res = await fetch('/api/config'); if (res.status === 401) return showLogin(); if (res.ok) { config = await res.json(); updateFirewallCmds(); } } catch (e) { console.error('Failed to fetch config:', e); } }; const fetchInstances = async () => { try { const res = await fetch('/api/instances'); if (res.status === 401) return showLogin(); instancesData = await res.json(); renderInstances(instancesData); } catch (e) { console.error('Failed to fetch instances:', e); } }; const fetchUsers = async () => { try { const res = await fetch('/api/users'); if (res.status === 401) return showLogin(); if (res.ok) { const users = await res.json(); renderUsers(users); } } catch (e) { console.error('Failed to fetch users:', e); } }; const renderInstances = (instances) => { if (instances.length === 0) { instancesList.innerHTML = '
No instances configured yet.
'; return; } instancesList.innerHTML = instances.map(inst => { let statusClass = 'status-unknown'; if (inst.last_status === 'Sent') statusClass = 'status-sent'; else if (inst.last_status === 'Offline') statusClass = 'status-offline'; else if (inst.last_status.startsWith('Failed')) statusClass = 'status-offline'; const timeAgo = inst.last_checked ? Math.round((Date.now()/1000 - inst.last_checked)) + 's ago' : 'Never'; return `

${escapeHtml(inst.name)}

${escapeHtml(inst.ip)}:${inst.port} ${escapeHtml(inst.last_status)} Last checked: ${timeAgo}
`; }).join(''); }; const renderUsers = (users) => { usersList.innerHTML = users.map(user => `

${escapeHtml(user.username)} ${user.is_admin ? 'Admin' : ''}

`).join(''); }; addForm.addEventListener('submit', async (e) => { e.preventDefault(); const name = document.getElementById('name').value; const ip = document.getElementById('ip').value; const port = parseInt(document.getElementById('port').value, 10); const secret = document.getElementById('secret').value; const url = editId ? `/api/instances/${editId}` : '/api/instances'; const method = editId ? 'PUT' : 'POST'; const body = { name, ip, port }; if (secret) body.secret = secret; try { const res = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); if (res.ok) { closeModal(addInstanceModal); fetchInstances(); } else if (res.status === 401) { showLogin(); } else { alert(`Failed to ${editId ? 'update' : 'add'} instance`); } } catch (e) { console.error('Error saving instance', e); } }); window.editInstance = (id) => { const inst = instancesData.find(i => i.id === id); if (!inst) return; editId = id; document.getElementById('name').value = inst.name; document.getElementById('ip').value = inst.ip; document.getElementById('port').value = inst.port; secretInput.value = ''; secretInput.required = false; secretInput.placeholder = 'Leave blank to keep current secret'; modalTitle.textContent = 'Edit Instance'; openModal(addInstanceModal); }; loginForm.addEventListener('submit', async (e) => { e.preventDefault(); const username = document.getElementById('login-username').value; const password = document.getElementById('login-password').value; try { const res = await fetch('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }) }); if (res.ok) { const data = await res.json(); currentUser = data.user; setupUIForUser(); hideLogin(); loginError.style.display = 'none'; document.getElementById('login-password').value = ''; fetchConfig(); fetchInstances(); } else { loginError.style.display = 'block'; } } catch (e) { console.error('Login error', e); } }); const setupUIForUser = () => { if (currentUser && currentUser.is_admin) { navAdmin.style.display = 'block'; } else { navAdmin.style.display = 'none'; } document.getElementById('profile-username').value = currentUser.username; }; logoutBtn.addEventListener('click', async () => { try { await fetch('/api/logout'); currentUser = null; showLogin(); } catch (e) { console.error('Logout error', e); } }); changeUsernameForm.addEventListener('submit', async (e) => { e.preventDefault(); const username = document.getElementById('profile-username').value; try { const res = await fetch('/api/users/me/username', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username }) }); if (res.ok) { currentUser.username = username; profileSuccess.style.display = 'block'; setTimeout(() => profileSuccess.style.display = 'none', 3000); } else { const data = await res.json(); alert(data.detail || 'Failed to update username'); } } catch (e) { console.error('Error changing username', e); } }); createUserForm.addEventListener('submit', async (e) => { e.preventDefault(); const username = document.getElementById('new-username').value; const password = document.getElementById('new-password').value; const is_admin = document.getElementById('new-is-admin').checked; try { const res = await fetch('/api/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password, is_admin }) }); if (res.ok) { createUserForm.reset(); closeModal(addUserModal); fetchUsers(); } else { const data = await res.json(); alert(data.detail || 'Failed to create user'); } } catch (e) { console.error('Error creating user', e); } }); changePasswordForm.addEventListener('submit', async (e) => { e.preventDefault(); const password = document.getElementById('profile-new-password').value; try { const res = await fetch('/api/users/me/password', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password }) }); if (res.ok) { changePasswordForm.reset(); profileSuccess.style.display = 'block'; setTimeout(() => profileSuccess.style.display = 'none', 3000); } } catch (e) { console.error('Error changing password', e); } }); refreshBtn.addEventListener('click', fetchInstances); window.deleteInstance = async (id) => { if (!confirm('Are you sure you want to delete this instance?')) return; try { const res = await fetch(`/api/instances/${id}`, { method: 'DELETE' }); if (res.status === 401) return showLogin(); if (res.ok) fetchInstances(); } catch (e) { console.error('Error deleting instance', e); } }; window.deleteUser = async (id) => { if (!confirm('Are you sure you want to delete this user? All their instances will also be deleted.')) return; try { const res = await fetch(`/api/users/${id}`, { method: 'DELETE' }); if (res.status === 401) return showLogin(); if (res.ok) fetchUsers(); else { const data = await res.json(); alert(data.detail || 'Failed to delete user'); } } catch (e) { console.error('Error deleting user', e); } }; window.resetUserPassword = async (id) => { const password = prompt('Enter new password for this user:'); if (!password) return; try { const res = await fetch(`/api/users/${id}/password`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password }) }); if (res.status === 401) return showLogin(); if (res.ok) alert('Password updated'); } catch (e) { console.error('Error resetting password', e); } }; window.copyToClipboard = async (elementId) => { const text = document.getElementById(elementId).textContent; const btn = event.currentTarget; try { await navigator.clipboard.writeText(text); const originalHtml = btn.innerHTML; btn.classList.add('copied'); btn.innerHTML = ''; setTimeout(() => { btn.classList.remove('copied'); btn.innerHTML = originalHtml; }, 2000); } catch (err) { console.error('Failed to copy: ', err); } }; portInput.addEventListener('input', updateFirewallCmds); const checkAuth = async () => { try { const res = await fetch('/api/auth/status'); const data = await res.json(); if (data.authenticated) { currentUser = data.user; setupUIForUser(); hideLogin(); fetchConfig(); fetchInstances(); } else { showLogin(); } } catch (e) { showLogin(); } }; function escapeHtml(unsafe) { if (!unsafe) return ''; return unsafe.toString() .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } checkAuth(); setInterval(() => { if (loginOverlay.style.display === 'none' && document.getElementById('instances-section').classList.contains('active')) { fetchInstances(); } }, 5000); });