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

@@ -5,9 +5,11 @@ There is no .env file. Every setting lives in the ``system_config`` table
"""
import logging
import os
import shutil
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, status
from sqlalchemy.orm import Session
from app.database import get_db
@@ -21,6 +23,10 @@ from app.utils.validators import SystemConfigUpdate
logger = logging.getLogger(__name__)
router = APIRouter()
UPLOAD_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "static", "uploads")
MAX_LOGO_SIZE = 512 * 1024 # 500 KB
ALLOWED_LOGO_TYPES = {"image/png", "image/jpeg", "image/svg+xml"}
@router.get("/system")
async def get_settings(
@@ -75,6 +81,11 @@ async def update_settings(
raw_password = update_data.pop("npm_api_password")
row.npm_api_password_encrypted = encrypt_value(raw_password)
# Handle Azure client secret encryption
if "azure_client_secret" in update_data:
raw_secret = update_data.pop("azure_client_secret")
row.azure_client_secret_encrypted = encrypt_value(raw_secret)
for field, value in update_data.items():
if hasattr(row, field):
setattr(row, field, value)
@@ -116,3 +127,85 @@ async def test_npm(
config.npm_api_url, config.npm_api_email, config.npm_api_password
)
return result
@router.get("/branding")
async def get_branding(db: Session = Depends(get_db)):
"""Public endpoint — returns branding info for the login page (no auth required)."""
row = db.query(SystemConfig).filter(SystemConfig.id == 1).first()
if not row:
return {
"branding_name": "NetBird MSP Appliance",
"branding_subtitle": "Multi-Tenant Management Platform",
"branding_logo_path": None,
"default_language": "en",
}
return {
"branding_name": row.branding_name or "NetBird MSP Appliance",
"branding_subtitle": row.branding_subtitle or "Multi-Tenant Management Platform",
"branding_logo_path": row.branding_logo_path,
"default_language": row.default_language or "en",
}
@router.post("/branding/logo")
async def upload_logo(
file: UploadFile = File(...),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Upload a branding logo image (PNG, JPG, SVG, max 500KB)."""
if file.content_type not in ALLOWED_LOGO_TYPES:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"File type '{file.content_type}' not allowed. Use PNG, JPG, or SVG.",
)
content = await file.read()
if len(content) > MAX_LOGO_SIZE:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"File too large ({len(content)} bytes). Maximum is {MAX_LOGO_SIZE} bytes.",
)
os.makedirs(UPLOAD_DIR, exist_ok=True)
ext_map = {"image/png": ".png", "image/jpeg": ".jpg", "image/svg+xml": ".svg"}
ext = ext_map.get(file.content_type, ".png")
filename = f"logo{ext}"
filepath = os.path.join(UPLOAD_DIR, filename)
with open(filepath, "wb") as f:
f.write(content)
logo_url = f"/static/uploads/{filename}"
row = db.query(SystemConfig).filter(SystemConfig.id == 1).first()
if row:
row.branding_logo_path = logo_url
row.updated_at = datetime.utcnow()
db.commit()
logger.info("Logo uploaded by %s: %s", current_user.username, logo_url)
return {"branding_logo_path": logo_url}
@router.delete("/branding/logo")
async def delete_logo(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Remove the branding logo and reset to default icon."""
row = db.query(SystemConfig).filter(SystemConfig.id == 1).first()
if row and row.branding_logo_path:
old_path = os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(__file__))),
row.branding_logo_path.lstrip("/"),
)
if os.path.isfile(old_path):
os.remove(old_path)
row.branding_logo_path = None
row.updated_at = datetime.utcnow()
db.commit()
return {"branding_logo_path": None}