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:
@@ -30,6 +30,7 @@ class AppConfig:
|
||||
data_dir: str
|
||||
docker_network: str
|
||||
relay_base_port: int
|
||||
dashboard_base_port: int
|
||||
|
||||
|
||||
# Environment-level settings (not stored in DB)
|
||||
@@ -77,4 +78,5 @@ def get_system_config(db: Session) -> Optional[AppConfig]:
|
||||
data_dir=row.data_dir,
|
||||
docker_network=row.docker_network,
|
||||
relay_base_port=row.relay_base_port,
|
||||
dashboard_base_port=getattr(row, "dashboard_base_port", 9000) or 9000,
|
||||
)
|
||||
|
||||
@@ -89,3 +89,16 @@ def generate_relay_secret() -> str:
|
||||
A 32-character hex string.
|
||||
"""
|
||||
return secrets.token_hex(16)
|
||||
|
||||
|
||||
def generate_datastore_encryption_key() -> str:
|
||||
"""Generate a base64-encoded 32-byte key for NetBird DataStoreEncryptionKey.
|
||||
|
||||
NetBird management (Go) expects standard base64 decoding to exactly 32 bytes.
|
||||
|
||||
Returns:
|
||||
A standard base64-encoded string representing 32 random bytes.
|
||||
"""
|
||||
import base64
|
||||
|
||||
return base64.b64encode(secrets.token_bytes(32)).decode()
|
||||
|
||||
@@ -109,6 +109,14 @@ class SystemConfigUpdate(BaseModel):
|
||||
data_dir: Optional[str] = Field(None, max_length=500)
|
||||
docker_network: Optional[str] = Field(None, max_length=100)
|
||||
relay_base_port: Optional[int] = Field(None, ge=1024, le=65535)
|
||||
dashboard_base_port: Optional[int] = Field(None, ge=1024, le=65535)
|
||||
branding_name: Optional[str] = Field(None, max_length=255)
|
||||
branding_subtitle: Optional[str] = Field(None, max_length=255)
|
||||
default_language: Optional[str] = Field(None, max_length=10)
|
||||
azure_enabled: Optional[bool] = None
|
||||
azure_tenant_id: Optional[str] = Field(None, max_length=255)
|
||||
azure_client_id: Optional[str] = Field(None, max_length=255)
|
||||
azure_client_secret: Optional[str] = None # encrypted before storage
|
||||
|
||||
@field_validator("base_domain")
|
||||
@classmethod
|
||||
@@ -143,6 +151,54 @@ class SystemConfigUpdate(BaseModel):
|
||||
return v.lower().strip()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Users
|
||||
# ---------------------------------------------------------------------------
|
||||
class UserCreate(BaseModel):
|
||||
"""Payload to create a new local user."""
|
||||
|
||||
username: str = Field(..., min_length=3, max_length=100)
|
||||
password: str = Field(..., min_length=8, max_length=128)
|
||||
email: Optional[str] = Field(None, max_length=255)
|
||||
default_language: Optional[str] = Field(None, max_length=10)
|
||||
|
||||
@field_validator("username")
|
||||
@classmethod
|
||||
def validate_username(cls, v: str) -> str:
|
||||
if not re.match(r"^[a-zA-Z0-9_.-]+$", v):
|
||||
raise ValueError("Username may only contain letters, digits, dots, hyphens, and underscores.")
|
||||
return v.strip()
|
||||
|
||||
@field_validator("email")
|
||||
@classmethod
|
||||
def validate_user_email(cls, v: Optional[str]) -> Optional[str]:
|
||||
if v is None:
|
||||
return v
|
||||
pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
|
||||
if not re.match(pattern, v):
|
||||
raise ValueError("Invalid email address.")
|
||||
return v.lower().strip()
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
"""Payload to update an existing user."""
|
||||
|
||||
email: Optional[str] = Field(None, max_length=255)
|
||||
is_active: Optional[bool] = None
|
||||
role: Optional[str] = Field(None, max_length=20)
|
||||
default_language: Optional[str] = Field(None, max_length=10)
|
||||
|
||||
@field_validator("email")
|
||||
@classmethod
|
||||
def validate_user_email(cls, v: Optional[str]) -> Optional[str]:
|
||||
if v is None:
|
||||
return v
|
||||
pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
|
||||
if not re.match(pattern, v):
|
||||
raise ValueError("Invalid email address.")
|
||||
return v.lower().strip()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Query params
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user