// Headscale Web Admin - Frontend let adminToken = ''; // --- Auth --- function doLogin() { const pw = document.getElementById('login-password').value; fetch('/api/auth', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password: pw }) }) .then(r => r.json()) .then(data => { if (data.ok) { adminToken = pw; sessionStorage.setItem('token', pw); document.getElementById('login-screen').style.display = 'none'; document.getElementById('app').style.display = 'block'; refreshAll(); } else { document.getElementById('login-error').textContent = 'Wrong password'; } }) .catch(() => { document.getElementById('login-error').textContent = 'Connection error'; }); } function doLogout() { adminToken = ''; sessionStorage.removeItem('token'); document.getElementById('app').style.display = 'none'; document.getElementById('login-screen').style.display = 'flex'; document.getElementById('login-password').value = ''; } // Auto-login from session (function() { const saved = sessionStorage.getItem('token'); if (saved) { adminToken = saved; document.getElementById('login-screen').style.display = 'none'; document.getElementById('app').style.display = 'block'; refreshAll(); } })(); // --- API helpers --- function api(path, opts = {}) { const headers = { 'X-Admin-Token': adminToken, ...opts.headers }; if (opts.body && typeof opts.body === 'object') { headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(opts.body); } return fetch(path, { ...opts, headers }).then(r => { if (r.status === 401) { doLogout(); throw new Error('unauthorized'); } return r.json(); }); } function toast(msg, type = 'success') { const el = document.getElementById('toast'); el.textContent = msg; el.className = 'toast show ' + type; setTimeout(() => el.className = 'toast', 3000); } // --- Tabs --- function switchTab(name) { document.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t.dataset.tab === name)); document.querySelectorAll('.tab-content').forEach(t => t.classList.toggle('active', t.id === 'tab-' + name)); } // --- Data --- let nodesData = []; let usersData = []; function refreshAll() { refreshNodes(); refreshUsers(); refreshKeys(); } // --- Nodes --- function refreshNodes() { api('/api/nodes').then(data => { const nodes = data.nodes || []; nodesData = nodes; renderNodes(nodes); renderDashboard(nodes); }).catch(e => toast('Failed to load nodes: ' + e.message, 'error')); } function renderNodes(nodes) { const tbody = document.getElementById('nodes-table'); if (!nodes.length) { tbody.innerHTML = 'No nodes registered'; return; } tbody.innerHTML = nodes.map(n => { const online = isOnline(n); const ips = (n.ipAddresses || []).join(', '); const userName = n.user ? (n.user.name || n.user.Name || '-') : '-'; const name = n.givenName || n.name || '-'; return ` ${n.id} ${esc(name)} ${esc(ips)} ${esc(userName)} ${online ? 'Online' : 'Offline'} ${timeAgo(n.lastSeen)} ${fmtDate(n.createdAt)} `; }).join(''); } function renderDashboard(nodes) { const online = nodes.filter(isOnline).length; document.getElementById('stat-total').textContent = nodes.length; document.getElementById('stat-online').textContent = online; document.getElementById('stat-offline').textContent = nodes.length - online; // Recent 5 nodes const recent = [...nodes].sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)).slice(0, 5); const tbody = document.getElementById('dashboard-nodes'); tbody.innerHTML = recent.map(n => { const online = isOnline(n); const ips = (n.ipAddresses || []).join(', '); const userName = n.user ? (n.user.name || n.user.Name || '-') : '-'; const name = n.givenName || n.name || '-'; return ` ${esc(name)} ${esc(ips)} ${esc(userName)} ${online ? 'Online' : 'Offline'} ${timeAgo(n.lastSeen)} `; }).join('') || 'No nodes yet'; } function deleteNode(id, name) { if (!confirm(`Delete node "${name}" (ID: ${id})?`)) return; api('/api/nodes/' + id, { method: 'DELETE' }) .then(() => { toast('Node deleted'); refreshNodes(); }) .catch(e => toast('Delete failed: ' + e.message, 'error')); } function expireNode(id) { api('/api/nodes/' + id + '/expire', { method: 'POST' }) .then(() => { toast('Node expired'); refreshNodes(); }) .catch(e => toast('Expire failed: ' + e.message, 'error')); } // --- Users --- function refreshUsers() { api('/api/users').then(data => { const users = data.users || []; usersData = users; renderUsers(users); document.getElementById('stat-users').textContent = users.length; updateKeyUserSelect(users); }).catch(e => toast('Failed to load users: ' + e.message, 'error')); } function renderUsers(users) { const tbody = document.getElementById('users-table'); if (!users.length) { tbody.innerHTML = 'No users'; return; } tbody.innerHTML = users.map(u => { const name = u.name || u.Name || '-'; return ` ${u.id} ${esc(name)} ${fmtDate(u.createdAt)} `; }).join(''); } function createUser() { const input = document.getElementById('new-user-name'); const name = input.value.trim(); if (!name) { toast('Enter a username', 'error'); return; } api('/api/users', { method: 'POST', body: { name: name } }) .then(() => { toast('User created: ' + name); input.value = ''; refreshUsers(); }) .catch(e => toast('Create failed: ' + e.message, 'error')); } function deleteUser(name) { if (!confirm(`Delete user "${name}"? All nodes of this user will also be removed.`)) return; api('/api/users/' + encodeURIComponent(name), { method: 'DELETE' }) .then(() => { toast('User deleted'); refreshUsers(); refreshNodes(); }) .catch(e => toast('Delete failed: ' + e.message, 'error')); } // --- Pre-auth Keys --- function updateKeyUserSelect(users) { const sel = document.getElementById('key-user-select'); sel.innerHTML = users.map(u => { const name = u.name || u.Name; return ``; }).join(''); } function refreshKeys() { // Need to fetch keys per user if (!usersData.length) { api('/api/users').then(data => { usersData = data.users || []; fetchAllKeys(); }); } else { fetchAllKeys(); } } function fetchAllKeys() { const promises = usersData.map(u => { const name = u.name || u.Name; return api('/api/preauthkeys?user=' + encodeURIComponent(name)) .then(data => (data.preAuthKeys || []).map(k => ({ ...k, _user: name }))) .catch(() => []); }); Promise.all(promises).then(results => { const allKeys = results.flat().sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)); renderKeys(allKeys); }); } function renderKeys(keys) { const tbody = document.getElementById('keys-table'); if (!keys.length) { tbody.innerHTML = 'No pre-auth keys'; return; } tbody.innerHTML = keys.map(k => { const expired = k.expiration && new Date(k.expiration) < new Date(); const keyShort = k.key ? k.key.substring(0, 16) + '...' : '-'; return ` ${esc(keyShort)} ${esc(k._user || k.user || '-')} ${k.reusable ? '✅' : '—'} ${k.ephemeral ? '✅' : '—'} ${k.used ? '✅' : '—'} ${expired ? 'Expired' : fmtDate(k.expiration)} ${fmtDate(k.createdAt)} `; }).join(''); } function createKey() { const user = document.getElementById('key-user-select').value; if (!user) { toast('Select a user first', 'error'); return; } const reusable = document.getElementById('key-reusable').checked; const ephemeral = document.getElementById('key-ephemeral').checked; const hours = parseInt(document.getElementById('key-expiry').value) || 24; const expiration = new Date(Date.now() + hours * 3600000).toISOString(); api('/api/preauthkeys', { method: 'POST', body: { user: user, reusable, ephemeral, expiration } }).then(data => { const key = data.preAuthKey ? data.preAuthKey.key : 'created'; toast('Key created: ' + key.substring(0, 20) + '...'); refreshKeys(); }).catch(e => toast('Create key failed: ' + e.message, 'error')); } function copyKey(key) { navigator.clipboard.writeText(key).then( () => toast('Key copied to clipboard'), () => toast('Copy failed', 'error') ); } // --- Helpers --- function isOnline(node) { if (!node.lastSeen) return false; const diff = Date.now() - new Date(node.lastSeen).getTime(); return node.online === true || diff < 5 * 60 * 1000; // 5 min } function timeAgo(dateStr) { if (!dateStr) return '-'; const diff = Date.now() - new Date(dateStr).getTime(); if (diff < 0) return 'just now'; const mins = Math.floor(diff / 60000); if (mins < 1) return 'just now'; if (mins < 60) return mins + 'm ago'; const hours = Math.floor(mins / 60); if (hours < 24) return hours + 'h ago'; const days = Math.floor(hours / 24); return days + 'd ago'; } function fmtDate(dateStr) { if (!dateStr) return '-'; const d = new Date(dateStr); return d.toLocaleDateString('vi-VN') + ' ' + d.toLocaleTimeString('vi-VN', { hour: '2-digit', minute: '2-digit' }); } function esc(s) { if (!s) return ''; const div = document.createElement('div'); div.textContent = String(s); return div.innerHTML; } // Auto-refresh every 30s setInterval(() => { if (adminToken) refreshNodes(); }, 30000);