/** * NetBird MSP Appliance - Frontend Application * Vanilla JavaScript with Bootstrap 5 */ // --------------------------------------------------------------------------- // State // --------------------------------------------------------------------------- let authToken = localStorage.getItem('authToken') || null; let currentUser = null; let currentPage = 'dashboard'; let currentCustomerId = null; let currentCustomerData = null; let customersPage = 1; let brandingData = { branding_name: 'NetBird MSP Appliance', branding_logo_path: null }; let azureConfig = { azure_enabled: false }; // --------------------------------------------------------------------------- // API helper // --------------------------------------------------------------------------- async function api(method, path, body = null) { const opts = { method, headers: { 'Content-Type': 'application/json' }, }; if (authToken) { opts.headers['Authorization'] = `Bearer ${authToken}`; } if (body) { opts.body = JSON.stringify(body); } let resp; try { resp = await fetch(`/api${path}`, opts); } catch (networkErr) { console.error(`API network error: ${method} ${path}`, networkErr); throw new Error(t('errors.networkError')); } if (resp.status === 401) { logout(); throw new Error(t('errors.sessionExpired')); } let data; try { data = await resp.json(); } catch (jsonErr) { console.error(`API JSON parse error: ${method} ${path} (status ${resp.status})`, jsonErr); throw new Error(t('errors.serverError', { status: resp.status })); } if (!resp.ok) { let msg = t('errors.requestFailed'); if (Array.isArray(data.detail)) { msg = data.detail.map(e => { const field = e.loc ? e.loc[e.loc.length - 1] : ''; const text = (e.msg || '').replace(/^Value error, ?/, ''); return field ? `${field}: ${text}` : text; }).join('\n'); } else if (typeof data.detail === 'string') { msg = data.detail; } else if (data.message) { msg = data.message; } console.error(`API error: ${method} ${path} (status ${resp.status})`, msg); throw new Error(msg); } return data; } // --------------------------------------------------------------------------- // Auth // --------------------------------------------------------------------------- async function initApp() { await initI18n(); await loadBranding(); await loadAzureLoginConfig(); if (authToken) { try { const user = await api('GET', '/auth/me'); currentUser = user; document.getElementById('nav-username').textContent = user.username; // Apply user's language preference if set if (user.default_language && !localStorage.getItem('language')) { await setLanguage(user.default_language); } showAppPage(); loadDashboard(); } catch { authToken = null; localStorage.removeItem('authToken'); showLoginPage(); } } else { showLoginPage(); } } function showLoginPage() { document.getElementById('login-page').classList.remove('d-none'); document.getElementById('app-page').classList.add('d-none'); } function showAppPage() { document.getElementById('login-page').classList.add('d-none'); document.getElementById('app-page').classList.remove('d-none'); } async function loadBranding() { try { const resp = await fetch('/api/settings/branding'); if (resp.ok) { brandingData = await resp.json(); // Set system default language from server config if (brandingData.default_language) { setSystemDefault(brandingData.default_language); } applyBranding(); } } catch { // Use defaults } } function applyBranding() { const name = brandingData.branding_name || 'NetBird MSP Appliance'; const subtitle = brandingData.branding_subtitle || t('login.subtitle'); const logoPath = brandingData.branding_logo_path; // Login page document.getElementById('login-title').textContent = name; const subtitleEl = document.getElementById('login-subtitle'); if (subtitleEl) subtitleEl.textContent = subtitle; document.title = name; if (logoPath) { document.getElementById('login-logo').innerHTML = `Logo`; } else { document.getElementById('login-logo').innerHTML = ''; } // Navbar — use short form for the nav bar const shortName = name.length > 30 ? name.substring(0, 30) + '\u2026' : name; document.getElementById('nav-brand-name').textContent = shortName; if (logoPath) { document.getElementById('nav-logo').innerHTML = `Logo`; } else { document.getElementById('nav-logo').innerHTML = ''; } } async function loadAzureLoginConfig() { try { const resp = await fetch('/api/auth/azure/config'); if (resp.ok) { azureConfig = await resp.json(); if (azureConfig.azure_enabled) { document.getElementById('azure-login-divider').classList.remove('d-none'); } else { document.getElementById('azure-login-divider').classList.add('d-none'); } } } catch { // Azure not configured } } function loginWithAzure() { if (!azureConfig.azure_enabled || !azureConfig.azure_tenant_id || !azureConfig.azure_client_id) { alert(t('errors.azureNotConfigured')); return; } const redirectUri = window.location.origin + '/'; const authUrl = `https://login.microsoftonline.com/${azureConfig.azure_tenant_id}/oauth2/v2.0/authorize` + `?client_id=${azureConfig.azure_client_id}` + `&response_type=code` + `&redirect_uri=${encodeURIComponent(redirectUri)}` + `&scope=${encodeURIComponent('openid profile email User.Read')}` + `&response_mode=query`; window.location.href = authUrl; } async function handleAzureCallback() { const params = new URLSearchParams(window.location.search); const code = params.get('code'); if (!code) return false; // Clear URL params window.history.replaceState({}, document.title, '/'); try { const data = await api('POST', '/auth/azure/callback', { code: code, redirect_uri: window.location.origin + '/', }); authToken = data.access_token; localStorage.setItem('authToken', authToken); currentUser = data.user; document.getElementById('nav-username').textContent = currentUser.username; // Apply user's language preference if (currentUser.default_language) { await setLanguage(currentUser.default_language); } showAppPage(); loadDashboard(); return true; } catch (err) { const errorEl = document.getElementById('login-error'); errorEl.textContent = t('errors.azureLoginFailed', { error: err.message }); errorEl.classList.remove('d-none'); showLoginPage(); return true; } } document.getElementById('login-form').addEventListener('submit', async (e) => { e.preventDefault(); const errorEl = document.getElementById('login-error'); const spinner = document.getElementById('login-spinner'); errorEl.classList.add('d-none'); spinner.classList.remove('d-none'); try { const data = await api('POST', '/auth/login', { username: document.getElementById('login-username').value, password: document.getElementById('login-password').value, }); authToken = data.access_token; localStorage.setItem('authToken', authToken); currentUser = data.user; document.getElementById('nav-username').textContent = currentUser.username; // Apply user's language preference if (currentUser.default_language) { await setLanguage(currentUser.default_language); } showAppPage(); loadDashboard(); } catch (err) { errorEl.textContent = err.message; errorEl.classList.remove('d-none'); } finally { spinner.classList.add('d-none'); } }); function logout() { // Use fetch directly (not api()) to avoid 401 → logout → 401 infinite loop if (authToken) { fetch('/api/auth/logout', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${authToken}`, }, }).catch(() => {}); } authToken = null; currentUser = null; localStorage.removeItem('authToken'); showLoginPage(); } // --------------------------------------------------------------------------- // Language switching (saves preference to server for logged-in users) // --------------------------------------------------------------------------- async function switchLanguage(lang) { await setLanguage(lang); applyBranding(); // Save preference to server if user is logged in if (currentUser && currentUser.id) { try { await api('PUT', `/users/${currentUser.id}`, { default_language: lang }); currentUser.default_language = lang; } catch { // Silently fail — localStorage already saved } } } // --------------------------------------------------------------------------- // Navigation // --------------------------------------------------------------------------- function showPage(page) { document.querySelectorAll('.page-content').forEach(el => el.classList.add('d-none')); document.getElementById(`page-${page}`).classList.remove('d-none'); currentPage = page; if (page === 'dashboard') loadDashboard(); else if (page === 'settings') loadSettings(); else if (page === 'monitoring') loadMonitoring(); } // --------------------------------------------------------------------------- // Dashboard // --------------------------------------------------------------------------- async function loadDashboard() { await Promise.all([loadStats(), loadCustomers()]); } async function loadStats() { try { const data = await api('GET', '/monitoring/status'); document.getElementById('stat-total').textContent = data.total_customers; document.getElementById('stat-active').textContent = data.active; document.getElementById('stat-inactive').textContent = data.inactive; document.getElementById('stat-error').textContent = data.error; } catch (err) { console.error('Failed to load stats:', err); } } async function loadCustomers() { const search = document.getElementById('search-input').value; const status = document.getElementById('status-filter').value; let url = `/customers?page=${customersPage}&per_page=25`; if (search) url += `&search=${encodeURIComponent(search)}`; if (status) url += `&status=${encodeURIComponent(status)}`; try { const data = await api('GET', url); renderCustomersTable(data); } catch (err) { console.error('Failed to load customers:', err); } } function renderCustomersTable(data) { const tbody = document.getElementById('customers-table-body'); if (!data.items || data.items.length === 0) { tbody.innerHTML = `${t('dashboard.noCustomers')}`; document.getElementById('pagination-info').textContent = t('dashboard.showingEmpty'); document.getElementById('pagination-controls').innerHTML = ''; return; } tbody.innerHTML = data.items.map(c => { const dPort = c.deployment && c.deployment.dashboard_port; const dashUrl = c.deployment && c.deployment.setup_url; const dashLink = dPort ? `:${dPort} ` : '-'; return ` ${c.id} ${esc(c.name)} ${esc(c.subdomain)} ${statusBadge(c.status)} ${dashLink} ${c.max_devices} ${formatDate(c.created_at)}
${c.deployment && c.deployment.deployment_status === 'running' ? `` : `` }
`; }).join(''); // Pagination const start = (data.page - 1) * data.per_page + 1; const end = Math.min(data.page * data.per_page, data.total); document.getElementById('pagination-info').textContent = t('dashboard.showing', { start, end, total: data.total }); let paginationHtml = ''; for (let i = 1; i <= data.pages; i++) { paginationHtml += `
  • ${i}
  • `; } document.getElementById('pagination-controls').innerHTML = paginationHtml; } function goToPage(page) { customersPage = page; loadCustomers(); } // Search & filter listeners document.getElementById('search-input').addEventListener('input', debounce(() => { customersPage = 1; loadCustomers(); }, 300)); document.getElementById('status-filter').addEventListener('change', () => { customersPage = 1; loadCustomers(); }); // --------------------------------------------------------------------------- // Customer CRUD // --------------------------------------------------------------------------- function showNewCustomerModal() { document.getElementById('customer-modal-title').textContent = t('customerModal.newCustomer'); document.getElementById('customer-edit-id').value = ''; document.getElementById('customer-form').reset(); document.getElementById('cust-max-devices').value = '20'; document.getElementById('customer-modal-error').classList.add('d-none'); const saveBtnSpan = document.getElementById('customer-save-btn').querySelector('span[data-i18n]'); if (saveBtnSpan) saveBtnSpan.textContent = t('customerModal.saveAndDeploy'); // Update subdomain suffix api('GET', '/settings/system').then(cfg => { document.getElementById('cust-subdomain-suffix').textContent = `.${cfg.base_domain || 'domain.com'}`; }).catch(() => {}); const modalEl = document.getElementById('customer-modal'); const modal = bootstrap.Modal.getOrCreateInstance(modalEl); // Enable subdomain field for new customers document.getElementById('cust-subdomain').disabled = false; modal.show(); } function editCurrentCustomer() { if (!currentCustomerData) return; const c = currentCustomerData; document.getElementById('customer-modal-title').textContent = t('customerModal.editCustomer'); document.getElementById('customer-edit-id').value = c.id; document.getElementById('cust-name').value = c.name; document.getElementById('cust-company').value = c.company || ''; document.getElementById('cust-subdomain').value = c.subdomain; document.getElementById('cust-subdomain').disabled = true; // Can't change subdomain document.getElementById('cust-email').value = c.email; document.getElementById('cust-max-devices').value = c.max_devices; document.getElementById('cust-notes').value = c.notes || ''; document.getElementById('customer-modal-error').classList.add('d-none'); const saveBtnSpan = document.getElementById('customer-save-btn').querySelector('span[data-i18n]'); if (saveBtnSpan) saveBtnSpan.textContent = t('customerModal.saveChanges'); const modalEl = document.getElementById('customer-modal'); const modal = bootstrap.Modal.getOrCreateInstance(modalEl); modal.show(); } async function saveCustomer() { const errorEl = document.getElementById('customer-modal-error'); const spinner = document.getElementById('customer-save-spinner'); errorEl.classList.add('d-none'); spinner.classList.remove('d-none'); const editId = document.getElementById('customer-edit-id').value; const payload = { name: document.getElementById('cust-name').value, company: document.getElementById('cust-company').value || null, email: document.getElementById('cust-email').value, max_devices: parseInt(document.getElementById('cust-max-devices').value) || 20, notes: document.getElementById('cust-notes').value || null, }; try { if (editId) { await api('PUT', `/customers/${editId}`, payload); } else { payload.subdomain = document.getElementById('cust-subdomain').value.toLowerCase(); await api('POST', '/customers', payload); } // Close modal safely const modalEl = document.getElementById('customer-modal'); const modalInstance = bootstrap.Modal.getInstance(modalEl); if (modalInstance) { modalInstance.hide(); } else { modalEl.classList.remove('show'); document.body.classList.remove('modal-open'); document.querySelector('.modal-backdrop')?.remove(); } loadDashboard(); if (editId && currentCustomerId == editId) { viewCustomer(editId); } } catch (err) { console.error('saveCustomer error:', err); errorEl.textContent = err.message || t('errors.unknownError'); errorEl.classList.remove('d-none'); errorEl.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); } finally { spinner.classList.add('d-none'); } } function showDeleteModal(id, name) { document.getElementById('delete-customer-id').value = id; document.getElementById('delete-customer-name').textContent = name; new bootstrap.Modal(document.getElementById('delete-modal')).show(); } function deleteCurrentCustomer() { if (!currentCustomerData) return; showDeleteModal(currentCustomerData.id, currentCustomerData.name); } async function confirmDeleteCustomer() { const id = document.getElementById('delete-customer-id').value; const spinner = document.getElementById('delete-spinner'); spinner.classList.remove('d-none'); try { await api('DELETE', `/customers/${id}`); bootstrap.Modal.getInstance(document.getElementById('delete-modal')).hide(); showPage('dashboard'); } catch (err) { alert(t('errors.deleteFailed', { error: err.message })); } finally { spinner.classList.add('d-none'); } } // --------------------------------------------------------------------------- // Customer Actions (start/stop/restart) // --------------------------------------------------------------------------- async function customerAction(id, action) { try { await api('POST', `/customers/${id}/${action}`); if (currentPage === 'dashboard') loadCustomers(); if (currentCustomerId == id) viewCustomer(id); } catch (err) { alert(t('errors.actionFailed', { action, error: err.message })); } } // --------------------------------------------------------------------------- // Customer Detail // --------------------------------------------------------------------------- async function viewCustomer(id) { currentCustomerId = id; showPage('customer-detail'); try { const data = await api('GET', `/customers/${id}`); currentCustomerData = data; document.getElementById('detail-customer-name').textContent = data.name; const badge = document.getElementById('detail-customer-status'); badge.innerHTML = statusBadge(data.status); // Info tab document.getElementById('detail-info-content').innerHTML = `
    ${t('customer.name')} ${esc(data.name)}
    ${t('customer.company')} ${esc(data.company || '-')}
    ${t('customer.subdomain')} ${esc(data.subdomain)}
    ${t('customer.email')} ${esc(data.email)}
    ${t('customer.maxDevices')} ${data.max_devices}
    ${t('customer.status')} ${statusBadge(data.status)}
    ${t('customer.created')} ${formatDate(data.created_at)}
    ${t('customer.updated')} ${formatDate(data.updated_at)}
    ${data.notes ? `
    ${t('customer.notes')} ${esc(data.notes)}
    ` : ''}
    `; // Deployment tab if (data.deployment) { const d = data.deployment; document.getElementById('detail-deployment-content').innerHTML = `
    ${t('customer.deploymentStatus')} ${statusBadge(d.deployment_status)}
    ${t('customer.relayUdpPort')} ${d.relay_udp_port}
    ${t('customer.dashboardPort')} ${d.dashboard_port || '-'}${d.dashboard_port ? ` ${t('customer.open')}` : ''}
    ${t('customer.containerPrefix')} ${esc(d.container_prefix)}
    ${t('customer.deployed')} ${formatDate(d.deployed_at)}
    ${t('customer.setupUrl')}
    ${t('customer.netbirdLogin')} ${d.has_credentials ? '' : `${t('customer.notAvailable')}`}
    ${d.has_credentials ? `
    ` : `

    ${t('customer.credentialsNotAvailable')}

    `}
    `; } else { document.getElementById('detail-deployment-content').innerHTML = `

    ${t('customer.noDeployment')}

    `; } // Logs tab (preview from deployment_logs table) if (data.logs && data.logs.length > 0) { document.getElementById('detail-logs-content').innerHTML = data.logs.map(l => `
    ${formatDate(l.created_at)} ${l.status} ${esc(l.action)}: ${esc(l.message)}
    ` ).join(''); } } catch (err) { document.getElementById('detail-info-content').innerHTML = `
    ${err.message}
    `; } } async function loadCustomerLogs() { if (!currentCustomerId) return; try { const data = await api('GET', `/customers/${currentCustomerId}/logs`); const content = document.getElementById('detail-logs-content'); if (!data.logs || Object.keys(data.logs).length === 0) { content.innerHTML = `

    ${t('customer.noContainerLogs')}

    `; return; } let html = ''; for (const [name, logText] of Object.entries(data.logs)) { html += `
    ${esc(name)}
    ${esc(logText)}
    `; } content.innerHTML = html; } catch (err) { document.getElementById('detail-logs-content').innerHTML = `
    ${err.message}
    `; } } async function loadCustomerHealth() { if (!currentCustomerId) return; try { const data = await api('GET', `/customers/${currentCustomerId}/health`); const content = document.getElementById('detail-health-content'); let html = `
    ${t('customer.overall')} ${data.healthy ? `${t('customer.healthy')}` : `${t('customer.unhealthy')}`}
    `; if (data.containers && data.containers.length > 0) { html += ``; data.containers.forEach(c => { const statusClass = c.status === 'running' ? 'text-success' : 'text-danger'; const healthClass = c.health === 'healthy' ? 'text-success' : 'text-danger'; const healthLabel = c.health === 'healthy' ? t('customer.healthy') : t('customer.unhealthy'); html += ``; }); html += '
    ${t('customer.thContainer')}${t('customer.thContainerStatus')}${t('customer.thHealth')}${t('customer.thImage')}
    ${esc(c.name)}${c.status}${healthLabel}${esc(c.image)}
    '; } html += `
    ${t('customer.lastCheck', { time: formatDate(data.last_check) })}
    `; content.innerHTML = html; } catch (err) { document.getElementById('detail-health-content').innerHTML = `
    ${err.message}
    `; } } function copySetupUrl() { const input = document.getElementById('setup-url-input'); navigator.clipboard.writeText(input.value).then(() => { showToast(t('messages.setupUrlCopied')); }); } async function loadCredentials(customerId) { try { const data = await api('GET', `/customers/${customerId}/credentials`); document.getElementById('cred-email').value = data.email; document.getElementById('cred-password').value = data.password; document.getElementById('credentials-placeholder').style.display = 'none'; document.getElementById('credentials-content').style.display = 'block'; } catch (err) { showToast(t('errors.failedToLoadCredentials', { error: err.message }), 'danger'); } } function copyCredential(fieldId) { const input = document.getElementById(fieldId); const origType = input.type; input.type = 'text'; navigator.clipboard.writeText(input.value).then(() => { input.type = origType; showToast(t('messages.copiedToClipboard')); }); } // --------------------------------------------------------------------------- // Settings // --------------------------------------------------------------------------- async function loadSettings() { try { const cfg = await api('GET', '/settings/system'); document.getElementById('cfg-base-domain').value = cfg.base_domain || ''; document.getElementById('cfg-admin-email').value = cfg.admin_email || ''; document.getElementById('cfg-data-dir').value = cfg.data_dir || ''; document.getElementById('cfg-docker-network').value = cfg.docker_network || ''; document.getElementById('cfg-relay-base-port').value = cfg.relay_base_port || 3478; document.getElementById('cfg-dashboard-base-port').value = cfg.dashboard_base_port || 9000; document.getElementById('cfg-npm-api-url').value = cfg.npm_api_url || ''; document.getElementById('npm-credentials-status').textContent = cfg.npm_credentials_set ? t('settings.credentialsSet') : t('settings.noCredentials'); document.getElementById('cfg-mgmt-image').value = cfg.netbird_management_image || ''; document.getElementById('cfg-signal-image').value = cfg.netbird_signal_image || ''; document.getElementById('cfg-relay-image').value = cfg.netbird_relay_image || ''; document.getElementById('cfg-dashboard-image').value = cfg.netbird_dashboard_image || ''; // Branding tab document.getElementById('cfg-branding-name').value = cfg.branding_name || ''; document.getElementById('cfg-branding-subtitle').value = cfg.branding_subtitle || ''; document.getElementById('cfg-default-language').value = cfg.default_language || 'en'; updateLogoPreview(cfg.branding_logo_path); // Azure AD tab document.getElementById('cfg-azure-enabled').checked = cfg.azure_enabled || false; document.getElementById('cfg-azure-tenant').value = cfg.azure_tenant_id || ''; document.getElementById('cfg-azure-client-id').value = cfg.azure_client_id || ''; document.getElementById('azure-secret-status').textContent = cfg.azure_client_secret_set ? t('settings.secretSet') : t('settings.noSecret'); } catch (err) { showSettingsAlert('danger', t('errors.failedToLoadSettings', { error: err.message })); } } function updateLogoPreview(logoPath) { const preview = document.getElementById('branding-logo-preview'); if (logoPath) { preview.innerHTML = `Logo
    ${logoPath}
    `; } else { preview.innerHTML = `
    ${t('settings.defaultIcon')}
    `; } } // System settings form document.getElementById('settings-system-form').addEventListener('submit', async (e) => { e.preventDefault(); try { await api('PUT', '/settings/system', { base_domain: document.getElementById('cfg-base-domain').value, admin_email: document.getElementById('cfg-admin-email').value, data_dir: document.getElementById('cfg-data-dir').value, docker_network: document.getElementById('cfg-docker-network').value, relay_base_port: parseInt(document.getElementById('cfg-relay-base-port').value), dashboard_base_port: parseInt(document.getElementById('cfg-dashboard-base-port').value), }); showSettingsAlert('success', t('messages.systemSettingsSaved')); } catch (err) { showSettingsAlert('danger', t('errors.failed', { error: err.message })); } }); // NPM settings form document.getElementById('settings-npm-form').addEventListener('submit', async (e) => { e.preventDefault(); const payload = { npm_api_url: document.getElementById('cfg-npm-api-url').value }; const email = document.getElementById('cfg-npm-api-email').value; const password = document.getElementById('cfg-npm-api-password').value; if (email) payload.npm_api_email = email; if (password) payload.npm_api_password = password; try { await api('PUT', '/settings/system', payload); showSettingsAlert('success', t('messages.npmSettingsSaved')); document.getElementById('cfg-npm-api-email').value = ''; document.getElementById('cfg-npm-api-password').value = ''; loadSettings(); } catch (err) { showSettingsAlert('danger', t('errors.failed', { error: err.message })); } }); // Image settings form document.getElementById('settings-images-form').addEventListener('submit', async (e) => { e.preventDefault(); try { await api('PUT', '/settings/system', { netbird_management_image: document.getElementById('cfg-mgmt-image').value, netbird_signal_image: document.getElementById('cfg-signal-image').value, netbird_relay_image: document.getElementById('cfg-relay-image').value, netbird_dashboard_image: document.getElementById('cfg-dashboard-image').value, }); showSettingsAlert('success', t('messages.imageSettingsSaved')); } catch (err) { showSettingsAlert('danger', t('errors.failed', { error: err.message })); } }); // Test NPM connection async function testNpmConnection() { const spinner = document.getElementById('npm-test-spinner'); const resultEl = document.getElementById('npm-test-result'); spinner.classList.remove('d-none'); resultEl.classList.add('d-none'); try { const data = await api('GET', '/settings/test-npm'); resultEl.className = `mt-3 alert alert-${data.ok ? 'success' : 'danger'}`; resultEl.textContent = data.message; resultEl.classList.remove('d-none'); } catch (err) { resultEl.className = 'mt-3 alert alert-danger'; resultEl.textContent = err.message; resultEl.classList.remove('d-none'); } finally { spinner.classList.add('d-none'); } } // Change password form document.getElementById('change-password-form').addEventListener('submit', async (e) => { e.preventDefault(); const resultEl = document.getElementById('password-result'); const newPw = document.getElementById('pw-new').value; const confirmPw = document.getElementById('pw-confirm').value; if (newPw !== confirmPw) { resultEl.className = 'mt-3 alert alert-danger'; resultEl.textContent = t('errors.passwordsDoNotMatch'); resultEl.classList.remove('d-none'); return; } try { await api('POST', '/auth/change-password', { current_password: document.getElementById('pw-current').value, new_password: newPw, }); resultEl.className = 'mt-3 alert alert-success'; resultEl.textContent = t('messages.passwordChanged'); resultEl.classList.remove('d-none'); document.getElementById('change-password-form').reset(); } catch (err) { resultEl.className = 'mt-3 alert alert-danger'; resultEl.textContent = err.message; resultEl.classList.remove('d-none'); } }); function showSettingsAlert(type, msg) { const el = document.getElementById('settings-alert'); el.className = `alert alert-${type} alert-dismissible fade show`; el.innerHTML = `${msg}`; el.classList.remove('d-none'); setTimeout(() => el.classList.add('d-none'), 5000); } // Branding form document.getElementById('settings-branding-form').addEventListener('submit', async (e) => { e.preventDefault(); try { await api('PUT', '/settings/system', { branding_name: document.getElementById('cfg-branding-name').value || 'NetBird MSP Appliance', branding_subtitle: document.getElementById('cfg-branding-subtitle').value || 'Multi-Tenant Management Platform', default_language: document.getElementById('cfg-default-language').value || 'en', }); showSettingsAlert('success', t('messages.brandingNameSaved')); await loadBranding(); } catch (err) { showSettingsAlert('danger', t('errors.failed', { error: err.message })); } }); async function uploadLogo() { const fileInput = document.getElementById('branding-logo-file'); if (!fileInput.files.length) { showSettingsAlert('danger', t('errors.selectFileFirst')); return; } const formData = new FormData(); formData.append('file', fileInput.files[0]); try { const resp = await fetch('/api/settings/branding/logo', { method: 'POST', headers: { 'Authorization': `Bearer ${authToken}` }, body: formData, }); const data = await resp.json(); if (!resp.ok) { throw new Error(data.detail || t('errors.uploadFailed')); } updateLogoPreview(data.branding_logo_path); showSettingsAlert('success', t('messages.logoUploaded')); fileInput.value = ''; await loadBranding(); } catch (err) { showSettingsAlert('danger', t('errors.logoUploadFailed', { error: err.message })); } } async function deleteLogo() { try { await api('DELETE', '/settings/branding/logo'); updateLogoPreview(null); showSettingsAlert('success', t('messages.logoRemoved')); await loadBranding(); } catch (err) { showSettingsAlert('danger', t('errors.failedToRemoveLogo', { error: err.message })); } } // --------------------------------------------------------------------------- // User Management // --------------------------------------------------------------------------- async function loadUsers() { try { const users = await api('GET', '/users'); const tbody = document.getElementById('users-table-body'); if (!users || users.length === 0) { tbody.innerHTML = `${t('settings.noUsersFound') || t('common.loading')}`; return; } tbody.innerHTML = users.map(u => { const langDisplay = u.default_language ? u.default_language.toUpperCase() : `${t('settings.systemDefault')}`; return ` ${u.id} ${esc(u.username)} ${esc(u.email || '-')} ${esc(u.role || 'admin')} ${esc(u.auth_provider || 'local')} ${langDisplay} ${u.is_active ? `${t('common.active')}` : `${t('common.disabled')}`}
    ${u.is_active ? `` : `` } ${u.auth_provider === 'local' ? `` : ''}
    `; }).join(''); } catch (err) { document.getElementById('users-table-body').innerHTML = `${err.message}`; } } function showNewUserModal() { document.getElementById('user-form').reset(); document.getElementById('user-modal-error').classList.add('d-none'); new bootstrap.Modal(document.getElementById('user-modal')).show(); } async function saveNewUser() { const errorEl = document.getElementById('user-modal-error'); errorEl.classList.add('d-none'); const langValue = document.getElementById('new-user-language').value; const payload = { username: document.getElementById('new-user-username').value, password: document.getElementById('new-user-password').value, email: document.getElementById('new-user-email').value || null, default_language: langValue || null, }; try { await api('POST', '/users', payload); bootstrap.Modal.getInstance(document.getElementById('user-modal')).hide(); showSettingsAlert('success', t('messages.userCreated', { username: payload.username })); loadUsers(); } catch (err) { errorEl.textContent = err.message; errorEl.classList.remove('d-none'); } } async function deleteUser(id, username) { if (!confirm(t('messages.confirmDeleteUser', { username }))) return; try { await api('DELETE', `/users/${id}`); showSettingsAlert('success', t('messages.userDeleted', { username })); loadUsers(); } catch (err) { showSettingsAlert('danger', t('errors.deleteFailed', { error: err.message })); } } async function toggleUserActive(id, active) { try { await api('PUT', `/users/${id}`, { is_active: active }); loadUsers(); } catch (err) { showSettingsAlert('danger', t('errors.updateFailed', { error: err.message })); } } async function resetUserPassword(id, username) { if (!confirm(t('messages.confirmResetPassword', { username }))) return; try { const data = await api('POST', `/users/${id}/reset-password`); alert(t('messages.newPasswordAlert', { username, password: data.new_password })); showSettingsAlert('success', t('messages.passwordResetFor', { username })); } catch (err) { showSettingsAlert('danger', t('errors.passwordResetFailed', { error: err.message })); } } // --------------------------------------------------------------------------- // Azure AD Settings // --------------------------------------------------------------------------- document.getElementById('settings-azure-form').addEventListener('submit', async (e) => { e.preventDefault(); const payload = { azure_enabled: document.getElementById('cfg-azure-enabled').checked, azure_tenant_id: document.getElementById('cfg-azure-tenant').value || null, azure_client_id: document.getElementById('cfg-azure-client-id').value || null, }; const secret = document.getElementById('cfg-azure-client-secret').value; if (secret) payload.azure_client_secret = secret; try { await api('PUT', '/settings/system', payload); showSettingsAlert('success', t('messages.azureSettingsSaved')); document.getElementById('cfg-azure-client-secret').value = ''; loadSettings(); await loadAzureLoginConfig(); } catch (err) { showSettingsAlert('danger', t('errors.failed', { error: err.message })); } }); function togglePasswordVisibility(inputId) { const input = document.getElementById(inputId); if (!input) return; const isHidden = input.type === 'password'; input.type = isHidden ? 'text' : 'password'; const btn = input.parentElement.querySelector('[data-toggle-pw]'); if (btn) { const icon = btn.querySelector('i'); if (icon) icon.className = isHidden ? 'bi bi-eye-slash' : 'bi bi-eye'; } } // --------------------------------------------------------------------------- // Monitoring // --------------------------------------------------------------------------- async function loadMonitoring() { await Promise.all([loadResources(), loadAllCustomerStatuses()]); } async function loadResources() { try { const data = await api('GET', '/monitoring/resources'); document.getElementById('monitoring-resources').innerHTML = `
    ${t('monitoring.hostname')}
    ${esc(data.hostname)}
    ${esc(data.os)}
    ${t('monitoring.cpu', { count: data.cpu.count })}
    ${data.cpu.percent}%
    ${t('monitoring.memory', { used: data.memory.used_gb, total: data.memory.total_gb })}
    ${data.memory.percent}%
    ${t('monitoring.disk', { used: data.disk.used_gb, total: data.disk.total_gb })}
    ${data.disk.percent}%
    `; } catch (err) { document.getElementById('monitoring-resources').innerHTML = `
    ${err.message}
    `; } } async function loadAllCustomerStatuses() { try { const data = await api('GET', '/monitoring/customers'); const tbody = document.getElementById('monitoring-customers-body'); if (!data || data.length === 0) { tbody.innerHTML = `${t('monitoring.noCustomers')}`; return; } tbody.innerHTML = data.map(c => { const containerInfo = c.containers.map(ct => `${ct.name}: ${ct.status}`).join(', ') || '-'; const dashPort = c.dashboard_port; const dashLink = dashPort ? `:${dashPort}` : '-'; return ` ${c.id} ${esc(c.name)} ${esc(c.subdomain)} ${statusBadge(c.status)} ${c.deployment_status ? statusBadge(c.deployment_status) : '-'} ${dashLink} ${c.relay_udp_port || '-'} ${esc(containerInfo)} `; }).join(''); } catch (err) { document.getElementById('monitoring-customers-body').innerHTML = `${err.message}`; } } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function statusBadge(status) { const map = { active: 'success', running: 'success', inactive: 'secondary', stopped: 'secondary', deploying: 'info', pending: 'info', error: 'danger', failed: 'danger', }; const color = map[status] || 'secondary'; return `${status}`; } function formatDate(isoStr) { if (!isoStr) return '-'; const locale = getCurrentLanguage() === 'de' ? 'de-DE' : 'en-US'; const d = new Date(isoStr); return d.toLocaleDateString(locale) + ' ' + d.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' }); } function esc(str) { if (!str) return ''; const div = document.createElement('div'); div.textContent = str; return div.innerHTML; } function debounce(fn, delay) { let timer; return function (...args) { clearTimeout(timer); timer = setTimeout(() => fn.apply(this, args), delay); }; } function showToast(message) { // Simple inline notification const el = document.createElement('div'); el.className = 'toast-notification'; el.textContent = message; document.body.appendChild(el); setTimeout(() => el.remove(), 3000); } // --------------------------------------------------------------------------- // Init // --------------------------------------------------------------------------- document.addEventListener('DOMContentLoaded', async () => { // Check for Azure AD callback first const params = new URLSearchParams(window.location.search); if (params.has('code')) { await initI18n(); await loadBranding(); const handled = await handleAzureCallback(); if (handled) return; } initApp(); });