// Tailscale Custom - Web Admin Frontend let authToken = ''; let currentUser = { username: '', role: '' }; let nodesData = []; let usersData = []; let accountsData = []; let refreshTimer = null; // ==================== Auth ==================== async function doLogin() { const username = document.getElementById('login-username').value.trim(); const password = document.getElementById('login-password').value; const errEl = document.getElementById('login-error'); errEl.style.display = 'none'; if (!username || !password) { errEl.textContent = 'Please enter username and password'; errEl.style.display = 'block'; return; } try { const resp = await fetch('api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }) }); const data = await resp.json(); if (!resp.ok || !data.ok) { errEl.textContent = data.error || 'Login failed'; errEl.style.display = 'block'; return; } authToken = data.token; currentUser = { username: data.username, role: data.role }; sessionStorage.setItem('authToken', authToken); sessionStorage.setItem('authUser', JSON.stringify(currentUser)); enterApp(); } catch (e) { errEl.textContent = 'Connection error'; errEl.style.display = 'block'; } } function doLogout() { api('api/auth/logout', 'POST').catch(() => {}); authToken = ''; currentUser = { username: '', role: '' }; sessionStorage.removeItem('authToken'); sessionStorage.removeItem('authUser'); if (refreshTimer) clearInterval(refreshTimer); document.getElementById('main-app').style.display = 'none'; document.getElementById('login-screen').style.display = 'flex'; document.getElementById('login-password').value = ''; } function enterApp() { document.getElementById('login-screen').style.display = 'none'; document.getElementById('main-app').style.display = 'flex'; // Update user display document.getElementById('user-display').textContent = currentUser.username; document.getElementById('user-avatar').textContent = currentUser.username.charAt(0).toUpperCase(); const badge = document.getElementById('user-role-badge'); badge.textContent = currentUser.role; badge.className = 'role-badge role-' + currentUser.role; // Show/hide tabs based on role const isAdmin = currentUser.role === 'admin'; document.querySelectorAll('.admin-only').forEach(el => el.style.display = isAdmin ? '' : 'none'); document.querySelectorAll('.user-only').forEach(el => el.style.display = isAdmin ? 'none' : ''); // Activate first visible tab const firstTab = document.querySelector('.nav-item[style=""], .nav-item:not([style])'); if (firstTab) switchTab(firstTab.dataset.tab); // Auto-refresh refreshAll(); if (refreshTimer) clearInterval(refreshTimer); refreshTimer = setInterval(refreshAll, 30000); } // ==================== API helper ==================== async function api(url, method = 'GET', body = null) { const opts = { method, headers: { 'Authorization': 'Bearer ' + authToken, 'Content-Type': 'application/json' } }; if (body) opts.body = JSON.stringify(body); const resp = await fetch(url, opts); if (resp.status === 401) { doLogout(); throw new Error('Session expired'); } return resp; } // ==================== Tab switching ==================== document.addEventListener('click', (e) => { const item = e.target.closest('.nav-item'); if (item) switchTab(item.dataset.tab); }); function switchTab(tab) { document.querySelectorAll('.nav-item').forEach(el => el.classList.toggle('active', el.dataset.tab === tab)); document.querySelectorAll('.tab-content').forEach(el => el.style.display = el.id === 'tab-' + tab ? '' : 'none'); // Refresh data for this tab switch (tab) { case 'dashboard': refreshDashboard(); break; case 'nodes': refreshNodes(); break; case 'accounts': refreshAccounts(); break; case 'users': refreshUsers(); break; case 'keys': refreshKeys(); break; case 'my-nodes': refreshMyNodes(); break; case 'download': refreshDownloads(); break; } } // ==================== Refresh all ==================== function refreshAll() { if (currentUser.role === 'admin') { refreshDashboard(); refreshNodes(); } else { refreshMyNodes(); } } // ==================== Dashboard (admin) ==================== async function refreshDashboard() { try { const [nodesResp, usersResp] = await Promise.all([ api('api/admin/nodes'), api('api/admin/users') ]); const nodesJson = await nodesResp.json(); const usersJson = await usersResp.json(); const nodes = nodesJson.nodes || []; const users = usersJson.users || []; const online = nodes.filter(n => isOnline(n)).length; const total = nodes.length; document.getElementById('stats-grid').innerHTML = `
${total}
Total Nodes
${online}
Online
${total - online}
Offline
${users.length}
Users
`; } catch (e) { console.error('dashboard', e); } } // ==================== Nodes (admin) ==================== async function refreshNodes() { try { const resp = await api('api/admin/nodes'); const data = await resp.json(); nodesData = data.nodes || []; renderNodes(); } catch (e) { console.error('nodes', e); } } function renderNodes() { const tbody = document.querySelector('#nodes-table tbody'); if (!nodesData.length) { tbody.innerHTML = 'No nodes found'; return; } tbody.innerHTML = nodesData.map(n => { const on = isOnline(n); const ip = (n.ipAddresses || [])[0] || '-'; const user = n.user?.name || '-'; const lastSeen = n.lastSeen ? timeAgo(n.lastSeen) : 'never'; const name = n.givenName || n.name || '-'; return ` ${esc(name)} ${esc(ip)} ${esc(user)} ${on ? 'Online' : 'Offline'} ${lastSeen} `; }).join(''); } async function deleteNode(id, name) { if (!confirm(`Delete node "${name}"?`)) return; try { await api('api/admin/nodes/' + id, 'DELETE'); toast('Node deleted'); refreshNodes(); refreshDashboard(); } catch (e) { toast('Error: ' + e.message, true); } } // ==================== Accounts (admin) ==================== async function refreshAccounts() { try { const resp = await api('api/admin/accounts'); const data = await resp.json(); accountsData = data.accounts || []; renderAccounts(); } catch (e) { console.error('accounts', e); } } function renderAccounts() { const tbody = document.querySelector('#accounts-table tbody'); if (!accountsData.length) { tbody.innerHTML = 'No accounts'; return; } tbody.innerHTML = accountsData.map(a => { const created = a.createdAt ? new Date(a.createdAt).toLocaleString() : '-'; const isAdmin = a.username === 'admin'; return ` ${esc(a.username)} ${a.role} ${created} ${isAdmin ? '' : ``} `; }).join(''); } async function deleteAccount(username) { if (!confirm(`Delete account "${username}"? This will also delete the Headscale user.`)) return; try { const resp = await api('api/admin/accounts/' + encodeURIComponent(username), 'DELETE'); if (!resp.ok) { const d = await resp.json(); toast(d.error || 'Failed', true); return; } toast('Account deleted'); refreshAccounts(); refreshUsers(); } catch (e) { toast('Error: ' + e.message, true); } } function showCreateAccountModal() { openModal('Create Account', `
`); } async function createAccount() { const username = document.getElementById('new-acct-username').value.trim(); const password = document.getElementById('new-acct-password').value; const role = document.getElementById('new-acct-role').value; if (!username || !password) { toast('Fill all fields', true); return; } try { const resp = await api('api/admin/accounts', 'POST', { username, password, role }); const data = await resp.json(); if (!resp.ok) { toast(data.error || 'Failed', true); return; } toast('Account created'); closeModal(); refreshAccounts(); } catch (e) { toast('Error: ' + e.message, true); } } function showResetPasswordModal(username) { openModal('Reset Password: ' + username, `
`); } async function resetPassword(username) { const password = document.getElementById('reset-password').value; if (!password) { toast('Enter password', true); return; } try { const resp = await api('api/admin/accounts/' + encodeURIComponent(username) + '/password', 'POST', { password }); const data = await resp.json(); if (!resp.ok) { toast(data.error || 'Failed', true); return; } toast('Password reset'); closeModal(); } catch (e) { toast('Error: ' + e.message, true); } } // ==================== Headscale Users (admin) ==================== async function refreshUsers() { try { const resp = await api('api/admin/users'); const data = await resp.json(); usersData = data.users || []; renderUsers(); } catch (e) { console.error('users', e); } } function renderUsers() { const tbody = document.querySelector('#users-table tbody'); if (!usersData.length) { tbody.innerHTML = 'No users'; return; } tbody.innerHTML = usersData.map(u => { const created = u.createdAt ? new Date(u.createdAt).toLocaleString() : '-'; return ` ${esc(u.name)} ${created} `; }).join(''); } function showCreateUserModal() { openModal('Create Headscale User', `

A login account will also be created so this user can access the admin panel.

`); } async function createUser() { const name = document.getElementById('new-hs-user').value.trim(); const password = document.getElementById('new-hs-password').value; if (!name) { toast('Enter username', true); return; } if (!password || password.length < 4) { toast('Password required (min 4 characters)', true); return; } try { const resp = await api('api/admin/users', 'POST', { name, password }); if (!resp.ok) { const d = await resp.json(); toast(d.error || 'Failed', true); return; } toast('User created'); closeModal(); refreshUsers(); refreshAccounts(); } catch (e) { toast('Error: ' + e.message, true); } } async function deleteUser(name) { if (!confirm(`Delete Headscale user "${name}"? This will also delete the login account.`)) return; try { const resp = await api('api/admin/users/' + encodeURIComponent(name), 'DELETE'); if (!resp.ok) { const d = await resp.json(); toast(d.error || 'Failed', true); return; } toast('User deleted'); refreshUsers(); refreshAccounts(); } catch (e) { toast('Error: ' + e.message, true); } } function showResetUserPasswordModal(username) { openModal('Reset Password: ' + username, `
`); } async function resetUserPassword(username) { const password = document.getElementById('reset-user-password').value; if (!password || password.length < 12) { toast('Password required (min 12 characters)', true); return; } try { const resp = await api('api/admin/users/' + encodeURIComponent(username) + '/password', 'POST', { password }); const data = await resp.json(); if (!resp.ok) { toast(data.error || 'Failed', true); return; } toast('Password reset'); closeModal(); } catch (e) { toast('Error: ' + e.message, true); } } // ==================== Keys (admin) ==================== async function refreshKeys() { try { // Get keys for all users const usersResp = await api('api/admin/users'); const usersJson = await usersResp.json(); const users = usersJson.users || []; // Fetch all keys in parallel instead of sequential const keyPromises = users.map(u => api('api/admin/preauthkeys?user=' + encodeURIComponent(u.name)) .then(r => r.json()) .then(data => ({ user: u.name, keys: data.preAuthKeys || [] })) .catch(() => ({ user: u.name, keys: [] })) ); const results = await Promise.all(keyPromises); let allKeys = []; results.forEach(result => { result.keys.forEach(k => k._user = result.user); allKeys = allKeys.concat(result.keys); }); renderKeys(allKeys); } catch (e) { console.error('keys', e); } } function renderKeys(keys) { const tbody = document.querySelector('#keys-table tbody'); if (!keys.length) { tbody.innerHTML = 'No keys'; return; } tbody.innerHTML = keys.map(k => { const exp = k.expiration ? new Date(k.expiration).toLocaleString() : '-'; return ` ${esc((k.key || '').substring(0, 12))}... ${esc(k._user || k.user || '-')} ${k.reusable ? '✅' : '❌'} ${k.ephemeral ? '✅' : '❌'} ${k.used ? '✅' : '❌'} ${exp} `; }).join(''); } async function showCreateKeyModal() { if (!usersData.length) { try { const resp = await api('api/admin/users'); const data = await resp.json(); usersData = data.users || []; } catch (e) { console.error('load users', e); } } const usersOpts = usersData.length ? usersData.map(u => ``).join('') : ''; openModal('Create Pre-Auth Key', `
`); } async function createKey() { const user = document.getElementById('new-key-user').value; const reusable = document.getElementById('new-key-reusable').checked; const ephemeral = document.getElementById('new-key-ephemeral').checked; const hours = parseInt(document.getElementById('new-key-expiry').value) || 24; const expiration = new Date(Date.now() + hours * 3600000).toISOString(); try { const resp = await api('api/admin/preauthkeys', 'POST', { user, reusable, ephemeral, expiration }); if (!resp.ok) { const d = await resp.json(); toast(d.error || 'Failed', true); return; } toast('Key created'); closeModal(); refreshKeys(); } catch (e) { toast('Error: ' + e.message, true); } } // ==================== Register Node (admin) ==================== async function showRegisterModal() { if (!usersData.length) { try { const resp = await api('api/admin/users'); const data = await resp.json(); usersData = data.users || []; } catch (e) { console.error('load users', e); } } const usersOpts = usersData.length ? usersData.map(u => ``).join('') : ''; openModal('Register Node', `

The registration key is shown when the client tries to connect.

`); } async function registerNode() { const user = document.getElementById('reg-user').value; let key = document.getElementById('reg-key').value.trim(); if (!user || !key) { toast('Fill all fields', true); return; } key = extractKey(key); try { const resp = await api('api/admin/register', 'POST', { user, key }); const data = await resp.json(); if (!resp.ok) { toast(data.message || data.error || 'Registration failed', true); return; } toast('Node registered!'); closeModal(); refreshNodes(); refreshDashboard(); } catch (e) { toast('Error: ' + e.message, true); } } function extractKey(input) { // Extract nodekey from URL or text const match = input.match(/key=([a-f0-9]+)/i); if (match) return match[1]; // Remove nodekey: or mkey: prefix return input.replace(/^(nodekey:|mkey:)/, ''); } // ==================== My Nodes (user) ==================== async function refreshMyNodes() { try { const resp = await api('api/user/nodes'); const data = await resp.json(); const nodes = data.nodes || []; renderMyNodes(nodes); } catch (e) { console.error('my-nodes', e); } } function renderMyNodes(nodes) { const tbody = document.querySelector('#my-nodes-table tbody'); if (!nodes.length) { tbody.innerHTML = 'No nodes registered. Click "+ Register Node" to add one.'; return; } tbody.innerHTML = nodes.map(n => { const on = isOnline(n); const ip = (n.ipAddresses || [])[0] || '-'; const lastSeen = n.lastSeen ? timeAgo(n.lastSeen) : 'never'; const name = n.givenName || n.name || '-'; return ` ${esc(name)} ${esc(ip)} ${on ? 'Online' : 'Offline'} ${lastSeen} `; }).join(''); } function showUserRegisterModal() { openModal('Register My Node', `

Open Tailscale Custom client → it will show a registration URL. Copy the key from there and paste it here.

`); } async function userRegisterNode() { let key = document.getElementById('user-reg-key').value.trim(); if (!key) { toast('Enter registration key', true); return; } key = extractKey(key); try { const resp = await api('api/user/register', 'POST', { key }); const data = await resp.json(); if (!resp.ok) { toast(data.message || data.error || 'Registration failed', true); return; } toast('Node registered!'); closeModal(); refreshMyNodes(); } catch (e) { toast('Error: ' + e.message, true); } } // ==================== Downloads ==================== async function refreshDownloads() { try { const resp = await fetch('download/'); const data = await resp.json(); const files = data.files || []; const container = document.getElementById('downloads-list'); if (!files.length) { container.innerHTML = '

No downloads available yet.

'; return; } container.innerHTML = files.map(f => { const sizeStr = formatSize(f.size); let icon, desc; if (f.name.endsWith('.msi')) { icon = '💿'; desc = 'Windows Installer (MSI) — auto-register service'; } else if (f.name.endsWith('.zip')) { icon = '📦'; desc = 'Windows Portable (ZIP) — manual service setup'; } else if (f.name.match(/linux/)) { icon = '🐧'; desc = 'Linux (amd64)'; } else if (f.name.endsWith('.exe')) { icon = '⚙'; desc = 'Windows executable'; } else { icon = '📄'; desc = ''; } return `
${icon}
${esc(f.name)}
${desc ? esc(desc) + ' — ' : ''}${sizeStr}
Download
`; }).join(''); } catch (e) { console.error('downloads', e); } } // ==================== Change Password ==================== function showPasswordModal() { openModal('Change Password', `
`); } async function changePassword() { const oldPassword = document.getElementById('chg-old-pw').value; const newPassword = document.getElementById('chg-new-pw').value; if (!oldPassword || !newPassword) { toast('Fill all fields', true); return; } try { const resp = await api('api/auth/password', 'POST', { oldPassword, newPassword }); const data = await resp.json(); if (!resp.ok) { toast(data.error || 'Failed', true); return; } toast('Password changed'); closeModal(); } catch (e) { toast('Error: ' + e.message, true); } } // ==================== Modal helpers ==================== function openModal(title, bodyHtml) { document.getElementById('modal-title').textContent = title; document.getElementById('modal-body').innerHTML = bodyHtml; document.getElementById('modal-overlay').style.display = 'flex'; } function closeModal() { document.getElementById('modal-overlay').style.display = 'none'; } // ==================== Utility ==================== function isOnline(node) { if (!node.lastSeen) return false; const diff = Date.now() - new Date(node.lastSeen).getTime(); return node.online === true || diff < 300000; } function timeAgo(dateStr) { const diff = Date.now() - new Date(dateStr).getTime(); if (diff < 60000) return 'just now'; if (diff < 3600000) return Math.floor(diff / 60000) + 'm ago'; if (diff < 86400000) return Math.floor(diff / 3600000) + 'h ago'; return Math.floor(diff / 86400000) + 'd ago'; } function formatSize(bytes) { if (bytes < 1024) return bytes + ' B'; if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB'; if (bytes < 1073741824) return (bytes / 1048576).toFixed(1) + ' MB'; return (bytes / 1073741824).toFixed(1) + ' GB'; } function esc(str) { const d = document.createElement('div'); d.textContent = str || ''; return d.innerHTML; } function toast(msg, isError = false) { const el = document.getElementById('toast'); el.textContent = msg; el.className = 'toast' + (isError ? ' toast-error' : ' toast-success'); el.style.display = 'block'; setTimeout(() => { el.style.display = 'none'; }, 3000); } // Enter key for login document.getElementById('login-password').addEventListener('keyup', (e) => { if (e.key === 'Enter') doLogin(); }); document.getElementById('login-username').addEventListener('keyup', (e) => { if (e.key === 'Enter') document.getElementById('login-password').focus(); }); // Auto-login from session (function init() { const token = sessionStorage.getItem('authToken'); const user = sessionStorage.getItem('authUser'); if (token && user) { authToken = token; currentUser = JSON.parse(user); // Verify session is still valid fetch('api/auth/me', { headers: { 'Authorization': 'Bearer ' + token } }) .then(r => { if (r.ok) enterApp(); else doLogout(); }) .catch(() => doLogout()); } })();