The modal was showing '#2' instead of the customer name when opened from the customer detail view, because the dashboard table row was not visible. Now the name is passed directly from the button's onclick context where data.name is already available. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1962 lines
87 KiB
JavaScript
1962 lines
87 KiB
JavaScript
/**
|
|
* 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, version: 'alpha-1.1' };
|
|
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 && !path.startsWith('/auth/mfa/') && path !== '/auth/login') {
|
|
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;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Dark mode
|
|
// ---------------------------------------------------------------------------
|
|
function toggleDarkMode() {
|
|
const isDark = document.documentElement.getAttribute('data-bs-theme') === 'dark';
|
|
if (isDark) {
|
|
document.documentElement.removeAttribute('data-bs-theme');
|
|
localStorage.setItem('darkMode', 'light');
|
|
document.getElementById('darkmode-icon').className = 'bi bi-moon-fill';
|
|
} else {
|
|
document.documentElement.setAttribute('data-bs-theme', 'dark');
|
|
localStorage.setItem('darkMode', 'dark');
|
|
document.getElementById('darkmode-icon').className = 'bi bi-sun-fill';
|
|
}
|
|
}
|
|
|
|
function syncDarkmodeIcon() {
|
|
const icon = document.getElementById('darkmode-icon');
|
|
if (!icon) return;
|
|
icon.className = document.documentElement.getAttribute('data-bs-theme') === 'dark'
|
|
? 'bi bi-sun-fill'
|
|
: 'bi bi-moon-fill';
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Auth
|
|
// ---------------------------------------------------------------------------
|
|
async function initApp() {
|
|
syncDarkmodeIcon();
|
|
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');
|
|
// Reset MFA sections when going back to login
|
|
resetLoginForm();
|
|
}
|
|
|
|
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;
|
|
const version = brandingData.version || 'alpha-1.1';
|
|
|
|
// Login page
|
|
document.getElementById('login-title').textContent = name;
|
|
const subtitleEl = document.getElementById('login-subtitle');
|
|
if (subtitleEl) subtitleEl.textContent = subtitle;
|
|
document.title = name;
|
|
|
|
// Update version string in login page
|
|
const versionEl = document.querySelector('#login-page .text-muted.small.mb-0');
|
|
if (versionEl) {
|
|
versionEl.innerHTML = `<i class="bi bi-tag me-1"></i>${version}`;
|
|
}
|
|
if (logoPath) {
|
|
document.getElementById('login-logo').innerHTML = `<img src="${logoPath}" alt="Logo" style="max-height:64px;max-width:200px;" class="mb-1">`;
|
|
} else {
|
|
document.getElementById('login-logo').innerHTML = '<i class="bi bi-hdd-network fs-1 text-primary"></i>';
|
|
}
|
|
|
|
// 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 = `<img src="${logoPath}" alt="Logo" style="height:28px;max-width:120px;" class="me-2">`;
|
|
} else {
|
|
document.getElementById('nav-logo').innerHTML = '<i class="bi bi-hdd-network me-2"></i>';
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
// Track MFA token between login steps
|
|
let pendingMfaToken = null;
|
|
|
|
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,
|
|
});
|
|
|
|
// Check if MFA is required
|
|
if (data.mfa_required) {
|
|
pendingMfaToken = data.mfa_token;
|
|
document.getElementById('login-form').classList.add('d-none');
|
|
document.getElementById('azure-login-divider').classList.add('d-none');
|
|
|
|
if (data.totp_setup_needed) {
|
|
// First-time TOTP setup — get QR code
|
|
await startMfaSetup();
|
|
} else {
|
|
// Existing TOTP — show verify form
|
|
document.getElementById('mfa-verify-section').classList.remove('d-none');
|
|
document.getElementById('mfa-code').focus();
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Normal login (no MFA)
|
|
completeLogin(data);
|
|
} catch (err) {
|
|
errorEl.textContent = err.message;
|
|
errorEl.classList.remove('d-none');
|
|
} finally {
|
|
spinner.classList.add('d-none');
|
|
}
|
|
});
|
|
|
|
async function completeLogin(data) {
|
|
authToken = data.access_token;
|
|
localStorage.setItem('authToken', authToken);
|
|
currentUser = data.user;
|
|
document.getElementById('nav-username').textContent = currentUser.username;
|
|
if (currentUser.default_language) {
|
|
await setLanguage(currentUser.default_language);
|
|
}
|
|
pendingMfaToken = null;
|
|
showAppPage();
|
|
loadDashboard();
|
|
}
|
|
|
|
function resetLoginForm() {
|
|
pendingMfaToken = null;
|
|
document.getElementById('login-form').classList.remove('d-none');
|
|
document.getElementById('mfa-verify-section').classList.add('d-none');
|
|
document.getElementById('mfa-setup-section').classList.add('d-none');
|
|
document.getElementById('login-error').classList.add('d-none');
|
|
document.getElementById('login-password').value = '';
|
|
// Re-check azure config visibility
|
|
if (azureConfig.azure_enabled) {
|
|
document.getElementById('azure-login-divider').classList.remove('d-none');
|
|
}
|
|
}
|
|
|
|
// MFA Verify form (existing TOTP)
|
|
document.getElementById('mfa-verify-form').addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const errorEl = document.getElementById('mfa-verify-error');
|
|
const spinner = document.getElementById('mfa-verify-spinner');
|
|
errorEl.classList.add('d-none');
|
|
spinner.classList.remove('d-none');
|
|
|
|
try {
|
|
const data = await api('POST', '/auth/mfa/verify', {
|
|
mfa_token: pendingMfaToken,
|
|
totp_code: document.getElementById('mfa-code').value,
|
|
});
|
|
completeLogin(data);
|
|
} catch (err) {
|
|
errorEl.textContent = err.message;
|
|
errorEl.classList.remove('d-none');
|
|
document.getElementById('mfa-code').value = '';
|
|
document.getElementById('mfa-code').focus();
|
|
} finally {
|
|
spinner.classList.add('d-none');
|
|
}
|
|
});
|
|
|
|
// MFA Setup — get QR code from server
|
|
async function startMfaSetup() {
|
|
try {
|
|
const data = await api('POST', '/auth/mfa/setup', {
|
|
mfa_token: pendingMfaToken,
|
|
});
|
|
document.getElementById('mfa-qr-code').src = data.qr_code;
|
|
document.getElementById('mfa-secret-manual').textContent = data.secret;
|
|
document.getElementById('mfa-setup-section').classList.remove('d-none');
|
|
document.getElementById('mfa-setup-code').focus();
|
|
} catch (err) {
|
|
document.getElementById('login-error').textContent = err.message;
|
|
document.getElementById('login-error').classList.remove('d-none');
|
|
resetLoginForm();
|
|
}
|
|
}
|
|
|
|
// MFA Setup Complete form (first-time TOTP)
|
|
document.getElementById('mfa-setup-form').addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const errorEl = document.getElementById('mfa-setup-error');
|
|
const spinner = document.getElementById('mfa-setup-spinner');
|
|
errorEl.classList.add('d-none');
|
|
spinner.classList.remove('d-none');
|
|
|
|
try {
|
|
const data = await api('POST', '/auth/mfa/setup/complete', {
|
|
mfa_token: pendingMfaToken,
|
|
totp_code: document.getElementById('mfa-setup-code').value,
|
|
});
|
|
completeLogin(data);
|
|
} catch (err) {
|
|
errorEl.textContent = err.message;
|
|
errorEl.classList.remove('d-none');
|
|
document.getElementById('mfa-setup-code').value = '';
|
|
document.getElementById('mfa-setup-code').focus();
|
|
} finally {
|
|
spinner.classList.add('d-none');
|
|
}
|
|
});
|
|
|
|
// Back-to-login links
|
|
document.getElementById('mfa-back-to-login').addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
resetLoginForm();
|
|
});
|
|
document.getElementById('mfa-setup-back-to-login').addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
resetLoginForm();
|
|
});
|
|
|
|
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 = `<tr><td colspan="8" class="text-center text-muted py-4">${t('dashboard.noCustomers')}</td></tr>`;
|
|
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
|
|
? `<a href="${esc(dashUrl || 'http://localhost:' + dPort)}" target="_blank" class="text-decoration-none" title="${t('customer.openDashboard')}">:${dPort} <i class="bi bi-box-arrow-up-right"></i></a>`
|
|
: '-';
|
|
return `<tr data-customer-id="${c.id}">
|
|
<td>${c.id}</td>
|
|
<td><a href="#" onclick="viewCustomer(${c.id})" class="text-decoration-none fw-semibold">${esc(c.name)}</a></td>
|
|
<td><code>${esc(c.subdomain)}</code></td>
|
|
<td><span class="customer-status-cell">${statusBadge(c.status)}</span></td>
|
|
<td>${dashLink}</td>
|
|
<td>${c.max_devices}</td>
|
|
<td>${formatDate(c.created_at)}</td>
|
|
<td>
|
|
<div class="btn-group btn-group-sm">
|
|
<button class="btn btn-outline-primary" title="${t('common.view')}" onclick="viewCustomer(${c.id})"><i class="bi bi-eye"></i></button>
|
|
${c.deployment && c.deployment.deployment_status === 'running'
|
|
? `<button class="btn btn-outline-warning" title="${t('common.stop')}" onclick="customerAction(${c.id},'stop')"><i class="bi bi-stop-circle"></i></button>`
|
|
: `<button class="btn btn-outline-success" title="${t('common.start')}" onclick="customerAction(${c.id},'start')"><i class="bi bi-play-circle"></i></button>`
|
|
}
|
|
<button class="btn btn-outline-info" title="${t('common.restart')}" onclick="customerAction(${c.id},'restart')"><i class="bi bi-arrow-repeat"></i></button>
|
|
<button class="btn btn-outline-danger" title="${t('common.delete')}" onclick="showDeleteModal(${c.id},'${esc(c.name)}')"><i class="bi bi-trash"></i></button>
|
|
</div>
|
|
</td>
|
|
</tr>`;
|
|
}).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 += `<li class="page-item ${i === data.page ? 'active' : ''}"><a class="page-link" href="#" onclick="goToPage(${i})">${i}</a></li>`;
|
|
}
|
|
document.getElementById('pagination-controls').innerHTML = paginationHtml;
|
|
|
|
// Lazy-load update badges after table renders (best-effort, silent fail)
|
|
loadCustomerUpdateBadges().catch(() => {});
|
|
}
|
|
|
|
async function loadCustomerUpdateBadges() {
|
|
const data = await api('GET', '/monitoring/customers/local-update-status');
|
|
data.forEach(s => {
|
|
if (!s.needs_update) return;
|
|
const tr = document.querySelector(`tr[data-customer-id="${s.customer_id}"]`);
|
|
if (!tr) return;
|
|
const cell = tr.querySelector('.customer-status-cell');
|
|
if (cell && !cell.querySelector('.update-badge')) {
|
|
const badge = document.createElement('span');
|
|
badge.className = 'badge bg-warning text-dark update-badge ms-1';
|
|
badge.title = t('monitoring.updateAvailable');
|
|
badge.innerHTML = '<i class="bi bi-arrow-repeat"></i> Update';
|
|
cell.appendChild(badge);
|
|
}
|
|
});
|
|
}
|
|
|
|
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/deploy)
|
|
// ---------------------------------------------------------------------------
|
|
async function customerAction(id, action, name) {
|
|
if (action === 'deploy') {
|
|
showRedeployModal(id, name);
|
|
return;
|
|
}
|
|
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 }));
|
|
}
|
|
}
|
|
|
|
function showRedeployModal(id, name) {
|
|
// Prefer passed name, fallback to dashboard table row, then ID
|
|
if (!name) {
|
|
const row = document.querySelector(`tr[data-customer-id="${id}"]`);
|
|
name = row ? row.querySelector('td')?.textContent?.trim() : `#${id}`;
|
|
}
|
|
document.getElementById('redeploy-customer-id').value = id;
|
|
document.getElementById('redeploy-customer-name').textContent = name;
|
|
new bootstrap.Modal(document.getElementById('redeploy-modal')).show();
|
|
}
|
|
|
|
async function confirmRedeploy(keepData) {
|
|
const id = document.getElementById('redeploy-customer-id').value;
|
|
bootstrap.Modal.getInstance(document.getElementById('redeploy-modal'))?.hide();
|
|
try {
|
|
await api('POST', `/customers/${id}/deploy?keep_data=${keepData}`);
|
|
if (currentPage === 'dashboard') loadCustomers();
|
|
if (currentCustomerId == id) viewCustomer(id);
|
|
} catch (err) {
|
|
alert(t('errors.actionFailed', { action: 'deploy', 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 = `
|
|
<div class="row g-3">
|
|
<div class="col-md-6"><strong>${t('customer.name')}</strong> ${esc(data.name)}</div>
|
|
<div class="col-md-6"><strong>${t('customer.company')}</strong> ${esc(data.company || '-')}</div>
|
|
<div class="col-md-6"><strong>${t('customer.subdomain')}</strong> <code>${esc(data.subdomain)}</code></div>
|
|
<div class="col-md-6"><strong>${t('customer.email')}</strong> ${esc(data.email)}</div>
|
|
<div class="col-md-6"><strong>${t('customer.maxDevices')}</strong> ${data.max_devices}</div>
|
|
<div class="col-md-6"><strong>${t('customer.status')}</strong> ${statusBadge(data.status)}</div>
|
|
<div class="col-md-6"><strong>${t('customer.created')}</strong> ${formatDate(data.created_at)}</div>
|
|
<div class="col-md-6"><strong>${t('customer.updated')}</strong> ${formatDate(data.updated_at)}</div>
|
|
${data.notes ? `<div class="col-12"><strong>${t('customer.notes')}</strong> ${esc(data.notes)}</div>` : ''}
|
|
</div>
|
|
`;
|
|
|
|
// Deployment tab
|
|
if (data.deployment) {
|
|
const d = data.deployment;
|
|
document.getElementById('detail-deployment-content').innerHTML = `
|
|
<div class="row g-3">
|
|
<div class="col-md-6"><strong>${t('customer.deploymentStatus')}</strong> ${statusBadge(d.deployment_status)}</div>
|
|
<div class="col-md-6"><strong>${t('customer.relayUdpPort')}</strong> ${d.relay_udp_port}</div>
|
|
<div class="col-md-6"><strong>${t('customer.dashboardPort')}</strong> ${d.dashboard_port || '-'}${d.dashboard_port ? ` <a href="${esc(d.setup_url || 'http://localhost:' + d.dashboard_port)}" target="_blank" class="ms-2"><i class="bi bi-box-arrow-up-right"></i> ${t('customer.open')}</a>` : ''}</div>
|
|
<div class="col-md-6"><strong>${t('customer.containerPrefix')}</strong> <code>${esc(d.container_prefix)}</code></div>
|
|
<div class="col-md-6"><strong>${t('customer.deployed')}</strong> ${formatDate(d.deployed_at)}</div>
|
|
<div class="col-12">
|
|
<strong>${t('customer.setupUrl')}</strong>
|
|
<div class="input-group mt-1">
|
|
<input type="text" class="form-control" value="${esc(d.setup_url || '')}" readonly id="setup-url-input">
|
|
<button class="btn btn-outline-secondary" onclick="copySetupUrl()"><i class="bi bi-clipboard"></i> ${t('customer.copy')}</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="card mt-3">
|
|
<div class="card-header d-flex justify-content-between align-items-center">
|
|
<strong><i class="bi bi-key me-1"></i>${t('customer.netbirdLogin')}</strong>
|
|
${d.has_credentials ? '' : `<span class="badge bg-secondary">${t('customer.notAvailable')}</span>`}
|
|
</div>
|
|
<div class="card-body" id="credentials-container">
|
|
${d.has_credentials ? `
|
|
<div id="credentials-placeholder">
|
|
<button class="btn btn-outline-primary btn-sm" onclick="loadCredentials(${id})">
|
|
<i class="bi bi-shield-lock me-1"></i>${t('customer.showCredentials')}
|
|
</button>
|
|
</div>
|
|
<div id="credentials-content" style="display:none">
|
|
<div class="mb-2">
|
|
<label class="form-label mb-1"><small>${t('customer.credEmail')}</small></label>
|
|
<div class="input-group input-group-sm">
|
|
<input type="text" class="form-control" id="cred-email" readonly>
|
|
<button class="btn btn-outline-secondary" onclick="copyCredential('cred-email')" title="${t('customer.copy')}"><i class="bi bi-clipboard"></i></button>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label class="form-label mb-1"><small>${t('customer.credPassword')}</small></label>
|
|
<div class="input-group input-group-sm">
|
|
<input type="password" class="form-control" id="cred-password" readonly>
|
|
<button class="btn btn-outline-secondary" data-toggle-pw onclick="togglePasswordVisibility('cred-password')" title="${t('customer.showHide')}"><i class="bi bi-eye"></i></button>
|
|
<button class="btn btn-outline-secondary" onclick="copyCredential('cred-password')" title="${t('customer.copy')}"><i class="bi bi-clipboard"></i></button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
` : `<p class="text-muted mb-0">${t('customer.credentialsNotAvailable')}</p>`}
|
|
</div>
|
|
</div>
|
|
<div class="mt-3">
|
|
<button class="btn btn-success btn-sm me-1" onclick="customerAction(${id},'start')"><i class="bi bi-play-circle me-1"></i>${t('customer.start')}</button>
|
|
<button class="btn btn-warning btn-sm me-1" onclick="customerAction(${id},'stop')"><i class="bi bi-stop-circle me-1"></i>${t('customer.stop')}</button>
|
|
<button class="btn btn-info btn-sm me-1" onclick="customerAction(${id},'restart')"><i class="bi bi-arrow-repeat me-1"></i>${t('customer.restart')}</button>
|
|
<button class="btn btn-outline-primary btn-sm me-1" onclick="customerAction(${id},'deploy',${JSON.stringify(data.name)})"><i class="bi bi-rocket me-1"></i>${t('customer.reDeploy')}</button>
|
|
<button class="btn btn-outline-warning btn-sm" id="btn-update-images-detail" onclick="updateCustomerImagesFromDetail(${id})">
|
|
<span id="update-detail-spinner" class="spinner-border spinner-border-sm d-none me-1"></span>
|
|
<i class="bi bi-arrow-repeat me-1"></i>${t('customer.updateImages')}
|
|
</button>
|
|
</div>
|
|
<div id="detail-update-result"></div>
|
|
`;
|
|
} else {
|
|
document.getElementById('detail-deployment-content').innerHTML = `
|
|
<p class="text-muted">${t('customer.noDeployment')}</p>
|
|
<button class="btn btn-primary" onclick="customerAction(${id},'deploy')"><i class="bi bi-rocket me-1"></i>${t('customer.deployNow')}</button>
|
|
`;
|
|
}
|
|
|
|
// Logs tab (preview from deployment_logs table)
|
|
if (data.logs && data.logs.length > 0) {
|
|
document.getElementById('detail-logs-content').innerHTML = data.logs.map(l =>
|
|
`<div class="log-entry log-${l.status}"><span class="log-time">${formatDate(l.created_at)}</span> <span class="badge bg-${l.status === 'success' ? 'success' : l.status === 'error' ? 'danger' : 'info'}">${l.status}</span> <strong>${esc(l.action)}</strong>: ${esc(l.message)}</div>`
|
|
).join('');
|
|
}
|
|
} catch (err) {
|
|
document.getElementById('detail-info-content').innerHTML = `<div class="alert alert-danger">${err.message}</div>`;
|
|
}
|
|
}
|
|
|
|
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 = `<p class="text-muted">${t('customer.noContainerLogs')}</p>`;
|
|
return;
|
|
}
|
|
let html = '';
|
|
for (const [name, logText] of Object.entries(data.logs)) {
|
|
html += `<h6 class="mt-3"><i class="bi bi-box me-1"></i>${esc(name)}</h6><pre class="log-pre">${esc(logText)}</pre>`;
|
|
}
|
|
content.innerHTML = html;
|
|
} catch (err) {
|
|
document.getElementById('detail-logs-content').innerHTML = `<div class="alert alert-danger">${err.message}</div>`;
|
|
}
|
|
}
|
|
|
|
async function loadCustomerHealth() {
|
|
if (!currentCustomerId) return;
|
|
try {
|
|
const data = await api('GET', `/customers/${currentCustomerId}/health`);
|
|
const content = document.getElementById('detail-health-content');
|
|
let html = `<div class="mb-3"><strong>${t('customer.overall')}</strong> ${data.healthy ? `<span class="text-success">${t('customer.healthy')}</span>` : `<span class="text-danger">${t('customer.unhealthy')}</span>`}</div>`;
|
|
if (data.containers && data.containers.length > 0) {
|
|
html += `<table class="table table-sm"><thead><tr><th>${t('customer.thContainer')}</th><th>${t('customer.thContainerStatus')}</th><th>${t('customer.thHealth')}</th><th>${t('customer.thImage')}</th></tr></thead><tbody>`;
|
|
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 += `<tr><td>${esc(c.name)}</td><td class="${statusClass}">${c.status}</td><td class="${healthClass}">${healthLabel}</td><td><code>${esc(c.image)}</code></td></tr>`;
|
|
});
|
|
html += '</tbody></table>';
|
|
}
|
|
html += `<div class="text-muted small">${t('customer.lastCheck', { time: formatDate(data.last_check) })}</div>`;
|
|
content.innerHTML = html;
|
|
} catch (err) {
|
|
document.getElementById('detail-health-content').innerHTML = `<div class="alert alert-danger">${err.message}</div>`;
|
|
}
|
|
}
|
|
|
|
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');
|
|
|
|
// SSL mode
|
|
document.getElementById('cfg-ssl-mode').value = cfg.ssl_mode || 'letsencrypt';
|
|
onSslModeChange();
|
|
if (cfg.ssl_mode === 'wildcard') {
|
|
loadNpmCertificates(cfg.wildcard_cert_id);
|
|
}
|
|
|
|
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);
|
|
|
|
// MFA tab (Security)
|
|
document.getElementById('cfg-mfa-enabled').checked = cfg.mfa_enabled || false;
|
|
loadMfaStatus();
|
|
|
|
// 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');
|
|
document.getElementById('cfg-azure-group-id').value = cfg.azure_allowed_group_id || '';
|
|
|
|
// DNS tab
|
|
document.getElementById('cfg-dns-enabled').checked = cfg.dns_enabled || false;
|
|
document.getElementById('cfg-dns-server').value = cfg.dns_server || '';
|
|
document.getElementById('cfg-dns-zone').value = cfg.dns_zone || '';
|
|
document.getElementById('cfg-dns-username').value = cfg.dns_username || '';
|
|
document.getElementById('cfg-dns-record-ip').value = cfg.dns_record_ip || '';
|
|
document.getElementById('dns-password-status').textContent = cfg.dns_password_set ? t('settings.passwordSet') : t('settings.noPasswordSet');
|
|
|
|
// LDAP tab
|
|
document.getElementById('cfg-ldap-enabled').checked = cfg.ldap_enabled || false;
|
|
document.getElementById('cfg-ldap-server').value = cfg.ldap_server || '';
|
|
document.getElementById('cfg-ldap-port').value = cfg.ldap_port || 389;
|
|
document.getElementById('cfg-ldap-use-ssl').checked = cfg.ldap_use_ssl || false;
|
|
document.getElementById('cfg-ldap-bind-dn').value = cfg.ldap_bind_dn || '';
|
|
document.getElementById('cfg-ldap-base-dn').value = cfg.ldap_base_dn || '';
|
|
document.getElementById('cfg-ldap-user-filter').value = cfg.ldap_user_filter || '(sAMAccountName={username})';
|
|
document.getElementById('cfg-ldap-group-dn').value = cfg.ldap_group_dn || '';
|
|
document.getElementById('ldap-password-status').textContent = cfg.ldap_bind_password_set ? t('settings.passwordSet') : t('settings.noPasswordSet');
|
|
|
|
// Git/Update tab
|
|
document.getElementById('cfg-git-repo-url').value = cfg.git_repo_url || '';
|
|
document.getElementById('cfg-git-branch').value = cfg.git_branch || 'main';
|
|
document.getElementById('git-token-status').textContent = cfg.git_token_set ? t('settings.tokenSet') : t('settings.noToken');
|
|
} catch (err) {
|
|
showSettingsAlert('danger', t('errors.failedToLoadSettings', { error: err.message }));
|
|
}
|
|
|
|
// Automatically fetch branches once the base config is populated
|
|
await loadGitBranches();
|
|
}
|
|
|
|
function updateLogoPreview(logoPath) {
|
|
const preview = document.getElementById('branding-logo-preview');
|
|
if (logoPath) {
|
|
preview.innerHTML = `<img src="${logoPath}" alt="Logo" style="max-height:64px;max-width:200px;"><div class="text-muted small mt-1">${logoPath}</div>`;
|
|
} else {
|
|
preview.innerHTML = `<i class="bi bi-hdd-network fs-1 text-primary"></i><div class="text-muted small mt-1">${t('settings.defaultIcon')}</div>`;
|
|
}
|
|
}
|
|
|
|
// 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;
|
|
|
|
// SSL mode
|
|
const sslMode = document.getElementById('cfg-ssl-mode').value;
|
|
payload.ssl_mode = sslMode;
|
|
if (sslMode === 'wildcard') {
|
|
const certId = document.getElementById('cfg-wildcard-cert-id').value;
|
|
if (certId) payload.wildcard_cert_id = parseInt(certId);
|
|
}
|
|
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');
|
|
}
|
|
}
|
|
|
|
// SSL mode toggle
|
|
function onSslModeChange() {
|
|
const mode = document.getElementById('cfg-ssl-mode').value;
|
|
const section = document.getElementById('wildcard-cert-section');
|
|
section.style.display = mode === 'wildcard' ? '' : 'none';
|
|
}
|
|
|
|
// Load NPM wildcard certificates into dropdown
|
|
async function loadNpmCertificates(preselectId) {
|
|
const select = document.getElementById('cfg-wildcard-cert-id');
|
|
const statusEl = document.getElementById('wildcard-cert-status');
|
|
select.innerHTML = `<option value="">${t('settings.selectCertificate')}</option>`;
|
|
statusEl.textContent = t('common.loading');
|
|
statusEl.className = 'mt-1 text-muted';
|
|
|
|
try {
|
|
const certs = await api('GET', '/settings/npm-certificates');
|
|
const wildcards = certs.filter(c => c.is_wildcard || (c.domain_names && c.domain_names.some(d => d.startsWith('*.'))));
|
|
wildcards.forEach(c => {
|
|
const domains = (c.domain_names || []).join(', ');
|
|
const expires = c.expires_on ? ` (${t('settings.expiresOn')}: ${new Date(c.expires_on).toLocaleDateString()})` : '';
|
|
const opt = document.createElement('option');
|
|
opt.value = c.id;
|
|
opt.textContent = `${domains}${expires}`;
|
|
select.appendChild(opt);
|
|
});
|
|
if (preselectId) select.value = preselectId;
|
|
statusEl.textContent = t('settings.certsLoaded', { count: wildcards.length });
|
|
statusEl.className = wildcards.length > 0 ? 'mt-1 text-success small' : 'mt-1 text-warning small';
|
|
if (wildcards.length === 0) statusEl.textContent = t('settings.noWildcardCerts');
|
|
} catch (err) {
|
|
statusEl.textContent = t('errors.failed', { error: err.message });
|
|
statusEl.className = 'mt-1 text-danger small';
|
|
}
|
|
}
|
|
|
|
// 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}<button type="button" class="btn-close" data-bs-dismiss="alert"></button>`;
|
|
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 }));
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// DNS Settings
|
|
// ---------------------------------------------------------------------------
|
|
document.getElementById('settings-dns-form').addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const payload = {
|
|
dns_enabled: document.getElementById('cfg-dns-enabled').checked,
|
|
dns_server: document.getElementById('cfg-dns-server').value,
|
|
dns_zone: document.getElementById('cfg-dns-zone').value,
|
|
dns_username: document.getElementById('cfg-dns-username').value,
|
|
dns_record_ip: document.getElementById('cfg-dns-record-ip').value,
|
|
};
|
|
const pw = document.getElementById('cfg-dns-password').value;
|
|
if (pw) payload.dns_password = pw;
|
|
try {
|
|
await api('PUT', '/settings/system', payload);
|
|
showSettingsAlert('success', t('messages.dnsSettingsSaved'));
|
|
document.getElementById('cfg-dns-password').value = '';
|
|
loadSettings();
|
|
} catch (err) {
|
|
showSettingsAlert('danger', t('errors.failed', { error: err.message }));
|
|
}
|
|
});
|
|
|
|
async function testDnsConnection() {
|
|
const spinner = document.getElementById('dns-test-spinner');
|
|
const resultEl = document.getElementById('dns-test-result');
|
|
spinner.classList.remove('d-none');
|
|
resultEl.classList.add('d-none');
|
|
try {
|
|
const data = await api('GET', '/settings/test-dns');
|
|
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');
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// LDAP Settings
|
|
// ---------------------------------------------------------------------------
|
|
document.getElementById('settings-ldap-form').addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const payload = {
|
|
ldap_enabled: document.getElementById('cfg-ldap-enabled').checked,
|
|
ldap_server: document.getElementById('cfg-ldap-server').value,
|
|
ldap_port: parseInt(document.getElementById('cfg-ldap-port').value) || 389,
|
|
ldap_use_ssl: document.getElementById('cfg-ldap-use-ssl').checked,
|
|
ldap_bind_dn: document.getElementById('cfg-ldap-bind-dn').value,
|
|
ldap_base_dn: document.getElementById('cfg-ldap-base-dn').value,
|
|
ldap_user_filter: document.getElementById('cfg-ldap-user-filter').value,
|
|
ldap_group_dn: document.getElementById('cfg-ldap-group-dn').value,
|
|
};
|
|
const pw = document.getElementById('cfg-ldap-bind-password').value;
|
|
if (pw) payload.ldap_bind_password = pw;
|
|
try {
|
|
await api('PUT', '/settings/system', payload);
|
|
showSettingsAlert('success', t('messages.ldapSettingsSaved'));
|
|
document.getElementById('cfg-ldap-bind-password').value = '';
|
|
loadSettings();
|
|
} catch (err) {
|
|
showSettingsAlert('danger', t('errors.failed', { error: err.message }));
|
|
}
|
|
});
|
|
|
|
async function testLdapConnection() {
|
|
const spinner = document.getElementById('ldap-test-spinner');
|
|
const resultEl = document.getElementById('ldap-test-result');
|
|
spinner.classList.remove('d-none');
|
|
resultEl.classList.add('d-none');
|
|
try {
|
|
const data = await api('GET', '/settings/test-ldap');
|
|
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');
|
|
}
|
|
}
|
|
|
|
async function loadGitBranches() {
|
|
const branchSelect = document.getElementById('cfg-git-branch');
|
|
const currentVal = branchSelect.value;
|
|
|
|
// Disable mapping while loading
|
|
branchSelect.disabled = true;
|
|
branchSelect.innerHTML = `<option value="${currentVal}">${currentVal} (Loading...)</option>`;
|
|
|
|
try {
|
|
const branches = await api('GET', '/settings/branches');
|
|
branchSelect.innerHTML = '';
|
|
|
|
// Always ensure the currently saved branch is an option
|
|
if (currentVal && !branches.includes(currentVal)) {
|
|
branches.unshift(currentVal);
|
|
}
|
|
|
|
if (branches.length === 0) {
|
|
branchSelect.innerHTML = `<option value="main">main</option>`;
|
|
} else {
|
|
branches.forEach(b => {
|
|
const opt = document.createElement('option');
|
|
opt.value = b;
|
|
opt.textContent = b;
|
|
if (b === currentVal) opt.selected = true;
|
|
branchSelect.appendChild(opt);
|
|
});
|
|
}
|
|
} catch (err) {
|
|
showSettingsAlert('warning', `Failed to load branches: ${err.message}`);
|
|
branchSelect.innerHTML = `<option value="${currentVal}">${currentVal}</option>`;
|
|
} finally {
|
|
branchSelect.disabled = false;
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Update / Version Management
|
|
// ---------------------------------------------------------------------------
|
|
document.getElementById('settings-git-form').addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const payload = {
|
|
git_repo_url: document.getElementById('cfg-git-repo-url').value,
|
|
git_branch: document.getElementById('cfg-git-branch').value || 'main',
|
|
};
|
|
const token = document.getElementById('cfg-git-token').value;
|
|
if (token) payload.git_token = token;
|
|
try {
|
|
await api('PUT', '/settings/system', payload);
|
|
showSettingsAlert('success', t('messages.gitSettingsSaved'));
|
|
document.getElementById('cfg-git-token').value = '';
|
|
loadSettings();
|
|
} catch (err) {
|
|
showSettingsAlert('danger', t('errors.failed', { error: err.message }));
|
|
}
|
|
});
|
|
|
|
async function loadVersionInfo() {
|
|
const el = document.getElementById('version-info-content');
|
|
if (!el) return;
|
|
el.innerHTML = `<div class="text-muted">${t('common.loading')}</div>`;
|
|
try {
|
|
const data = await api('GET', '/settings/version');
|
|
const current = data.current || {};
|
|
const latest = data.latest;
|
|
const needsUpdate = data.needs_update;
|
|
|
|
const currentTag = current.tag && current.tag !== 'unknown' ? current.tag : null;
|
|
const currentCommit = current.commit || 'unknown';
|
|
|
|
let html = `<div class="row g-3">
|
|
<div class="col-md-6">
|
|
<div class="border rounded p-3 h-100">
|
|
<div class="text-muted small mb-1">${t('settings.currentVersion')}</div>
|
|
<div class="fw-bold fs-5">${esc(currentTag || currentCommit)}</div>
|
|
${currentTag ? `<div class="text-muted small font-monospace">${t('settings.commitHash')}: ${esc(currentCommit)}</div>` : ''}
|
|
<div class="text-muted small">${t('settings.branch')}: <strong>${esc(current.branch || 'unknown')}</strong></div>
|
|
<div class="text-muted small mt-2"><i class="bi bi-clock me-1"></i>${formatDate(current.date)}</div>
|
|
</div>
|
|
</div>`;
|
|
|
|
if (latest) {
|
|
const latestTag = latest.tag && latest.tag !== 'unknown' ? latest.tag : null;
|
|
const latestCommit = latest.commit || 'unknown';
|
|
const badge = needsUpdate
|
|
? `<span class="badge bg-warning text-dark ms-1">${t('settings.updateAvailable')}</span>`
|
|
: `<span class="badge bg-success ms-1">${t('settings.upToDate')}</span>`;
|
|
html += `<div class="col-md-6">
|
|
<div class="border rounded p-3 h-100 ${needsUpdate ? 'border-warning' : ''}">
|
|
<div class="text-muted small mb-1">${t('settings.latestVersion')} ${badge}</div>
|
|
<div class="fw-bold fs-5">${esc(latestTag || latestCommit)}</div>
|
|
${latestTag ? `<div class="text-muted small font-monospace">${t('settings.commitHash')}: ${esc(latestCommit)}</div>` : ''}
|
|
<div class="text-muted small">${t('settings.branch')}: <strong>${esc(latest.branch || 'unknown')}</strong></div>
|
|
<div class="text-muted small mt-2"><i class="bi bi-clock me-1"></i>${formatDate(latest.date)}</div>
|
|
${latest.message ? `<div class="text-muted small mt-1 border-top pt-1 text-truncate" title="${esc(latest.message)}"><i class="bi bi-chat-text me-1"></i>${esc(latest.message)}</div>` : ''}
|
|
</div>
|
|
</div>`;
|
|
} else if (data.error) {
|
|
html += `<div class="col-md-6"><div class="alert alert-warning h-100 mb-0">${esc(data.error)}</div></div>`;
|
|
}
|
|
html += '</div>';
|
|
|
|
if (needsUpdate) {
|
|
html += `<div class="mt-3">
|
|
<button class="btn btn-warning" onclick="triggerUpdate()">
|
|
<span class="spinner-border spinner-border-sm d-none me-1" id="update-spinner"></span>
|
|
<i class="bi bi-arrow-repeat me-1"></i>${t('settings.triggerUpdate')}
|
|
</button>
|
|
<div class="text-muted small mt-1">${t('settings.updateWarning')}</div>
|
|
</div>`;
|
|
}
|
|
el.innerHTML = html;
|
|
} catch (err) {
|
|
el.innerHTML = `<div class="text-danger">${esc(err.message)}</div>`;
|
|
}
|
|
}
|
|
|
|
async function triggerUpdate() {
|
|
if (!confirm(t('settings.confirmUpdate'))) return;
|
|
const spinner = document.getElementById('update-spinner');
|
|
if (spinner) spinner.classList.remove('d-none');
|
|
try {
|
|
const data = await api('POST', '/settings/update');
|
|
showSettingsAlert('success', data.message || t('messages.updateStarted'));
|
|
} catch (err) {
|
|
showSettingsAlert('danger', t('errors.failed', { error: err.message }));
|
|
if (spinner) spinner.classList.add('d-none');
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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 = `<tr><td colspan="9" class="text-center text-muted py-4">${t('settings.noUsersFound') || t('common.loading')}</td></tr>`;
|
|
return;
|
|
}
|
|
tbody.innerHTML = users.map(u => {
|
|
const langDisplay = u.default_language ? u.default_language.toUpperCase() : `<span class="text-muted">${t('settings.systemDefault')}</span>`;
|
|
const mfaDisplay = u.totp_enabled
|
|
? `<span class="badge bg-success">${t('mfa.totpActive')}</span>`
|
|
: `<span class="text-muted">—</span>`;
|
|
return `<tr>
|
|
<td>${u.id}</td>
|
|
<td><strong>${esc(u.username)}</strong></td>
|
|
<td>${esc(u.email || '-')}</td>
|
|
<td><span class="badge bg-${u.role === 'admin' ? 'success' : 'secondary'}">${esc(u.role || 'admin')}</span></td>
|
|
<td><span class="badge bg-${u.auth_provider === 'azure' ? 'primary' : u.auth_provider === 'ldap' ? 'info' : 'secondary'}">${esc(u.auth_provider || 'local')}</span></td>
|
|
<td>${langDisplay}</td>
|
|
<td>${mfaDisplay}</td>
|
|
<td>${u.is_active ? `<span class="badge bg-success">${t('common.active')}</span>` : `<span class="badge bg-danger">${t('common.disabled')}</span>`}</td>
|
|
<td>
|
|
<div class="btn-group btn-group-sm">
|
|
${u.is_active
|
|
? `<button class="btn btn-outline-warning" title="${t('common.disable')}" onclick="toggleUserActive(${u.id}, false)"><i class="bi bi-pause-circle"></i></button>`
|
|
: `<button class="btn btn-outline-success" title="${t('common.enable')}" onclick="toggleUserActive(${u.id}, true)"><i class="bi bi-play-circle"></i></button>`
|
|
}
|
|
${u.auth_provider === 'local' ? `<button class="btn btn-outline-info" title="${t('common.resetPassword')}" onclick="resetUserPassword(${u.id}, '${esc(u.username)}')"><i class="bi bi-key"></i></button>` : ''}
|
|
${u.totp_enabled ? `<button class="btn btn-outline-secondary" title="${t('mfa.resetMfa')}" onclick="resetUserMfa(${u.id}, '${esc(u.username)}')"><i class="bi bi-shield-x"></i></button>` : ''}
|
|
${currentUser && currentUser.role === 'admin' && u.id !== currentUser.id
|
|
? (u.role === 'admin'
|
|
? `<button class="btn btn-outline-secondary" title="${t('settings.makeViewer')}" onclick="toggleUserRole(${u.id}, 'admin')"><i class="bi bi-person-dash"></i></button>`
|
|
: `<button class="btn btn-outline-success" title="${t('settings.makeAdmin')}" onclick="toggleUserRole(${u.id}, 'viewer')"><i class="bi bi-person-check"></i></button>`)
|
|
: ''}
|
|
<button class="btn btn-outline-danger" title="${t('common.delete')}" onclick="deleteUser(${u.id}, '${esc(u.username)}')"><i class="bi bi-trash"></i></button>
|
|
</div>
|
|
</td>
|
|
</tr>`;
|
|
}).join('');
|
|
} catch (err) {
|
|
document.getElementById('users-table-body').innerHTML = `<tr><td colspan="9" class="text-danger">${err.message}</td></tr>`;
|
|
}
|
|
}
|
|
|
|
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 toggleUserRole(id, currentRole) {
|
|
const newRole = currentRole === 'admin' ? 'viewer' : 'admin';
|
|
try {
|
|
await api('PUT', `/users/${id}`, { role: newRole });
|
|
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,
|
|
azure_allowed_group_id: document.getElementById('cfg-azure-group-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 }));
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// MFA Settings
|
|
// ---------------------------------------------------------------------------
|
|
async function saveMfaSettings() {
|
|
try {
|
|
await api('PUT', '/settings/system', {
|
|
mfa_enabled: document.getElementById('cfg-mfa-enabled').checked,
|
|
});
|
|
showSettingsAlert('success', t('mfa.mfaSaved'));
|
|
} catch (err) {
|
|
showSettingsAlert('danger', t('errors.failed', { error: err.message }));
|
|
}
|
|
}
|
|
|
|
async function loadMfaStatus() {
|
|
try {
|
|
const data = await api('GET', '/auth/mfa/status');
|
|
document.getElementById('cfg-mfa-enabled').checked = data.mfa_enabled_global;
|
|
|
|
const statusEl = document.getElementById('mfa-own-status');
|
|
const disableBtn = document.getElementById('mfa-disable-own');
|
|
|
|
if (data.totp_enabled_user) {
|
|
statusEl.innerHTML = `<span class="badge bg-success">${t('mfa.totpActive')}</span>`;
|
|
disableBtn.classList.remove('d-none');
|
|
} else {
|
|
statusEl.innerHTML = `<span class="badge bg-warning text-dark">${t('mfa.totpNotSetUp')}</span>`;
|
|
disableBtn.classList.add('d-none');
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to load MFA status:', err);
|
|
}
|
|
}
|
|
|
|
async function disableOwnTotp() {
|
|
try {
|
|
await api('POST', '/auth/mfa/disable');
|
|
showSettingsAlert('success', t('mfa.mfaDisabled'));
|
|
loadMfaStatus();
|
|
} catch (err) {
|
|
showSettingsAlert('danger', t('errors.failed', { error: err.message }));
|
|
}
|
|
}
|
|
|
|
async function resetUserMfa(id, username) {
|
|
if (!confirm(t('mfa.confirmResetMfa', { username }))) return;
|
|
try {
|
|
await api('POST', `/users/${id}/reset-mfa`);
|
|
showSettingsAlert('success', t('mfa.mfaResetSuccess', { username }));
|
|
loadUsers();
|
|
} 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 = `
|
|
<div class="row g-3">
|
|
<div class="col-md-3">
|
|
<div class="text-muted small">${t('monitoring.hostname')}</div>
|
|
<div class="fw-bold">${esc(data.hostname)}</div>
|
|
<div class="text-muted small">${esc(data.os)}</div>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<div class="text-muted small">${t('monitoring.cpu', { count: data.cpu.count })}</div>
|
|
<div class="progress mt-1" style="height: 20px;">
|
|
<div class="progress-bar ${data.cpu.percent > 80 ? 'bg-danger' : data.cpu.percent > 50 ? 'bg-warning' : 'bg-success'}"
|
|
style="width: ${data.cpu.percent}%">${data.cpu.percent}%</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<div class="text-muted small">${t('monitoring.memory', { used: data.memory.used_gb, total: data.memory.total_gb })}</div>
|
|
<div class="progress mt-1" style="height: 20px;">
|
|
<div class="progress-bar ${data.memory.percent > 80 ? 'bg-danger' : data.memory.percent > 50 ? 'bg-warning' : 'bg-success'}"
|
|
style="width: ${data.memory.percent}%">${data.memory.percent}%</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<div class="text-muted small">${t('monitoring.disk', { used: data.disk.used_gb, total: data.disk.total_gb })}</div>
|
|
<div class="progress mt-1" style="height: 20px;">
|
|
<div class="progress-bar ${data.disk.percent > 80 ? 'bg-danger' : data.disk.percent > 50 ? 'bg-warning' : 'bg-success'}"
|
|
style="width: ${data.disk.percent}%">${data.disk.percent}%</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
} catch (err) {
|
|
document.getElementById('monitoring-resources').innerHTML = `<div class="alert alert-danger">${err.message}</div>`;
|
|
}
|
|
}
|
|
|
|
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 = `<tr><td colspan="8" class="text-center text-muted py-4">${t('monitoring.noCustomers')}</td></tr>`;
|
|
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
|
|
? `<a href="${esc(c.setup_url || 'http://localhost:' + dashPort)}" target="_blank">:${dashPort}</a>`
|
|
: '-';
|
|
return `<tr>
|
|
<td>${c.id}</td>
|
|
<td>${esc(c.name)}</td>
|
|
<td><code>${esc(c.subdomain)}</code></td>
|
|
<td>${statusBadge(c.status)}</td>
|
|
<td>${c.deployment_status ? statusBadge(c.deployment_status) : '-'}</td>
|
|
<td>${dashLink}</td>
|
|
<td>${c.relay_udp_port || '-'}</td>
|
|
<td class="small">${esc(containerInfo)}</td>
|
|
</tr>`;
|
|
}).join('');
|
|
} catch (err) {
|
|
document.getElementById('monitoring-customers-body').innerHTML = `<tr><td colspan="8" class="text-danger">${err.message}</td></tr>`;
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Image Updates
|
|
// ---------------------------------------------------------------------------
|
|
async function checkImageUpdates() {
|
|
const btn = document.getElementById('btn-check-updates');
|
|
const body = document.getElementById('image-updates-body');
|
|
btn.disabled = true;
|
|
body.innerHTML = `<div class="text-muted"><span class="spinner-border spinner-border-sm me-2"></span>${t('common.loading')}</div>`;
|
|
|
|
try {
|
|
const data = await api('GET', '/monitoring/images/check');
|
|
|
|
// Image status table
|
|
const imageRows = Object.values(data.images).map(img => {
|
|
const badge = img.update_available
|
|
? `<span class="badge bg-warning text-dark">${t('monitoring.updateAvailable')}</span>`
|
|
: `<span class="badge bg-success">${t('monitoring.upToDate')}</span>`;
|
|
const shortDigest = d => d ? d.substring(7, 19) + '…' : '-';
|
|
return `<tr>
|
|
<td><code class="small">${esc(img.image)}</code></td>
|
|
<td class="small text-muted">${shortDigest(img.local_digest)}</td>
|
|
<td class="small text-muted">${shortDigest(img.hub_digest)}</td>
|
|
<td>${badge}</td>
|
|
</tr>`;
|
|
}).join('');
|
|
|
|
// Customer status table
|
|
const customerRows = data.customer_status.length === 0
|
|
? `<tr><td colspan="3" class="text-center text-muted py-3">${t('monitoring.noCustomers')}</td></tr>`
|
|
: data.customer_status.map(c => {
|
|
const badge = c.needs_update
|
|
? `<span class="badge bg-warning text-dark">${t('monitoring.needsUpdate')}</span>`
|
|
: `<span class="badge bg-success">${t('monitoring.upToDate')}</span>`;
|
|
const updateBtn = c.needs_update
|
|
? `<button class="btn btn-sm btn-outline-warning ms-2 btn-update-customer" onclick="updateCustomerImages(${c.customer_id})"
|
|
title="${t('monitoring.updateCustomer')}"><i class="bi bi-arrow-repeat"></i></button>`
|
|
: '';
|
|
return `<tr>
|
|
<td>${c.customer_id}</td>
|
|
<td>${esc(c.customer_name)} <code class="small text-muted">${esc(c.subdomain)}</code></td>
|
|
<td>${badge}${updateBtn}</td>
|
|
</tr>`;
|
|
}).join('');
|
|
|
|
// Show "Update All" button if any customer needs update
|
|
const updateAllBtn = document.getElementById('btn-update-all');
|
|
if (data.customer_status.some(c => c.needs_update)) {
|
|
updateAllBtn.classList.remove('d-none');
|
|
} else {
|
|
updateAllBtn.classList.add('d-none');
|
|
}
|
|
|
|
body.innerHTML = `
|
|
<h6 class="mb-2">${t('monitoring.imageStatusTitle')}</h6>
|
|
<div class="table-responsive mb-4">
|
|
<table class="table table-sm mb-0">
|
|
<thead class="table-light">
|
|
<tr>
|
|
<th>${t('monitoring.thImage')}</th>
|
|
<th>${t('monitoring.thLocalDigest')}</th>
|
|
<th>${t('monitoring.thHubDigest')}</th>
|
|
<th>${t('monitoring.thStatus')}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>${imageRows}</tbody>
|
|
</table>
|
|
</div>
|
|
<h6 class="mb-2">${t('monitoring.customerImageTitle')}</h6>
|
|
<div class="table-responsive">
|
|
<table class="table table-sm mb-0">
|
|
<thead class="table-light">
|
|
<tr>
|
|
<th>${t('monitoring.thId')}</th>
|
|
<th>${t('monitoring.thName')}</th>
|
|
<th>${t('monitoring.thStatus')}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>${customerRows}</tbody>
|
|
</table>
|
|
</div>`;
|
|
} catch (err) {
|
|
body.innerHTML = `<div class="alert alert-danger">${err.message}</div>`;
|
|
} finally {
|
|
btn.disabled = false;
|
|
}
|
|
}
|
|
|
|
async function pullAllImages() {
|
|
if (!confirm(t('monitoring.confirmPull'))) return;
|
|
const btn = document.getElementById('btn-pull-images');
|
|
btn.disabled = true;
|
|
try {
|
|
await api('POST', '/monitoring/images/pull');
|
|
showToast(t('monitoring.pullStarted'));
|
|
// Re-check after a few seconds to let pull finish
|
|
setTimeout(() => checkImageUpdates(), 5000);
|
|
} catch (err) {
|
|
showMonitoringAlert('danger', err.message);
|
|
} finally {
|
|
btn.disabled = false;
|
|
}
|
|
}
|
|
|
|
async function updateCustomerImagesFromDetail(id) {
|
|
const btn = document.getElementById('btn-update-images-detail');
|
|
const spinner = document.getElementById('update-detail-spinner');
|
|
const resultDiv = document.getElementById('detail-update-result');
|
|
btn.disabled = true;
|
|
spinner.classList.remove('d-none');
|
|
resultDiv.innerHTML = `<div class="alert alert-info py-2 mt-2"><span class="spinner-border spinner-border-sm me-2"></span>${t('customer.updateInProgress')}</div>`;
|
|
try {
|
|
const data = await api('POST', `/customers/${id}/update-images`);
|
|
resultDiv.innerHTML = `<div class="alert alert-success py-2 mt-2"><i class="bi bi-check-circle me-1"></i>${esc(data.message)}</div>`;
|
|
setTimeout(() => { resultDiv.innerHTML = ''; }, 6000);
|
|
} catch (err) {
|
|
resultDiv.innerHTML = `<div class="alert alert-danger py-2 mt-2"><i class="bi bi-exclamation-circle me-1"></i>${esc(err.message)}</div>`;
|
|
} finally {
|
|
btn.disabled = false;
|
|
spinner.classList.add('d-none');
|
|
}
|
|
}
|
|
|
|
async function updateCustomerImages(customerId) {
|
|
// Find the update button for this customer row and show a spinner
|
|
const btn = document.querySelector(`tr[data-customer-id="${customerId}"] .btn-update-customer`);
|
|
if (btn) {
|
|
btn.disabled = true;
|
|
btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span>';
|
|
}
|
|
try {
|
|
await api('POST', `/customers/${customerId}/update-images`);
|
|
showToast(t('monitoring.updateDone'));
|
|
setTimeout(() => checkImageUpdates(), 2000);
|
|
} catch (err) {
|
|
showMonitoringAlert('danger', err.message);
|
|
if (btn) {
|
|
btn.disabled = false;
|
|
btn.innerHTML = '<i class="bi bi-arrow-repeat"></i>';
|
|
}
|
|
}
|
|
}
|
|
|
|
async function updateAllCustomers() {
|
|
if (!confirm(t('monitoring.confirmUpdateAll'))) return;
|
|
const btn = document.getElementById('btn-update-all');
|
|
const body = document.getElementById('image-updates-body');
|
|
btn.disabled = true;
|
|
btn.innerHTML = `<span class="spinner-border spinner-border-sm me-1"></span>${t('monitoring.updating')}`;
|
|
|
|
const progressDiv = document.createElement('div');
|
|
progressDiv.className = 'alert alert-info mt-3';
|
|
progressDiv.innerHTML = `<span class="spinner-border spinner-border-sm me-2"></span>${t('monitoring.updateAllProgress')}`;
|
|
body.appendChild(progressDiv);
|
|
|
|
try {
|
|
const data = await api('POST', '/monitoring/customers/update-all');
|
|
progressDiv.remove();
|
|
|
|
if (data.results && data.results.length > 0) {
|
|
const allOk = data.updated === data.results.length;
|
|
const rows = data.results.map(r => `<tr>
|
|
<td>${esc(r.customer_name)}</td>
|
|
<td>${r.success
|
|
? '<span class="badge bg-success"><i class="bi bi-check-lg"></i> OK</span>'
|
|
: '<span class="badge bg-danger"><i class="bi bi-x-lg"></i> Error</span>'}</td>
|
|
<td class="small text-muted">${esc(r.error || '')}</td>
|
|
</tr>`).join('');
|
|
const resultHtml = `<div class="alert alert-${allOk ? 'success' : 'warning'} mt-3">
|
|
<strong>${esc(data.message)}</strong>
|
|
<table class="table table-sm mb-0 mt-2">
|
|
<thead><tr><th>${t('monitoring.thName')}</th><th>${t('monitoring.thStatus')}</th><th></th></tr></thead>
|
|
<tbody>${rows}</tbody>
|
|
</table>
|
|
</div>`;
|
|
body.insertAdjacentHTML('beforeend', resultHtml);
|
|
} else {
|
|
showToast(data.message);
|
|
}
|
|
setTimeout(() => checkImageUpdates(), 2000);
|
|
} catch (err) {
|
|
progressDiv.remove();
|
|
showMonitoringAlert('danger', err.message);
|
|
} finally {
|
|
btn.disabled = false;
|
|
btn.innerHTML = `<i class="bi bi-lightning-charge-fill me-1"></i>${t('monitoring.updateAll')}`;
|
|
}
|
|
}
|
|
|
|
async function pullAllImagesSettings() {
|
|
if (!confirm(t('monitoring.confirmPull'))) return;
|
|
const btn = document.getElementById('btn-pull-images-settings');
|
|
const statusEl = document.getElementById('pull-images-settings-status');
|
|
btn.disabled = true;
|
|
statusEl.innerHTML = `<span class="spinner-border spinner-border-sm me-1"></span>${t('monitoring.pulling')}`;
|
|
try {
|
|
await api('POST', '/monitoring/images/pull');
|
|
statusEl.innerHTML = `<i class="bi bi-check-circle text-success me-1"></i>${t('monitoring.pullStartedShort')}`;
|
|
setTimeout(() => { statusEl.innerHTML = ''; }, 8000);
|
|
} catch (err) {
|
|
statusEl.innerHTML = `<span class="text-danger"><i class="bi bi-exclamation-circle me-1"></i>${esc(err.message)}</span>`;
|
|
} finally {
|
|
btn.disabled = false;
|
|
}
|
|
}
|
|
|
|
function showMonitoringAlert(type, msg) {
|
|
const body = document.getElementById('image-updates-body');
|
|
const existing = body.querySelector('.alert');
|
|
if (existing) existing.remove();
|
|
const div = document.createElement('div');
|
|
div.className = `alert alert-${type} mt-2`;
|
|
div.textContent = msg;
|
|
body.prepend(div);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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 `<span class="badge bg-${color}">${status}</span>`;
|
|
}
|
|
|
|
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();
|
|
});
|