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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user