Add i18n, branding, user management, health checks, and cleanup for deployment

- Multi-language support (EN/DE) with i18n engine and language files
- Configurable branding (name, subtitle, logo) in Settings
- Global default language and per-user language preference
- User management router with CRUD endpoints
- Customer status sync on start/stop/restart
- Health check fixes: derive status from container state, remove broken wget healthcheck
- Caddy reverse proxy and dashboard env templates for customer stacks
- Updated README with real hardware specs, prerequisites, and new features
- Removed .claude settings (JWT tokens) and build artifacts from tracking
- Updated .gitignore for .claude/ and Windows artifacts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-08 17:24:05 +01:00
parent c4d68db2f4
commit 41ba835a99
28 changed files with 2550 additions and 661 deletions

View File

@@ -1,5 +1,11 @@
/* NetBird MSP Appliance - Custom Styles */
/* i18n FOUC prevention */
body.i18n-loading #login-page,
body.i18n-loading #app-page {
visibility: hidden;
}
/* Login */
.login-container {
min-height: 100vh;
@@ -163,6 +169,15 @@
letter-spacing: 0.5px;
}
.navbar-brand img {
object-fit: contain;
}
/* Login logo */
#login-logo img {
object-fit: contain;
}
/* Card improvements */
.card {
border-radius: 10px;

View File

@@ -8,32 +8,38 @@
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.2/font/bootstrap-icons.min.css" rel="stylesheet">
<link href="/static/css/styles.css" rel="stylesheet">
</head>
<body>
<body class="i18n-loading">
<!-- Login Page -->
<div id="login-page" class="d-none">
<div class="login-container">
<div class="card login-card shadow">
<div class="card-body p-5">
<div class="text-center mb-4">
<i class="bi bi-hdd-network fs-1 text-primary"></i>
<h3 class="mt-2">NetBird MSP Appliance</h3>
<p class="text-muted">Multi-Tenant Management Platform</p>
<div id="login-logo"><i class="bi bi-hdd-network fs-1 text-primary"></i></div>
<h3 class="mt-2" id="login-title">NetBird MSP Appliance</h3>
<p class="text-muted" id="login-subtitle" data-i18n="login.subtitle">Multi-Tenant Management Platform</p>
</div>
<div id="login-error" class="alert alert-danger d-none"></div>
<form id="login-form">
<div class="mb-3">
<label class="form-label">Username</label>
<label class="form-label" data-i18n="login.username">Username</label>
<input type="text" class="form-control" id="login-username" required autofocus>
</div>
<div class="mb-3">
<label class="form-label">Password</label>
<label class="form-label" data-i18n="login.password">Password</label>
<input type="password" class="form-control" id="login-password" required>
</div>
<button type="submit" class="btn btn-primary w-100" id="login-btn">
<span class="spinner-border spinner-border-sm d-none me-1" id="login-spinner"></span>
Sign In
<span data-i18n="login.signIn">Sign In</span>
</button>
</form>
<div id="azure-login-divider" class="d-none">
<hr class="my-3">
<button type="button" class="btn btn-outline-dark w-100" id="azure-login-btn" onclick="loginWithAzure()">
<i class="bi bi-microsoft me-2"></i><span data-i18n="login.signInWithMicrosoft">Sign in with Microsoft</span>
</button>
</div>
</div>
</div>
</div>
@@ -44,20 +50,31 @@
<!-- Navbar -->
<nav class="navbar navbar-expand-lg navbar-dark bg-dark sticky-top">
<div class="container-fluid">
<a class="navbar-brand" href="#"><i class="bi bi-hdd-network me-2"></i>NetBird MSP</a>
<a class="navbar-brand d-flex align-items-center" href="#" onclick="showPage('dashboard'); return false;">
<span id="nav-logo"><i class="bi bi-hdd-network me-2"></i></span>
<span id="nav-brand-name">NetBird MSP</span>
</a>
<div class="d-flex align-items-center">
<!-- Language Switcher -->
<div class="dropdown me-2">
<button class="btn btn-outline-light btn-sm dropdown-toggle" id="language-switcher-btn" data-bs-toggle="dropdown" aria-expanded="false">EN</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="#" data-lang="en" onclick="switchLanguage('en'); return false;">English</a></li>
<li><a class="dropdown-item" href="#" data-lang="de" onclick="switchLanguage('de'); return false;">Deutsch</a></li>
</ul>
</div>
<button class="btn btn-success btn-sm me-3" onclick="showNewCustomerModal()">
<i class="bi bi-plus-lg me-1"></i>New Customer
<i class="bi bi-plus-lg me-1"></i><span data-i18n="nav.newCustomer">New Customer</span>
</button>
<div class="dropdown">
<button class="btn btn-outline-light btn-sm dropdown-toggle" data-bs-toggle="dropdown">
<i class="bi bi-person-circle me-1"></i><span id="nav-username">Admin</span>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="#" onclick="showPage('settings')"><i class="bi bi-gear me-2"></i>Settings</a></li>
<li><a class="dropdown-item" href="#" onclick="showPage('monitoring')"><i class="bi bi-activity me-2"></i>Monitoring</a></li>
<li><a class="dropdown-item" href="#" onclick="showPage('settings')"><i class="bi bi-gear me-2"></i><span data-i18n="nav.settings">Settings</span></a></li>
<li><a class="dropdown-item" href="#" onclick="showPage('monitoring')"><i class="bi bi-activity me-2"></i><span data-i18n="nav.monitoring">Monitoring</span></a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item text-danger" href="#" onclick="logout()"><i class="bi bi-box-arrow-right me-2"></i>Logout</a></li>
<li><a class="dropdown-item text-danger" href="#" onclick="logout()"><i class="bi bi-box-arrow-right me-2"></i><span data-i18n="nav.logout">Logout</span></a></li>
</ul>
</div>
</div>
@@ -74,7 +91,7 @@
<div class="card-body">
<div class="d-flex justify-content-between">
<div>
<div class="text-muted small">Total Customers</div>
<div class="text-muted small" data-i18n="dashboard.totalCustomers">Total Customers</div>
<div class="fs-3 fw-bold" id="stat-total">0</div>
</div>
<div class="stat-icon bg-primary bg-opacity-10 text-primary"><i class="bi bi-people"></i></div>
@@ -87,7 +104,7 @@
<div class="card-body">
<div class="d-flex justify-content-between">
<div>
<div class="text-muted small">Active</div>
<div class="text-muted small" data-i18n="dashboard.active">Active</div>
<div class="fs-3 fw-bold text-success" id="stat-active">0</div>
</div>
<div class="stat-icon bg-success bg-opacity-10 text-success"><i class="bi bi-check-circle"></i></div>
@@ -100,7 +117,7 @@
<div class="card-body">
<div class="d-flex justify-content-between">
<div>
<div class="text-muted small">Inactive</div>
<div class="text-muted small" data-i18n="dashboard.inactive">Inactive</div>
<div class="fs-3 fw-bold text-warning" id="stat-inactive">0</div>
</div>
<div class="stat-icon bg-warning bg-opacity-10 text-warning"><i class="bi bi-pause-circle"></i></div>
@@ -113,7 +130,7 @@
<div class="card-body">
<div class="d-flex justify-content-between">
<div>
<div class="text-muted small">Errors</div>
<div class="text-muted small" data-i18n="dashboard.errors">Errors</div>
<div class="fs-3 fw-bold text-danger" id="stat-error">0</div>
</div>
<div class="stat-icon bg-danger bg-opacity-10 text-danger"><i class="bi bi-exclamation-triangle"></i></div>
@@ -128,19 +145,19 @@
<div class="card-body">
<div class="row g-2">
<div class="col-md-6">
<input type="text" class="form-control" id="search-input" placeholder="Search by name, subdomain, email...">
<input type="text" class="form-control" id="search-input" data-i18n-placeholder="dashboard.searchPlaceholder" placeholder="Search by name, subdomain, email...">
</div>
<div class="col-md-3">
<select class="form-select" id="status-filter">
<option value="">All Statuses</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
<option value="deploying">Deploying</option>
<option value="error">Error</option>
<option value="" data-i18n="dashboard.allStatuses">All Statuses</option>
<option value="active" data-i18n="dashboard.statusActive">Active</option>
<option value="inactive" data-i18n="dashboard.statusInactive">Inactive</option>
<option value="deploying" data-i18n="dashboard.statusDeploying">Deploying</option>
<option value="error" data-i18n="dashboard.statusError">Error</option>
</select>
</div>
<div class="col-md-3 text-end">
<button class="btn btn-outline-secondary" onclick="loadCustomers()"><i class="bi bi-arrow-clockwise me-1"></i>Refresh</button>
<button class="btn btn-outline-secondary" onclick="loadCustomers()"><i class="bi bi-arrow-clockwise me-1"></i><span data-i18n="dashboard.refresh">Refresh</span></button>
</div>
</div>
</div>
@@ -152,24 +169,24 @@
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>ID</th>
<th>Name</th>
<th>Company</th>
<th>Subdomain</th>
<th>Status</th>
<th>Devices</th>
<th>Created</th>
<th>Actions</th>
<th data-i18n="dashboard.thId">ID</th>
<th data-i18n="dashboard.thName">Name</th>
<th data-i18n="dashboard.thSubdomain">Subdomain</th>
<th data-i18n="dashboard.thStatus">Status</th>
<th data-i18n="dashboard.thDashboard">Dashboard</th>
<th data-i18n="dashboard.thDevices">Devices</th>
<th data-i18n="dashboard.thCreated">Created</th>
<th data-i18n="dashboard.thActions">Actions</th>
</tr>
</thead>
<tbody id="customers-table-body">
<tr><td colspan="8" class="text-center text-muted py-4">Loading...</td></tr>
<tr><td colspan="8" class="text-center text-muted py-4" data-i18n="common.loading">Loading...</td></tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<div class="card-footer d-flex justify-content-between align-items-center">
<div class="text-muted small" id="pagination-info">Showing 0 of 0</div>
<div class="text-muted small" id="pagination-info" data-i18n="dashboard.showingEmpty">Showing 0 of 0</div>
<nav>
<ul class="pagination pagination-sm mb-0" id="pagination-controls"></ul>
</nav>
@@ -183,45 +200,45 @@
<div class="container-fluid p-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<button class="btn btn-outline-secondary btn-sm me-2" onclick="showPage('dashboard')"><i class="bi bi-arrow-left me-1"></i>Back</button>
<button class="btn btn-outline-secondary btn-sm me-2" onclick="showPage('dashboard')"><i class="bi bi-arrow-left me-1"></i><span data-i18n="common.back">Back</span></button>
<span class="fs-4 fw-bold" id="detail-customer-name">Customer</span>
<span class="badge ms-2" id="detail-customer-status">active</span>
</div>
<div>
<button class="btn btn-outline-primary btn-sm me-1" onclick="editCurrentCustomer()"><i class="bi bi-pencil me-1"></i>Edit</button>
<button class="btn btn-outline-danger btn-sm" onclick="deleteCurrentCustomer()"><i class="bi bi-trash me-1"></i>Delete</button>
<button class="btn btn-outline-primary btn-sm me-1" onclick="editCurrentCustomer()"><i class="bi bi-pencil me-1"></i><span data-i18n="customer.edit">Edit</span></button>
<button class="btn btn-outline-danger btn-sm" onclick="deleteCurrentCustomer()"><i class="bi bi-trash me-1"></i><span data-i18n="customer.delete">Delete</span></button>
</div>
</div>
<ul class="nav nav-tabs mb-3" id="detail-tabs">
<li class="nav-item"><a class="nav-link active" data-bs-toggle="tab" href="#tab-info">Info</a></li>
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#tab-deployment">Deployment</a></li>
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#tab-logs">Logs</a></li>
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#tab-health">Health</a></li>
<li class="nav-item"><a class="nav-link active" data-bs-toggle="tab" href="#tab-info" data-i18n="customer.tabInfo">Info</a></li>
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#tab-deployment" data-i18n="customer.tabDeployment">Deployment</a></li>
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#tab-logs" data-i18n="customer.tabLogs">Logs</a></li>
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#tab-health" data-i18n="customer.tabHealth">Health</a></li>
</ul>
<div class="tab-content">
<!-- Tab: Info -->
<div class="tab-pane fade show active" id="tab-info">
<div class="card shadow-sm">
<div class="card-body" id="detail-info-content">Loading...</div>
<div class="card-body" id="detail-info-content" data-i18n="common.loading">Loading...</div>
</div>
</div>
<!-- Tab: Deployment -->
<div class="tab-pane fade" id="tab-deployment">
<div class="card shadow-sm">
<div class="card-body" id="detail-deployment-content">Loading...</div>
<div class="card-body" id="detail-deployment-content" data-i18n="common.loading">Loading...</div>
</div>
</div>
<!-- Tab: Logs -->
<div class="tab-pane fade" id="tab-logs">
<div class="card shadow-sm">
<div class="card-header d-flex justify-content-between">
<span>Container Logs</span>
<button class="btn btn-sm btn-outline-secondary" onclick="loadCustomerLogs()"><i class="bi bi-arrow-clockwise"></i> Refresh</button>
<span data-i18n="customer.containerLogs">Container Logs</span>
<button class="btn btn-sm btn-outline-secondary" onclick="loadCustomerLogs()"><i class="bi bi-arrow-clockwise"></i> <span data-i18n="dashboard.refresh">Refresh</span></button>
</div>
<div class="card-body">
<div id="detail-logs-content" class="log-viewer">No logs loaded.</div>
<div id="detail-logs-content" class="log-viewer" data-i18n="customer.noLogsLoaded">No logs loaded.</div>
</div>
</div>
</div>
@@ -229,10 +246,10 @@
<div class="tab-pane fade" id="tab-health">
<div class="card shadow-sm">
<div class="card-header d-flex justify-content-between">
<span>Health Check</span>
<button class="btn btn-sm btn-outline-secondary" onclick="loadCustomerHealth()"><i class="bi bi-arrow-clockwise"></i> Check</button>
<span data-i18n="customer.healthCheck">Health Check</span>
<button class="btn btn-sm btn-outline-secondary" onclick="loadCustomerHealth()"><i class="bi bi-arrow-clockwise"></i> <span data-i18n="customer.check">Check</span></button>
</div>
<div class="card-body" id="detail-health-content">Click "Check" to run a health check.</div>
<div class="card-body" id="detail-health-content" data-i18n="customer.clickCheck">Click "Check" to run a health check.</div>
</div>
</div>
</div>
@@ -242,14 +259,22 @@
<!-- Page: Settings -->
<div id="page-settings" class="page-content d-none">
<div class="container-fluid p-4">
<h4 class="mb-4"><i class="bi bi-gear me-2"></i>System Settings</h4>
<div class="d-flex justify-content-between align-items-center mb-4">
<h4 class="mb-0">
<button class="btn btn-outline-secondary btn-sm me-2" onclick="showPage('dashboard')"><i class="bi bi-arrow-left me-1"></i><span data-i18n="common.back">Back</span></button>
<i class="bi bi-gear me-2"></i><span data-i18n="settings.title">System Settings</span>
</h4>
</div>
<div id="settings-alert" class="d-none"></div>
<ul class="nav nav-tabs mb-3">
<li class="nav-item"><a class="nav-link active" data-bs-toggle="tab" href="#settings-system">System Configuration</a></li>
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#settings-npm">NPM Integration</a></li>
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#settings-images">Docker Images</a></li>
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#settings-security">Security</a></li>
<li class="nav-item"><a class="nav-link active" data-bs-toggle="tab" href="#settings-system" data-i18n="settings.tabSystem">System Configuration</a></li>
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#settings-npm" data-i18n="settings.tabNpm">NPM Integration</a></li>
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#settings-images" data-i18n="settings.tabImages">Docker Images</a></li>
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#settings-branding" data-i18n="settings.tabBranding">Branding</a></li>
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#settings-users" onclick="loadUsers()" data-i18n="settings.tabUsers">Users</a></li>
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#settings-azure" data-i18n="settings.tabAzure">Azure AD</a></li>
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#settings-security" data-i18n="settings.tabSecurity">Security</a></li>
</ul>
<div class="tab-content">
@@ -260,30 +285,35 @@
<form id="settings-system-form">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Base Domain</label>
<input type="text" class="form-control" id="cfg-base-domain" placeholder="yourdomain.com">
<div class="form-text">Customers get subdomains: kunde.yourdomain.com</div>
<label class="form-label" data-i18n="settings.baseDomain">Base Domain</label>
<input type="text" class="form-control" id="cfg-base-domain" data-i18n-placeholder="settings.baseDomainPlaceholder" placeholder="yourdomain.com">
<div class="form-text" data-i18n="settings.baseDomainHint">Customers get subdomains: customer.yourdomain.com</div>
</div>
<div class="col-md-6">
<label class="form-label">Admin Email</label>
<input type="email" class="form-control" id="cfg-admin-email" placeholder="admin@yourdomain.com">
<label class="form-label" data-i18n="settings.adminEmail">Admin Email</label>
<input type="email" class="form-control" id="cfg-admin-email" data-i18n-placeholder="settings.adminEmailPlaceholder" placeholder="admin@yourdomain.com">
</div>
<div class="col-md-6">
<label class="form-label">Data Directory</label>
<input type="text" class="form-control" id="cfg-data-dir" placeholder="/opt/netbird-instances">
<label class="form-label" data-i18n="settings.dataDir">Data Directory</label>
<input type="text" class="form-control" id="cfg-data-dir" data-i18n-placeholder="settings.dataDirPlaceholder" placeholder="/opt/netbird-instances">
</div>
<div class="col-md-6">
<label class="form-label">Docker Network</label>
<input type="text" class="form-control" id="cfg-docker-network" placeholder="npm-network">
<label class="form-label" data-i18n="settings.dockerNetwork">Docker Network</label>
<input type="text" class="form-control" id="cfg-docker-network" data-i18n-placeholder="settings.dockerNetworkPlaceholder" placeholder="npm-network">
</div>
<div class="col-md-6">
<label class="form-label">Relay Base Port</label>
<label class="form-label" data-i18n="settings.relayBasePort">Relay Base Port</label>
<input type="number" class="form-control" id="cfg-relay-base-port" min="1024" max="65535">
<div class="form-text">First UDP port for relay. Range: base to base+99</div>
<div class="form-text" data-i18n="settings.relayBasePortHint">First UDP port for relay. Range: base to base+99</div>
</div>
<div class="col-md-6">
<label class="form-label" data-i18n="settings.dashboardBasePort">Dashboard Base Port</label>
<input type="number" class="form-control" id="cfg-dashboard-base-port" min="1024" max="65535">
<div class="form-text" data-i18n="settings.dashboardBasePortHint">Base port for customer dashboards. Customer N gets base+N</div>
</div>
</div>
<div class="mt-4">
<button type="submit" class="btn btn-primary"><i class="bi bi-save me-1"></i>Save System Settings</button>
<button type="submit" class="btn btn-primary"><i class="bi bi-save me-1"></i><span data-i18n="settings.saveSystemSettings">Save System Settings</span></button>
</div>
</form>
</div>
@@ -295,31 +325,31 @@
<div class="card shadow-sm">
<div class="card-body">
<form id="settings-npm-form">
<p class="text-muted mb-3">NPM uses JWT authentication. Enter your NPM login credentials (email + password). The system will automatically log in and obtain tokens for API calls.</p>
<p class="text-muted mb-3" data-i18n="settings.npmDescription">NPM uses JWT authentication. Enter your NPM login credentials (email + password). The system will automatically log in and obtain tokens for API calls.</p>
<div class="row g-3">
<div class="col-md-8">
<label class="form-label">NPM API URL</label>
<input type="text" class="form-control" id="cfg-npm-api-url" placeholder="http://nginx-proxy-manager:81/api">
<div class="form-text">http:// or https:// - must include /api at the end</div>
<label class="form-label" data-i18n="settings.npmApiUrl">NPM API URL</label>
<input type="text" class="form-control" id="cfg-npm-api-url" data-i18n-placeholder="settings.npmApiUrlPlaceholder" placeholder="http://nginx-proxy-manager:81/api">
<div class="form-text" data-i18n="settings.npmApiUrlHint">http:// or https:// - must include /api at the end</div>
</div>
<div class="col-md-8">
<label class="form-label">NPM Login Email</label>
<input type="text" class="form-control" id="cfg-npm-api-email" placeholder="Leave empty to keep current">
<label class="form-label" data-i18n="settings.npmLoginEmail">NPM Login Email</label>
<input type="text" class="form-control" id="cfg-npm-api-email" data-i18n-placeholder="settings.npmLoginEmailPlaceholder" placeholder="Leave empty to keep current">
<div class="form-text" id="npm-credentials-status"></div>
</div>
<div class="col-md-8">
<label class="form-label">NPM Login Password</label>
<label class="form-label" data-i18n="settings.npmLoginPassword">NPM Login Password</label>
<div class="input-group">
<input type="password" class="form-control" id="cfg-npm-api-password" placeholder="Leave empty to keep current">
<input type="password" class="form-control" id="cfg-npm-api-password" data-i18n-placeholder="settings.npmLoginPasswordPlaceholder" placeholder="Leave empty to keep current">
<button class="btn btn-outline-secondary" type="button" onclick="togglePasswordVisibility('cfg-npm-api-password')"><i class="bi bi-eye"></i></button>
</div>
</div>
</div>
<div class="mt-4">
<button type="submit" class="btn btn-primary me-2"><i class="bi bi-save me-1"></i>Save NPM Settings</button>
<button type="submit" class="btn btn-primary me-2"><i class="bi bi-save me-1"></i><span data-i18n="settings.saveNpmSettings">Save NPM Settings</span></button>
<button type="button" class="btn btn-outline-info" id="test-npm-btn" onclick="testNpmConnection()">
<span class="spinner-border spinner-border-sm d-none me-1" id="npm-test-spinner"></span>
<i class="bi bi-plug me-1"></i>Test Connection
<i class="bi bi-plug me-1"></i><span data-i18n="settings.testConnection">Test Connection</span>
</button>
</div>
</form>
@@ -335,24 +365,142 @@
<form id="settings-images-form">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Management Image</label>
<input type="text" class="form-control" id="cfg-mgmt-image" placeholder="netbirdio/management:latest">
<label class="form-label" data-i18n="settings.managementImage">Management Image</label>
<input type="text" class="form-control" id="cfg-mgmt-image" data-i18n-placeholder="settings.managementImagePlaceholder" placeholder="netbirdio/management:latest">
</div>
<div class="col-md-6">
<label class="form-label">Signal Image</label>
<input type="text" class="form-control" id="cfg-signal-image" placeholder="netbirdio/signal:latest">
<label class="form-label" data-i18n="settings.signalImage">Signal Image</label>
<input type="text" class="form-control" id="cfg-signal-image" data-i18n-placeholder="settings.signalImagePlaceholder" placeholder="netbirdio/signal:latest">
</div>
<div class="col-md-6">
<label class="form-label">Relay Image</label>
<input type="text" class="form-control" id="cfg-relay-image" placeholder="netbirdio/relay:latest">
<label class="form-label" data-i18n="settings.relayImage">Relay Image</label>
<input type="text" class="form-control" id="cfg-relay-image" data-i18n-placeholder="settings.relayImagePlaceholder" placeholder="netbirdio/relay:latest">
</div>
<div class="col-md-6">
<label class="form-label">Dashboard Image</label>
<input type="text" class="form-control" id="cfg-dashboard-image" placeholder="netbirdio/dashboard:latest">
<label class="form-label" data-i18n="settings.dashboardImage">Dashboard Image</label>
<input type="text" class="form-control" id="cfg-dashboard-image" data-i18n-placeholder="settings.dashboardImagePlaceholder" placeholder="netbirdio/dashboard:latest">
</div>
</div>
<div class="mt-4">
<button type="submit" class="btn btn-primary"><i class="bi bi-save me-1"></i>Save Image Settings</button>
<button type="submit" class="btn btn-primary"><i class="bi bi-save me-1"></i><span data-i18n="settings.saveImageSettings">Save Image Settings</span></button>
</div>
</form>
</div>
</div>
</div>
<!-- Branding -->
<div class="tab-pane fade" id="settings-branding">
<div class="card shadow-sm">
<div class="card-body">
<h5 class="mb-3" data-i18n="settings.brandingTitle">Branding Settings</h5>
<form id="settings-branding-form">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label" data-i18n="settings.companyName">Company / Application Name</label>
<input type="text" class="form-control" id="cfg-branding-name" data-i18n-placeholder="settings.companyNamePlaceholder" placeholder="NetBird MSP Appliance" maxlength="255">
<div class="form-text" data-i18n="settings.companyNameHint">Displayed on login page and navbar</div>
</div>
<div class="col-md-6">
<label class="form-label" data-i18n="settings.brandingSubtitle">Subtitle</label>
<input type="text" class="form-control" id="cfg-branding-subtitle" data-i18n-placeholder="settings.brandingSubtitlePlaceholder" placeholder="Multi-Tenant Management Platform" maxlength="255">
<div class="form-text" data-i18n="settings.brandingSubtitleHint">Shown below the title on the login page</div>
</div>
<div class="col-md-6">
<label class="form-label" data-i18n="settings.defaultLanguage">Default Language</label>
<select class="form-select" id="cfg-default-language">
<option value="en">English</option>
<option value="de">Deutsch</option>
</select>
<div class="form-text" data-i18n="settings.defaultLanguageHint">Default language for users without a preference</div>
</div>
<div class="col-md-6">
<label class="form-label" data-i18n="settings.logoPreview">Logo Preview</label>
<div class="border rounded p-3 text-center" id="branding-logo-preview" style="min-height:80px;">
<i class="bi bi-hdd-network fs-1 text-primary"></i>
<div class="text-muted small mt-1" data-i18n="settings.defaultIcon">Default icon (no logo uploaded)</div>
</div>
</div>
<div class="col-md-6">
<label class="form-label" data-i18n="settings.uploadLogo">Upload Logo (PNG, JPG, SVG, max 500KB)</label>
<div class="input-group">
<input type="file" class="form-control" id="branding-logo-file" accept=".png,.jpg,.jpeg,.svg">
<button type="button" class="btn btn-outline-primary" onclick="uploadLogo()"><i class="bi bi-upload me-1"></i><span data-i18n="settings.uploadBtn">Upload</span></button>
</div>
</div>
<div class="col-md-6 d-flex align-items-end">
<button type="button" class="btn btn-outline-danger" onclick="deleteLogo()"><i class="bi bi-trash me-1"></i><span data-i18n="settings.removeLogo">Remove Logo</span></button>
</div>
</div>
<div class="mt-4">
<button type="submit" class="btn btn-primary"><i class="bi bi-save me-1"></i><span data-i18n="settings.saveBranding">Save Branding</span></button>
</div>
</form>
</div>
</div>
</div>
<!-- Users -->
<div class="tab-pane fade" id="settings-users">
<div class="card shadow-sm">
<div class="card-header d-flex justify-content-between align-items-center">
<span data-i18n="settings.userManagement">User Management</span>
<button class="btn btn-sm btn-success" onclick="showNewUserModal()"><i class="bi bi-plus-lg me-1"></i><span data-i18n="settings.newUser">New User</span></button>
</div>
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th data-i18n="settings.thId">ID</th>
<th data-i18n="settings.thUsername">Username</th>
<th data-i18n="settings.thEmail">Email</th>
<th data-i18n="settings.thRole">Role</th>
<th data-i18n="settings.thAuth">Auth</th>
<th data-i18n="settings.thLanguage">Language</th>
<th data-i18n="settings.thStatus">Status</th>
<th data-i18n="settings.thActions">Actions</th>
</tr>
</thead>
<tbody id="users-table-body">
<tr><td colspan="8" class="text-center text-muted py-4" data-i18n="common.loading">Loading...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Azure AD -->
<div class="tab-pane fade" id="settings-azure">
<div class="card shadow-sm">
<div class="card-body">
<h5 class="mb-3" data-i18n="settings.azureTitle">Azure AD / Entra ID Integration</h5>
<form id="settings-azure-form">
<div class="row g-3">
<div class="col-12">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="cfg-azure-enabled">
<label class="form-check-label" for="cfg-azure-enabled" data-i18n="settings.enableAzureSso">Enable Azure AD SSO</label>
</div>
</div>
<div class="col-md-6">
<label class="form-label" data-i18n="settings.tenantId">Tenant ID</label>
<input type="text" class="form-control" id="cfg-azure-tenant" placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx">
</div>
<div class="col-md-6">
<label class="form-label" data-i18n="settings.clientId">Client ID (Application ID)</label>
<input type="text" class="form-control" id="cfg-azure-client-id" placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx">
</div>
<div class="col-md-6">
<label class="form-label" data-i18n="settings.clientSecret">Client Secret</label>
<div class="input-group">
<input type="password" class="form-control" id="cfg-azure-client-secret" data-i18n-placeholder="settings.clientSecretPlaceholder" placeholder="Leave empty to keep current">
<button class="btn btn-outline-secondary" type="button" data-toggle-pw onclick="togglePasswordVisibility('cfg-azure-client-secret')"><i class="bi bi-eye"></i></button>
</div>
<div class="form-text" id="azure-secret-status"></div>
</div>
</div>
<div class="mt-4">
<button type="submit" class="btn btn-primary"><i class="bi bi-save me-1"></i><span data-i18n="settings.saveAzureSettings">Save Azure AD Settings</span></button>
</div>
</form>
</div>
@@ -363,25 +511,25 @@
<div class="tab-pane fade" id="settings-security">
<div class="card shadow-sm">
<div class="card-body">
<h5 class="mb-3">Change Admin Password</h5>
<h5 class="mb-3" data-i18n="settings.securityTitle">Change Admin Password</h5>
<form id="change-password-form">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Current Password</label>
<label class="form-label" data-i18n="settings.currentPassword">Current Password</label>
<input type="password" class="form-control" id="pw-current" required>
</div>
<div class="col-md-6"></div>
<div class="col-md-6">
<label class="form-label">New Password (min 12 chars)</label>
<label class="form-label" data-i18n="settings.newPassword">New Password (min 12 chars)</label>
<input type="password" class="form-control" id="pw-new" required minlength="12">
</div>
<div class="col-md-6">
<label class="form-label">Confirm New Password</label>
<label class="form-label" data-i18n="settings.confirmPassword">Confirm New Password</label>
<input type="password" class="form-control" id="pw-confirm" required minlength="12">
</div>
</div>
<div class="mt-4">
<button type="submit" class="btn btn-warning"><i class="bi bi-shield-lock me-1"></i>Change Password</button>
<button type="submit" class="btn btn-warning"><i class="bi bi-shield-lock me-1"></i><span data-i18n="settings.changePassword">Change Password</span></button>
</div>
</form>
<div id="password-result" class="mt-3 d-none"></div>
@@ -396,34 +544,38 @@
<div id="page-monitoring" class="page-content d-none">
<div class="container-fluid p-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<h4 class="mb-0"><i class="bi bi-activity me-2"></i>System Monitoring</h4>
<button class="btn btn-outline-secondary btn-sm" onclick="loadMonitoring()"><i class="bi bi-arrow-clockwise me-1"></i>Refresh</button>
<h4 class="mb-0">
<button class="btn btn-outline-secondary btn-sm me-2" onclick="showPage('dashboard')"><i class="bi bi-arrow-left me-1"></i><span data-i18n="common.back">Back</span></button>
<i class="bi bi-activity me-2"></i><span data-i18n="monitoring.title">System Monitoring</span>
</h4>
<button class="btn btn-outline-secondary btn-sm" onclick="loadMonitoring()"><i class="bi bi-arrow-clockwise me-1"></i><span data-i18n="monitoring.refresh">Refresh</span></button>
</div>
<!-- Host Resources -->
<div class="card shadow-sm mb-4">
<div class="card-header">Host Resources</div>
<div class="card-body" id="monitoring-resources">Loading...</div>
<div class="card-header" data-i18n="monitoring.hostResources">Host Resources</div>
<div class="card-body" id="monitoring-resources" data-i18n="common.loading">Loading...</div>
</div>
<!-- Customer Statuses -->
<div class="card shadow-sm">
<div class="card-header">All Customer Deployments</div>
<div class="card-header" data-i18n="monitoring.allCustomerDeployments">All Customer Deployments</div>
<div class="table-responsive">
<table class="table table-sm mb-0">
<thead class="table-light">
<tr>
<th>ID</th>
<th>Name</th>
<th>Subdomain</th>
<th>Status</th>
<th>Deployment</th>
<th>Relay Port</th>
<th>Containers</th>
<th data-i18n="monitoring.thId">ID</th>
<th data-i18n="monitoring.thName">Name</th>
<th data-i18n="monitoring.thSubdomain">Subdomain</th>
<th data-i18n="monitoring.thStatus">Status</th>
<th data-i18n="monitoring.thDeployment">Deployment</th>
<th data-i18n="monitoring.thDashboard">Dashboard</th>
<th data-i18n="monitoring.thRelayPort">Relay Port</th>
<th data-i18n="monitoring.thContainers">Containers</th>
</tr>
</thead>
<tbody id="monitoring-customers-body">
<tr><td colspan="7" class="text-center text-muted py-4">Loading...</td></tr>
<tr><td colspan="8" class="text-center text-muted py-4" data-i18n="common.loading">Loading...</td></tr>
</tbody>
</table>
</div>
@@ -437,7 +589,7 @@
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="customer-modal-title">New Customer</h5>
<h5 class="modal-title" id="customer-modal-title" data-i18n="customerModal.newCustomer">New Customer</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
@@ -445,40 +597,40 @@
<form id="customer-form">
<input type="hidden" id="customer-edit-id">
<div class="mb-3">
<label class="form-label">Name *</label>
<label class="form-label" data-i18n="customerModal.nameLabel">Name *</label>
<input type="text" class="form-control" id="cust-name" required>
</div>
<div class="mb-3">
<label class="form-label">Company</label>
<label class="form-label" data-i18n="customerModal.companyLabel">Company</label>
<input type="text" class="form-control" id="cust-company">
</div>
<div class="mb-3">
<label class="form-label">Subdomain *</label>
<label class="form-label" data-i18n="customerModal.subdomainLabel">Subdomain *</label>
<div class="input-group">
<input type="text" class="form-control" id="cust-subdomain" required pattern="[a-z0-9][-a-z0-9]*[a-z0-9]">
<span class="input-group-text" id="cust-subdomain-suffix">.domain.com</span>
</div>
<div class="form-text">Lowercase, alphanumeric + hyphens</div>
<div class="form-text" data-i18n="customerModal.subdomainHint">Lowercase, alphanumeric + hyphens</div>
</div>
<div class="mb-3">
<label class="form-label">Email *</label>
<label class="form-label" data-i18n="customerModal.emailLabel">Email *</label>
<input type="email" class="form-control" id="cust-email" required>
</div>
<div class="mb-3">
<label class="form-label">Max Devices</label>
<label class="form-label" data-i18n="customerModal.maxDevicesLabel">Max Devices</label>
<input type="number" class="form-control" id="cust-max-devices" value="20" min="1">
</div>
<div class="mb-3">
<label class="form-label">Notes</label>
<label class="form-label" data-i18n="customerModal.notesLabel">Notes</label>
<textarea class="form-control" id="cust-notes" rows="2"></textarea>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal" data-i18n="common.cancel">Cancel</button>
<button type="button" class="btn btn-primary" id="customer-save-btn" onclick="saveCustomer()">
<span class="spinner-border spinner-border-sm d-none me-1" id="customer-save-spinner"></span>
Save & Deploy
<span data-i18n="customerModal.saveAndDeploy">Save & Deploy</span>
</button>
</div>
</div>
@@ -490,26 +642,68 @@
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header bg-danger text-white">
<h5 class="modal-title">Confirm Deletion</h5>
<h5 class="modal-title" data-i18n="deleteModal.title">Confirm Deletion</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p>Are you sure you want to delete customer <strong id="delete-customer-name"></strong>?</p>
<p class="text-danger">This will remove all containers, NPM entries, and data. This action cannot be undone.</p>
<p><span data-i18n="deleteModal.confirmText">Are you sure you want to delete customer</span> <strong id="delete-customer-name"></strong>?</p>
<p class="text-danger" data-i18n="deleteModal.warning">This will remove all containers, NPM entries, and data. This action cannot be undone.</p>
<input type="hidden" id="delete-customer-id">
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal" data-i18n="common.cancel">Cancel</button>
<button type="button" class="btn btn-danger" onclick="confirmDeleteCustomer()">
<span class="spinner-border spinner-border-sm d-none me-1" id="delete-spinner"></span>
Delete
<span data-i18n="common.delete">Delete</span>
</button>
</div>
</div>
</div>
</div>
<!-- Modal: New User -->
<div class="modal fade" id="user-modal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" data-i18n="userModal.title">New User</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div id="user-modal-error" class="alert alert-danger d-none"></div>
<form id="user-form">
<div class="mb-3">
<label class="form-label" data-i18n="userModal.usernameLabel">Username *</label>
<input type="text" class="form-control" id="new-user-username" required minlength="3">
</div>
<div class="mb-3">
<label class="form-label" data-i18n="userModal.passwordLabel">Password * (min 8 chars)</label>
<input type="password" class="form-control" id="new-user-password" required minlength="8">
</div>
<div class="mb-3">
<label class="form-label" data-i18n="userModal.emailLabel">Email</label>
<input type="email" class="form-control" id="new-user-email">
</div>
<div class="mb-3">
<label class="form-label" data-i18n="userModal.languageLabel">Default Language</label>
<select class="form-select" id="new-user-language">
<option value="" data-i18n="settings.systemDefault">System Default</option>
<option value="en">English</option>
<option value="de">Deutsch</option>
</select>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal" data-i18n="common.cancel">Cancel</button>
<button type="button" class="btn btn-primary" onclick="saveNewUser()" data-i18n="userModal.createUser">Create User</button>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<script src="/static/js/i18n.js"></script>
<script src="/static/js/app.js"></script>
</body>
</html>

View File

@@ -12,6 +12,8 @@ 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
@@ -32,21 +34,32 @@ async function api(method, path, body = null) {
resp = await fetch(`/api${path}`, opts);
} catch (networkErr) {
console.error(`API network error: ${method} ${path}`, networkErr);
throw new Error('Network error — server not reachable.');
throw new Error(t('errors.networkError'));
}
if (resp.status === 401) {
logout();
throw new Error('Session expired.');
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(`Server error (HTTP ${resp.status}).`);
throw new Error(t('errors.serverError', { status: resp.status }));
}
if (!resp.ok) {
const msg = data.detail || data.message || 'Request failed.';
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);
}
@@ -56,20 +69,27 @@ async function api(method, path, body = null) {
// ---------------------------------------------------------------------------
// Auth
// ---------------------------------------------------------------------------
function initApp() {
async function initApp() {
await initI18n();
await loadBranding();
await loadAzureLoginConfig();
if (authToken) {
api('GET', '/auth/me')
.then(user => {
currentUser = user;
document.getElementById('nav-username').textContent = user.username;
showAppPage();
loadDashboard();
})
.catch(() => {
authToken = null;
localStorage.removeItem('authToken');
showLoginPage();
});
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();
}
@@ -85,6 +105,112 @@ function showAppPage() {
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 = `<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;
}
}
document.getElementById('login-form').addEventListener('submit', async (e) => {
e.preventDefault();
const errorEl = document.getElementById('login-error');
@@ -101,6 +227,10 @@ document.getElementById('login-form').addEventListener('submit', async (e) => {
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) {
@@ -128,6 +258,23 @@ function logout() {
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
// ---------------------------------------------------------------------------
@@ -178,39 +325,44 @@ async function loadCustomers() {
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">No customers found. Click "New Customer" to create one.</td></tr>';
document.getElementById('pagination-info').textContent = 'Showing 0 of 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 => `
<tr>
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>
<td>${c.id}</td>
<td><a href="#" onclick="viewCustomer(${c.id})" class="text-decoration-none fw-semibold">${esc(c.name)}</a></td>
<td>${esc(c.company || '-')}</td>
<td><code>${esc(c.subdomain)}</code></td>
<td>${statusBadge(c.status)}</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="View" onclick="viewCustomer(${c.id})"><i class="bi bi-eye"></i></button>
<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="Stop" onclick="customerAction(${c.id},'stop')"><i class="bi bi-stop-circle"></i></button>`
: `<button class="btn btn-outline-success" title="Start" onclick="customerAction(${c.id},'start')"><i class="bi bi-play-circle"></i></button>`
? `<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="Restart" onclick="customerAction(${c.id},'restart')"><i class="bi bi-arrow-repeat"></i></button>
<button class="btn btn-outline-danger" title="Delete" onclick="showDeleteModal(${c.id},'${esc(c.name)}')"><i class="bi bi-trash"></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('');
</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 = `Showing ${start}-${end} of ${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++) {
@@ -232,12 +384,13 @@ document.getElementById('status-filter').addEventListener('change', () => { cust
// Customer CRUD
// ---------------------------------------------------------------------------
function showNewCustomerModal() {
document.getElementById('customer-modal-title').textContent = 'New Customer';
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');
document.getElementById('customer-save-btn').innerHTML = '<span class="spinner-border spinner-border-sm d-none me-1" id="customer-save-spinner"></span> Save &amp; Deploy';
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 => {
@@ -254,7 +407,7 @@ function showNewCustomerModal() {
function editCurrentCustomer() {
if (!currentCustomerData) return;
const c = currentCustomerData;
document.getElementById('customer-modal-title').textContent = 'Edit Customer';
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 || '';
@@ -264,7 +417,8 @@ function editCurrentCustomer() {
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');
document.getElementById('customer-save-btn').innerHTML = '<span class="spinner-border spinner-border-sm d-none me-1" id="customer-save-spinner"></span> Save Changes';
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);
@@ -309,7 +463,7 @@ async function saveCustomer() {
}
} catch (err) {
console.error('saveCustomer error:', err);
errorEl.textContent = err.message || 'An unknown error occurred.';
errorEl.textContent = err.message || t('errors.unknownError');
errorEl.classList.remove('d-none');
errorEl.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
} finally {
@@ -338,7 +492,7 @@ async function confirmDeleteCustomer() {
bootstrap.Modal.getInstance(document.getElementById('delete-modal')).hide();
showPage('dashboard');
} catch (err) {
alert('Delete failed: ' + err.message);
alert(t('errors.deleteFailed', { error: err.message }));
} finally {
spinner.classList.add('d-none');
}
@@ -353,7 +507,7 @@ async function customerAction(id, action) {
if (currentPage === 'dashboard') loadCustomers();
if (currentCustomerId == id) viewCustomer(id);
} catch (err) {
alert(`${action} failed: ${err.message}`);
alert(t('errors.actionFailed', { action, error: err.message }));
}
}
@@ -374,15 +528,15 @@ async function viewCustomer(id) {
// Info tab
document.getElementById('detail-info-content').innerHTML = `
<div class="row g-3">
<div class="col-md-6"><strong>Name:</strong> ${esc(data.name)}</div>
<div class="col-md-6"><strong>Company:</strong> ${esc(data.company || '-')}</div>
<div class="col-md-6"><strong>Subdomain:</strong> <code>${esc(data.subdomain)}</code></div>
<div class="col-md-6"><strong>Email:</strong> ${esc(data.email)}</div>
<div class="col-md-6"><strong>Max Devices:</strong> ${data.max_devices}</div>
<div class="col-md-6"><strong>Status:</strong> ${statusBadge(data.status)}</div>
<div class="col-md-6"><strong>Created:</strong> ${formatDate(data.created_at)}</div>
<div class="col-md-6"><strong>Updated:</strong> ${formatDate(data.updated_at)}</div>
${data.notes ? `<div class="col-12"><strong>Notes:</strong> ${esc(data.notes)}</div>` : ''}
<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>
`;
@@ -391,29 +545,62 @@ async function viewCustomer(id) {
const d = data.deployment;
document.getElementById('detail-deployment-content').innerHTML = `
<div class="row g-3">
<div class="col-md-6"><strong>Status:</strong> ${statusBadge(d.deployment_status)}</div>
<div class="col-md-6"><strong>Relay UDP Port:</strong> ${d.relay_udp_port}</div>
<div class="col-md-6"><strong>Container Prefix:</strong> <code>${esc(d.container_prefix)}</code></div>
<div class="col-md-6"><strong>Deployed:</strong> ${formatDate(d.deployed_at)}</div>
<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>Setup URL:</strong>
<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> Copy</button>
<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>Start</button>
<button class="btn btn-warning btn-sm me-1" onclick="customerAction(${id},'stop')"><i class="bi bi-stop-circle me-1"></i>Stop</button>
<button class="btn btn-info btn-sm me-1" onclick="customerAction(${id},'restart')"><i class="bi bi-arrow-repeat me-1"></i>Restart</button>
<button class="btn btn-outline-primary btn-sm" onclick="customerAction(${id},'deploy')"><i class="bi bi-rocket me-1"></i>Re-Deploy</button>
<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" onclick="customerAction(${id},'deploy')"><i class="bi bi-rocket me-1"></i>${t('customer.reDeploy')}</button>
</div>
`;
} else {
document.getElementById('detail-deployment-content').innerHTML = `
<p class="text-muted">No deployment found.</p>
<button class="btn btn-primary" onclick="customerAction(${id},'deploy')"><i class="bi bi-rocket me-1"></i>Deploy Now</button>
<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>
`;
}
@@ -434,7 +621,7 @@ async function loadCustomerLogs() {
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">No container logs available.</p>';
content.innerHTML = `<p class="text-muted">${t('customer.noContainerLogs')}</p>`;
return;
}
let html = '';
@@ -452,16 +639,18 @@ async function loadCustomerHealth() {
try {
const data = await api('GET', `/customers/${currentCustomerId}/health`);
const content = document.getElementById('detail-health-content');
let html = `<div class="mb-3"><strong>Overall:</strong> ${data.healthy ? '<span class="text-success">Healthy</span>' : '<span class="text-danger">Unhealthy</span>'}</div>`;
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>Container</th><th>Status</th><th>Health</th><th>Image</th></tr></thead><tbody>';
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';
html += `<tr><td>${esc(c.name)}</td><td class="${statusClass}">${c.status}</td><td>${c.health}</td><td><code>${esc(c.image)}</code></td></tr>`;
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">Last check: ${formatDate(data.last_check)}</div>`;
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>`;
@@ -471,7 +660,29 @@ async function loadCustomerHealth() {
function copySetupUrl() {
const input = document.getElementById('setup-url-input');
navigator.clipboard.writeText(input.value).then(() => {
showToast('Setup URL copied to clipboard.');
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'));
});
}
@@ -486,14 +697,36 @@ async function loadSettings() {
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 ? 'Credentials are set (leave empty to keep current)' : 'No NPM credentials configured';
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', 'Failed to load settings: ' + err.message);
showSettingsAlert('danger', t('errors.failedToLoadSettings', { error: err.message }));
}
}
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>`;
}
}
@@ -507,10 +740,11 @@ document.getElementById('settings-system-form').addEventListener('submit', async
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', 'System settings saved.');
showSettingsAlert('success', t('messages.systemSettingsSaved'));
} catch (err) {
showSettingsAlert('danger', 'Failed: ' + err.message);
showSettingsAlert('danger', t('errors.failed', { error: err.message }));
}
});
@@ -524,12 +758,12 @@ document.getElementById('settings-npm-form').addEventListener('submit', async (e
if (password) payload.npm_api_password = password;
try {
await api('PUT', '/settings/system', payload);
showSettingsAlert('success', 'NPM settings saved.');
showSettingsAlert('success', t('messages.npmSettingsSaved'));
document.getElementById('cfg-npm-api-email').value = '';
document.getElementById('cfg-npm-api-password').value = '';
loadSettings();
} catch (err) {
showSettingsAlert('danger', 'Failed: ' + err.message);
showSettingsAlert('danger', t('errors.failed', { error: err.message }));
}
});
@@ -543,9 +777,9 @@ document.getElementById('settings-images-form').addEventListener('submit', async
netbird_relay_image: document.getElementById('cfg-relay-image').value,
netbird_dashboard_image: document.getElementById('cfg-dashboard-image').value,
});
showSettingsAlert('success', 'Image settings saved.');
showSettingsAlert('success', t('messages.imageSettingsSaved'));
} catch (err) {
showSettingsAlert('danger', 'Failed: ' + err.message);
showSettingsAlert('danger', t('errors.failed', { error: err.message }));
}
});
@@ -579,7 +813,7 @@ document.getElementById('change-password-form').addEventListener('submit', async
if (newPw !== confirmPw) {
resultEl.className = 'mt-3 alert alert-danger';
resultEl.textContent = 'Passwords do not match.';
resultEl.textContent = t('errors.passwordsDoNotMatch');
resultEl.classList.remove('d-none');
return;
}
@@ -590,7 +824,7 @@ document.getElementById('change-password-form').addEventListener('submit', async
new_password: newPw,
});
resultEl.className = 'mt-3 alert alert-success';
resultEl.textContent = 'Password changed successfully.';
resultEl.textContent = t('messages.passwordChanged');
resultEl.classList.remove('d-none');
document.getElementById('change-password-form').reset();
} catch (err) {
@@ -608,9 +842,193 @@ function showSettingsAlert(type, msg) {
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 = `<tr><td colspan="8" 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>`;
return `<tr>
<td>${u.id}</td>
<td><strong>${esc(u.username)}</strong></td>
<td>${esc(u.email || '-')}</td>
<td><span class="badge bg-info">${esc(u.role || 'admin')}</span></td>
<td><span class="badge bg-${u.auth_provider === 'azure' ? 'primary' : 'secondary'}">${esc(u.auth_provider || 'local')}</span></td>
<td>${langDisplay}</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>` : ''}
<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="8" 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 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);
input.type = input.type === 'password' ? 'text' : 'password';
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';
}
}
// ---------------------------------------------------------------------------
@@ -626,26 +1044,26 @@ async function loadResources() {
document.getElementById('monitoring-resources').innerHTML = `
<div class="row g-3">
<div class="col-md-3">
<div class="text-muted small">Hostname</div>
<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">CPU (${data.cpu.count} cores)</div>
<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">Memory (${data.memory.used_gb}/${data.memory.total_gb} GB)</div>
<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">Disk (${data.disk.used_gb}/${data.disk.total_gb} GB)</div>
<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>
@@ -663,23 +1081,28 @@ async function loadAllCustomerStatuses() {
const data = await api('GET', '/monitoring/customers');
const tbody = document.getElementById('monitoring-customers-body');
if (!data || data.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" class="text-center text-muted py-4">No customers.</td></tr>';
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="7" class="text-danger">${err.message}</td></tr>`;
document.getElementById('monitoring-customers-body').innerHTML = `<tr><td colspan="8" class="text-danger">${err.message}</td></tr>`;
}
}
@@ -699,8 +1122,9 @@ function statusBadge(status) {
function formatDate(isoStr) {
if (!isoStr) return '-';
const locale = getCurrentLanguage() === 'de' ? 'de-DE' : 'en-US';
const d = new Date(isoStr);
return d.toLocaleDateString('de-DE') + ' ' + d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
return d.toLocaleDateString(locale) + ' ' + d.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' });
}
function esc(str) {
@@ -730,4 +1154,14 @@ function showToast(message) {
// ---------------------------------------------------------------------------
// Init
// ---------------------------------------------------------------------------
document.addEventListener('DOMContentLoaded', initApp);
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();
});

100
static/js/i18n.js Normal file
View File

@@ -0,0 +1,100 @@
/**
* i18n - Internationalization for NetBird MSP Appliance
* Supports: English (en), German (de)
*/
let currentLanguage = null;
let systemDefaultLanguage = 'en';
const translations = {};
const SUPPORTED_LANGS = ['en', 'de'];
function setSystemDefault(lang) {
if (SUPPORTED_LANGS.includes(lang)) {
systemDefaultLanguage = lang;
}
}
function detectLanguage() {
const stored = localStorage.getItem('language');
if (stored && SUPPORTED_LANGS.includes(stored)) return stored;
// Fall back to system default (from server settings)
if (systemDefaultLanguage && SUPPORTED_LANGS.includes(systemDefaultLanguage)) return systemDefaultLanguage;
const browser = (navigator.language || '').toLowerCase();
if (browser.startsWith('de')) return 'de';
return 'en';
}
async function loadLanguage(lang) {
if (translations[lang]) return;
try {
const resp = await fetch(`/static/lang/${lang}.json`);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
translations[lang] = await resp.json();
} catch (err) {
console.error(`i18n: failed to load ${lang}`, err);
if (lang !== 'en') await loadLanguage('en');
}
}
function t(key, params) {
const lang = currentLanguage || 'en';
const dict = translations[lang] || translations['en'] || {};
let value = key.split('.').reduce((o, k) => (o && o[k] !== undefined) ? o[k] : null, dict);
if (value === null && lang !== 'en') {
const en = translations['en'] || {};
value = key.split('.').reduce((o, k) => (o && o[k] !== undefined) ? o[k] : null, en);
}
if (value === null) return key;
if (params && typeof value === 'string') {
value = value.replace(/\{(\w+)\}/g, (m, p) => params[p] !== undefined ? params[p] : m);
}
return value;
}
function applyTranslations() {
document.querySelectorAll('[data-i18n]').forEach(el => {
el.textContent = t(el.getAttribute('data-i18n'));
});
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
el.placeholder = t(el.getAttribute('data-i18n-placeholder'));
});
document.querySelectorAll('[data-i18n-title]').forEach(el => {
el.title = t(el.getAttribute('data-i18n-title'));
});
document.querySelectorAll('[data-i18n-html]').forEach(el => {
el.innerHTML = t(el.getAttribute('data-i18n-html'));
});
}
function updateLanguageSwitcher() {
const btn = document.getElementById('language-switcher-btn');
if (btn) btn.textContent = (currentLanguage || 'en').toUpperCase();
document.querySelectorAll('[data-lang]').forEach(el => {
el.classList.toggle('active', el.getAttribute('data-lang') === currentLanguage);
});
}
async function setLanguage(lang) {
if (!SUPPORTED_LANGS.includes(lang)) lang = 'en';
if (!translations[lang]) await loadLanguage(lang);
currentLanguage = lang;
localStorage.setItem('language', lang);
document.documentElement.lang = lang;
updateLanguageSwitcher();
applyTranslations();
}
function getCurrentLanguage() {
return currentLanguage || 'en';
}
async function initI18n() {
const lang = detectLanguage();
await loadLanguage('en');
if (lang !== 'en') await loadLanguage(lang);
currentLanguage = lang;
document.documentElement.lang = lang;
updateLanguageSwitcher();
applyTranslations();
document.body.classList.remove('i18n-loading');
}

285
static/lang/de.json Normal file
View File

@@ -0,0 +1,285 @@
{
"login": {
"subtitle": "Multi-Tenant Management Plattform",
"username": "Benutzername",
"password": "Passwort",
"signIn": "Anmelden",
"signInWithMicrosoft": "Mit Microsoft anmelden"
},
"nav": {
"newCustomer": "Neuer Kunde",
"settings": "Einstellungen",
"monitoring": "Monitoring",
"logout": "Abmelden"
},
"dashboard": {
"totalCustomers": "Kunden gesamt",
"active": "Aktiv",
"inactive": "Inaktiv",
"errors": "Fehler",
"searchPlaceholder": "Suche nach Name, Subdomain, E-Mail...",
"allStatuses": "Alle Status",
"statusActive": "Aktiv",
"statusInactive": "Inaktiv",
"statusDeploying": "Wird bereitgestellt",
"statusError": "Fehler",
"refresh": "Aktualisieren",
"thId": "ID",
"thName": "Name",
"thSubdomain": "Subdomain",
"thStatus": "Status",
"thDashboard": "Dashboard",
"thDevices": "Geraete",
"thCreated": "Erstellt",
"thActions": "Aktionen",
"noCustomers": "Keine Kunden gefunden. Klicken Sie auf \"Neuer Kunde\" um einen anzulegen.",
"showing": "Zeige {start}-{end} von {total}",
"showingEmpty": "Zeige 0 von 0"
},
"customer": {
"back": "Zurueck",
"customer": "Kunde",
"edit": "Bearbeiten",
"delete": "Loeschen",
"tabInfo": "Info",
"tabDeployment": "Deployment",
"tabLogs": "Logs",
"tabHealth": "Zustand",
"name": "Name:",
"company": "Firma:",
"subdomain": "Subdomain:",
"email": "E-Mail:",
"maxDevices": "Max. Geraete:",
"status": "Status:",
"created": "Erstellt:",
"updated": "Aktualisiert:",
"notes": "Notizen:",
"deploymentStatus": "Status:",
"relayUdpPort": "Relay UDP Port:",
"dashboardPort": "Dashboard Port:",
"containerPrefix": "Container-Praefix:",
"deployed": "Bereitgestellt:",
"setupUrl": "Setup URL:",
"copy": "Kopieren",
"open": "Oeffnen",
"netbirdLogin": "NetBird Login",
"notAvailable": "Nicht verfuegbar",
"showCredentials": "Zugangsdaten anzeigen",
"credEmail": "E-Mail",
"credPassword": "Passwort",
"showHide": "Anzeigen/Verbergen",
"credentialsNotAvailable": "Zugangsdaten nicht verfuegbar. Der Admin muss das Setup manuell ueber die Setup URL abschliessen.",
"start": "Starten",
"stop": "Stoppen",
"restart": "Neustarten",
"reDeploy": "Neu bereitstellen",
"noDeployment": "Kein Deployment gefunden.",
"deployNow": "Jetzt bereitstellen",
"containerLogs": "Container Logs",
"noContainerLogs": "Keine Container-Logs verfuegbar.",
"noLogsLoaded": "Keine Logs geladen.",
"healthCheck": "Zustandspruefung",
"check": "Pruefen",
"clickCheck": "Klicken Sie auf \"Pruefen\" um eine Zustandspruefung durchzufuehren.",
"healthy": "Gesund",
"unhealthy": "Fehlerhaft",
"overall": "Gesamt:",
"thContainer": "Container",
"thContainerStatus": "Status",
"thHealth": "Zustand",
"thImage": "Image",
"lastCheck": "Letzte Pruefung: {time}",
"openDashboard": "Dashboard oeffnen"
},
"customerModal": {
"newCustomer": "Neuer Kunde",
"editCustomer": "Kunde bearbeiten",
"nameLabel": "Name *",
"companyLabel": "Firma",
"subdomainLabel": "Subdomain *",
"subdomainHint": "Kleinbuchstaben, alphanumerisch + Bindestriche",
"emailLabel": "E-Mail *",
"maxDevicesLabel": "Max. Geraete",
"notesLabel": "Notizen",
"cancel": "Abbrechen",
"saveAndDeploy": "Speichern & Bereitstellen",
"saveChanges": "Aenderungen speichern"
},
"deleteModal": {
"title": "Loeschung bestaetigen",
"confirmText": "Sind Sie sicher, dass Sie den Kunden loeschen moechten",
"warning": "Alle Container, NPM-Eintraege und Daten werden entfernt. Diese Aktion kann nicht rueckgaengig gemacht werden.",
"cancel": "Abbrechen",
"delete": "Loeschen"
},
"settings": {
"title": "Systemeinstellungen",
"tabSystem": "Systemkonfiguration",
"tabNpm": "NPM Integration",
"tabImages": "Docker Images",
"tabBranding": "Branding",
"tabUsers": "Benutzer",
"tabAzure": "Azure AD",
"tabSecurity": "Sicherheit",
"baseDomain": "Basis-Domain",
"baseDomainPlaceholder": "ihredomain.com",
"baseDomainHint": "Kunden erhalten Subdomains: kunde.ihredomain.com",
"adminEmail": "Admin E-Mail",
"adminEmailPlaceholder": "admin@ihredomain.com",
"dataDir": "Datenverzeichnis",
"dataDirPlaceholder": "/opt/netbird-instances",
"dockerNetwork": "Docker Netzwerk",
"dockerNetworkPlaceholder": "npm-network",
"relayBasePort": "Relay Basis-Port",
"relayBasePortHint": "Erster UDP-Port fuer Relay. Bereich: Basis bis Basis+99",
"dashboardBasePort": "Dashboard Basis-Port",
"dashboardBasePortHint": "Basis-Port fuer Kunden-Dashboards. Kunde N erhaelt Basis+N",
"saveSystemSettings": "Systemeinstellungen speichern",
"npmDescription": "NPM verwendet JWT-Authentifizierung. Geben Sie Ihre NPM-Anmeldedaten (E-Mail + Passwort) ein. Das System meldet sich automatisch an und bezieht Tokens fuer API-Aufrufe.",
"npmApiUrl": "NPM API URL",
"npmApiUrlPlaceholder": "http://nginx-proxy-manager:81/api",
"npmApiUrlHint": "http:// oder https:// - muss /api am Ende enthalten",
"npmLoginEmail": "NPM Login E-Mail",
"npmLoginEmailPlaceholder": "Leer lassen um aktuelle beizubehalten",
"npmLoginPassword": "NPM Login Passwort",
"npmLoginPasswordPlaceholder": "Leer lassen um aktuelles beizubehalten",
"credentialsSet": "Zugangsdaten sind gesetzt (leer lassen um aktuelle beizubehalten)",
"noCredentials": "Keine NPM-Zugangsdaten konfiguriert",
"saveNpmSettings": "NPM Einstellungen speichern",
"testConnection": "Verbindung testen",
"managementImage": "Management Image",
"managementImagePlaceholder": "netbirdio/management:latest",
"signalImage": "Signal Image",
"signalImagePlaceholder": "netbirdio/signal:latest",
"relayImage": "Relay Image",
"relayImagePlaceholder": "netbirdio/relay:latest",
"dashboardImage": "Dashboard Image",
"dashboardImagePlaceholder": "netbirdio/dashboard:latest",
"saveImageSettings": "Image Einstellungen speichern",
"brandingTitle": "Branding Einstellungen",
"companyName": "Firmen- / Anwendungsname",
"companyNamePlaceholder": "NetBird MSP Appliance",
"companyNameHint": "Wird auf der Login-Seite und in der Navbar angezeigt",
"logoPreview": "Logo-Vorschau",
"defaultIcon": "Standard-Icon (kein Logo hochgeladen)",
"uploadLogo": "Logo hochladen (PNG, JPG, SVG, max 500KB)",
"uploadBtn": "Hochladen",
"removeLogo": "Logo entfernen",
"brandingSubtitle": "Untertitel",
"brandingSubtitlePlaceholder": "Multi-Tenant Management Plattform",
"brandingSubtitleHint": "Wird unter dem Titel auf der Login-Seite angezeigt",
"defaultLanguage": "Standardsprache",
"defaultLanguageHint": "Standardsprache fuer Benutzer ohne eigene Einstellung",
"systemDefault": "Systemstandard",
"saveBranding": "Branding speichern",
"userManagement": "Benutzerverwaltung",
"newUser": "Neuer Benutzer",
"thId": "ID",
"thUsername": "Benutzername",
"thEmail": "E-Mail",
"thRole": "Rolle",
"thAuth": "Auth",
"thLanguage": "Sprache",
"thStatus": "Status",
"thActions": "Aktionen",
"azureTitle": "Azure AD / Entra ID Integration",
"enableAzureSso": "Azure AD SSO aktivieren",
"tenantId": "Tenant ID",
"clientId": "Client ID (Anwendungs-ID)",
"clientSecret": "Client Secret",
"clientSecretPlaceholder": "Leer lassen um aktuelles beizubehalten",
"secretSet": "Secret ist gesetzt (leer lassen um aktuelles beizubehalten)",
"noSecret": "Kein Client Secret konfiguriert",
"saveAzureSettings": "Azure AD Einstellungen speichern",
"securityTitle": "Admin-Passwort aendern",
"currentPassword": "Aktuelles Passwort",
"newPassword": "Neues Passwort (min. 12 Zeichen)",
"confirmPassword": "Neues Passwort bestaetigen",
"changePassword": "Passwort aendern"
},
"monitoring": {
"title": "System Monitoring",
"refresh": "Aktualisieren",
"hostResources": "Host-Ressourcen",
"hostname": "Hostname",
"cpu": "CPU ({count} Kerne)",
"memory": "Speicher ({used}/{total} GB)",
"disk": "Festplatte ({used}/{total} GB)",
"allCustomerDeployments": "Alle Kunden-Deployments",
"thId": "ID",
"thName": "Name",
"thSubdomain": "Subdomain",
"thStatus": "Status",
"thDeployment": "Deployment",
"thDashboard": "Dashboard",
"thRelayPort": "Relay Port",
"thContainers": "Container",
"noCustomers": "Keine Kunden."
},
"userModal": {
"title": "Neuer Benutzer",
"usernameLabel": "Benutzername *",
"passwordLabel": "Passwort * (min. 8 Zeichen)",
"emailLabel": "E-Mail",
"languageLabel": "Standardsprache",
"cancel": "Abbrechen",
"createUser": "Benutzer erstellen"
},
"common": {
"loading": "Laden...",
"back": "Zurueck",
"save": "Speichern",
"cancel": "Abbrechen",
"delete": "Loeschen",
"edit": "Bearbeiten",
"view": "Anzeigen",
"start": "Starten",
"stop": "Stoppen",
"restart": "Neustarten",
"disable": "Deaktivieren",
"enable": "Aktivieren",
"resetPassword": "Passwort zuruecksetzen",
"open": "Oeffnen",
"active": "Aktiv",
"disabled": "Deaktiviert"
},
"errors": {
"networkError": "Netzwerkfehler \u2014 Server nicht erreichbar.",
"sessionExpired": "Sitzung abgelaufen.",
"requestFailed": "Anfrage fehlgeschlagen.",
"serverError": "Serverfehler (HTTP {status}).",
"unknownError": "Ein unbekannter Fehler ist aufgetreten.",
"uploadFailed": "Upload fehlgeschlagen.",
"deleteFailed": "Loeschen fehlgeschlagen: {error}",
"failedToLoadSettings": "Einstellungen konnten nicht geladen werden: {error}",
"failed": "Fehlgeschlagen: {error}",
"logoUploadFailed": "Logo-Upload fehlgeschlagen: {error}",
"failedToRemoveLogo": "Logo konnte nicht entfernt werden: {error}",
"updateFailed": "Aktualisierung fehlgeschlagen: {error}",
"passwordResetFailed": "Passwort-Zuruecksetzung fehlgeschlagen: {error}",
"selectFileFirst": "Bitte waehlen Sie zuerst eine Datei aus.",
"passwordsDoNotMatch": "Passwoerter stimmen nicht ueberein.",
"failedToLoadCredentials": "Zugangsdaten konnten nicht geladen werden: {error}",
"azureNotConfigured": "Azure AD ist nicht konfiguriert.",
"azureLoginFailed": "Azure AD Anmeldung fehlgeschlagen: {error}",
"actionFailed": "{action} fehlgeschlagen: {error}"
},
"messages": {
"systemSettingsSaved": "Systemeinstellungen gespeichert.",
"npmSettingsSaved": "NPM Einstellungen gespeichert.",
"imageSettingsSaved": "Image Einstellungen gespeichert.",
"brandingNameSaved": "Branding-Einstellungen gespeichert.",
"logoUploaded": "Logo erfolgreich hochgeladen.",
"logoRemoved": "Logo entfernt.",
"azureSettingsSaved": "Azure AD Einstellungen gespeichert.",
"passwordChanged": "Passwort erfolgreich geaendert.",
"setupUrlCopied": "Setup URL in die Zwischenablage kopiert.",
"copiedToClipboard": "In die Zwischenablage kopiert.",
"userCreated": "Benutzer '{username}' erstellt.",
"userDeleted": "Benutzer '{username}' geloescht.",
"passwordResetFor": "Passwort fuer '{username}' zurueckgesetzt.",
"newPasswordAlert": "Neues Passwort fuer '{username}':\n\n{password}\n\nBitte speichern Sie dieses Passwort jetzt. Es wird nicht erneut angezeigt.",
"confirmDeleteUser": "Benutzer '{username}' loeschen? Dies kann nicht rueckgaengig gemacht werden.",
"confirmResetPassword": "Passwort fuer '{username}' zuruecksetzen? Ein neues zufaelliges Passwort wird generiert."
}
}

285
static/lang/en.json Normal file
View File

@@ -0,0 +1,285 @@
{
"login": {
"subtitle": "Multi-Tenant Management Platform",
"username": "Username",
"password": "Password",
"signIn": "Sign In",
"signInWithMicrosoft": "Sign in with Microsoft"
},
"nav": {
"newCustomer": "New Customer",
"settings": "Settings",
"monitoring": "Monitoring",
"logout": "Logout"
},
"dashboard": {
"totalCustomers": "Total Customers",
"active": "Active",
"inactive": "Inactive",
"errors": "Errors",
"searchPlaceholder": "Search by name, subdomain, email...",
"allStatuses": "All Statuses",
"statusActive": "Active",
"statusInactive": "Inactive",
"statusDeploying": "Deploying",
"statusError": "Error",
"refresh": "Refresh",
"thId": "ID",
"thName": "Name",
"thSubdomain": "Subdomain",
"thStatus": "Status",
"thDashboard": "Dashboard",
"thDevices": "Devices",
"thCreated": "Created",
"thActions": "Actions",
"noCustomers": "No customers found. Click \"New Customer\" to create one.",
"showing": "Showing {start}-{end} of {total}",
"showingEmpty": "Showing 0 of 0"
},
"customer": {
"back": "Back",
"customer": "Customer",
"edit": "Edit",
"delete": "Delete",
"tabInfo": "Info",
"tabDeployment": "Deployment",
"tabLogs": "Logs",
"tabHealth": "Health",
"name": "Name:",
"company": "Company:",
"subdomain": "Subdomain:",
"email": "Email:",
"maxDevices": "Max Devices:",
"status": "Status:",
"created": "Created:",
"updated": "Updated:",
"notes": "Notes:",
"deploymentStatus": "Status:",
"relayUdpPort": "Relay UDP Port:",
"dashboardPort": "Dashboard Port:",
"containerPrefix": "Container Prefix:",
"deployed": "Deployed:",
"setupUrl": "Setup URL:",
"copy": "Copy",
"open": "Open",
"netbirdLogin": "NetBird Login",
"notAvailable": "Not available",
"showCredentials": "Show Credentials",
"credEmail": "Email",
"credPassword": "Password",
"showHide": "Show/Hide",
"credentialsNotAvailable": "Credentials not available. Admin must complete setup manually at the Setup URL.",
"start": "Start",
"stop": "Stop",
"restart": "Restart",
"reDeploy": "Re-Deploy",
"noDeployment": "No deployment found.",
"deployNow": "Deploy Now",
"containerLogs": "Container Logs",
"noContainerLogs": "No container logs available.",
"noLogsLoaded": "No logs loaded.",
"healthCheck": "Health Check",
"check": "Check",
"clickCheck": "Click \"Check\" to run a health check.",
"healthy": "Healthy",
"unhealthy": "Unhealthy",
"overall": "Overall:",
"thContainer": "Container",
"thContainerStatus": "Status",
"thHealth": "Health",
"thImage": "Image",
"lastCheck": "Last check: {time}",
"openDashboard": "Open Dashboard"
},
"customerModal": {
"newCustomer": "New Customer",
"editCustomer": "Edit Customer",
"nameLabel": "Name *",
"companyLabel": "Company",
"subdomainLabel": "Subdomain *",
"subdomainHint": "Lowercase, alphanumeric + hyphens",
"emailLabel": "Email *",
"maxDevicesLabel": "Max Devices",
"notesLabel": "Notes",
"cancel": "Cancel",
"saveAndDeploy": "Save & Deploy",
"saveChanges": "Save Changes"
},
"deleteModal": {
"title": "Confirm Deletion",
"confirmText": "Are you sure you want to delete customer",
"warning": "This will remove all containers, NPM entries, and data. This action cannot be undone.",
"cancel": "Cancel",
"delete": "Delete"
},
"settings": {
"title": "System Settings",
"tabSystem": "System Configuration",
"tabNpm": "NPM Integration",
"tabImages": "Docker Images",
"tabBranding": "Branding",
"tabUsers": "Users",
"tabAzure": "Azure AD",
"tabSecurity": "Security",
"baseDomain": "Base Domain",
"baseDomainPlaceholder": "yourdomain.com",
"baseDomainHint": "Customers get subdomains: customer.yourdomain.com",
"adminEmail": "Admin Email",
"adminEmailPlaceholder": "admin@yourdomain.com",
"dataDir": "Data Directory",
"dataDirPlaceholder": "/opt/netbird-instances",
"dockerNetwork": "Docker Network",
"dockerNetworkPlaceholder": "npm-network",
"relayBasePort": "Relay Base Port",
"relayBasePortHint": "First UDP port for relay. Range: base to base+99",
"dashboardBasePort": "Dashboard Base Port",
"dashboardBasePortHint": "Base port for customer dashboards. Customer N gets base+N",
"saveSystemSettings": "Save System Settings",
"npmDescription": "NPM uses JWT authentication. Enter your NPM login credentials (email + password). The system will automatically log in and obtain tokens for API calls.",
"npmApiUrl": "NPM API URL",
"npmApiUrlPlaceholder": "http://nginx-proxy-manager:81/api",
"npmApiUrlHint": "http:// or https:// - must include /api at the end",
"npmLoginEmail": "NPM Login Email",
"npmLoginEmailPlaceholder": "Leave empty to keep current",
"npmLoginPassword": "NPM Login Password",
"npmLoginPasswordPlaceholder": "Leave empty to keep current",
"credentialsSet": "Credentials are set (leave empty to keep current)",
"noCredentials": "No NPM credentials configured",
"saveNpmSettings": "Save NPM Settings",
"testConnection": "Test Connection",
"managementImage": "Management Image",
"managementImagePlaceholder": "netbirdio/management:latest",
"signalImage": "Signal Image",
"signalImagePlaceholder": "netbirdio/signal:latest",
"relayImage": "Relay Image",
"relayImagePlaceholder": "netbirdio/relay:latest",
"dashboardImage": "Dashboard Image",
"dashboardImagePlaceholder": "netbirdio/dashboard:latest",
"saveImageSettings": "Save Image Settings",
"brandingTitle": "Branding Settings",
"companyName": "Company / Application Name",
"companyNamePlaceholder": "NetBird MSP Appliance",
"companyNameHint": "Displayed on login page and navbar",
"logoPreview": "Logo Preview",
"defaultIcon": "Default icon (no logo uploaded)",
"uploadLogo": "Upload Logo (PNG, JPG, SVG, max 500KB)",
"uploadBtn": "Upload",
"removeLogo": "Remove Logo",
"brandingSubtitle": "Subtitle",
"brandingSubtitlePlaceholder": "Multi-Tenant Management Platform",
"brandingSubtitleHint": "Shown below the title on the login page",
"defaultLanguage": "Default Language",
"defaultLanguageHint": "Default language for users without a preference",
"systemDefault": "System Default",
"saveBranding": "Save Branding",
"userManagement": "User Management",
"newUser": "New User",
"thId": "ID",
"thUsername": "Username",
"thEmail": "Email",
"thRole": "Role",
"thAuth": "Auth",
"thLanguage": "Language",
"thStatus": "Status",
"thActions": "Actions",
"azureTitle": "Azure AD / Entra ID Integration",
"enableAzureSso": "Enable Azure AD SSO",
"tenantId": "Tenant ID",
"clientId": "Client ID (Application ID)",
"clientSecret": "Client Secret",
"clientSecretPlaceholder": "Leave empty to keep current",
"secretSet": "Secret is set (leave empty to keep current)",
"noSecret": "No client secret configured",
"saveAzureSettings": "Save Azure AD Settings",
"securityTitle": "Change Admin Password",
"currentPassword": "Current Password",
"newPassword": "New Password (min 12 chars)",
"confirmPassword": "Confirm New Password",
"changePassword": "Change Password"
},
"monitoring": {
"title": "System Monitoring",
"refresh": "Refresh",
"hostResources": "Host Resources",
"hostname": "Hostname",
"cpu": "CPU ({count} cores)",
"memory": "Memory ({used}/{total} GB)",
"disk": "Disk ({used}/{total} GB)",
"allCustomerDeployments": "All Customer Deployments",
"thId": "ID",
"thName": "Name",
"thSubdomain": "Subdomain",
"thStatus": "Status",
"thDeployment": "Deployment",
"thDashboard": "Dashboard",
"thRelayPort": "Relay Port",
"thContainers": "Containers",
"noCustomers": "No customers."
},
"userModal": {
"title": "New User",
"usernameLabel": "Username *",
"passwordLabel": "Password * (min 8 chars)",
"emailLabel": "Email",
"languageLabel": "Default Language",
"cancel": "Cancel",
"createUser": "Create User"
},
"common": {
"loading": "Loading...",
"back": "Back",
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"edit": "Edit",
"view": "View",
"start": "Start",
"stop": "Stop",
"restart": "Restart",
"disable": "Disable",
"enable": "Enable",
"resetPassword": "Reset Password",
"open": "Open",
"active": "Active",
"disabled": "Disabled"
},
"errors": {
"networkError": "Network error \u2014 server not reachable.",
"sessionExpired": "Session expired.",
"requestFailed": "Request failed.",
"serverError": "Server error (HTTP {status}).",
"unknownError": "An unknown error occurred.",
"uploadFailed": "Upload failed.",
"deleteFailed": "Delete failed: {error}",
"failedToLoadSettings": "Failed to load settings: {error}",
"failed": "Failed: {error}",
"logoUploadFailed": "Logo upload failed: {error}",
"failedToRemoveLogo": "Failed to remove logo: {error}",
"updateFailed": "Update failed: {error}",
"passwordResetFailed": "Password reset failed: {error}",
"selectFileFirst": "Please select a file first.",
"passwordsDoNotMatch": "Passwords do not match.",
"failedToLoadCredentials": "Failed to load credentials: {error}",
"azureNotConfigured": "Azure AD is not configured.",
"azureLoginFailed": "Azure AD login failed: {error}",
"actionFailed": "{action} failed: {error}"
},
"messages": {
"systemSettingsSaved": "System settings saved.",
"npmSettingsSaved": "NPM settings saved.",
"imageSettingsSaved": "Image settings saved.",
"brandingNameSaved": "Branding settings saved.",
"logoUploaded": "Logo uploaded successfully.",
"logoRemoved": "Logo removed.",
"azureSettingsSaved": "Azure AD settings saved.",
"passwordChanged": "Password changed successfully.",
"setupUrlCopied": "Setup URL copied to clipboard.",
"copiedToClipboard": "Copied to clipboard.",
"userCreated": "User '{username}' created.",
"userDeleted": "User '{username}' deleted.",
"passwordResetFor": "Password reset for '{username}'.",
"newPasswordAlert": "New password for '{username}':\n\n{password}\n\nPlease save this password now. It will not be shown again.",
"confirmDeleteUser": "Delete user '{username}'? This cannot be undone.",
"confirmResetPassword": "Reset password for '{username}'? A new random password will be generated."
}
}