feat: multi-Oracle server support with runtime switching

Complete implementation of multi-server Oracle database support:

Backend:
- Multi-pool Oracle with lazy loading per server
- Email-to-server cache for automatic server discovery
- JWT tokens include server_id claim
- /auth/check-identity and /auth/check-email endpoints
- /auth/my-servers endpoint for listing user's accessible servers
- Server switch with password re-authentication

Frontend:
- New ServerSelector component for header dropdown
- Multi-step login flow (identity → server → password)
- Server switching from header with password modal
- Mobile drawer menu with server selection
- Dark mode support for all new components
- URL bookmark support with ?server= query param

Scripts:
- Unified start.sh replacing start-prod.sh/start-test.sh
- Unified ssh-tunnel.sh with multi-server support
- Updated status.sh for new architecture

Tests:
- E2E tests for multi-server and single-server login flows
- Backend unit tests for all new endpoints
- Oracle multi-pool integration tests

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-01-26 22:39:06 +00:00
parent 5f99ee2fd0
commit b137e80b71
102 changed files with 9398 additions and 2787 deletions

View File

@@ -4,11 +4,37 @@ Consolidates settings from Reports, Data Entry, and Telegram modules
"""
import os
import json
import logging
from pathlib import Path
from typing import List
from typing import List, Optional
from pydantic_settings import BaseSettings
from pydantic import BaseModel
from functools import lru_cache
logger = logging.getLogger(__name__)
class OracleServerConfig(BaseModel):
"""Configuration for a single Oracle server instance."""
id: str # Unique identifier (e.g., "romfast", "client_a")
name: str # Human-readable name (e.g., "Romfast - Producție")
host: str = "localhost"
port: int = 1521
user: str
password: str
sid: Optional[str] = None
service_name: Optional[str] = None
def get_dsn(self) -> str:
"""Build DSN string for this server."""
if self.service_name:
return f"{self.host}:{self.port}/{self.service_name}"
elif self.sid:
return f"{self.host}:{self.port}:{self.sid}"
else:
return f"{self.host}:{self.port}/ROA"
class UnifiedSettings(BaseSettings):
"""Unified application settings for all modules."""
@@ -25,12 +51,105 @@ class UnifiedSettings(BaseSettings):
# ============================================================================
# ORACLE DATABASE (Shared by all modules)
# ============================================================================
# Legacy single-server configuration (backward compatible)
oracle_user: str = ""
oracle_password: str = ""
oracle_host: str = "localhost"
oracle_port: int = 1526
oracle_sid: str = "ROA"
# ============================================================================
# MULTI-ORACLE SERVER CONFIGURATION (Optional)
# ============================================================================
# JSON array of server configs. If not set, uses legacy single-server config.
# Example: ORACLE_SERVERS='[{"id": "romfast", "name": "Romfast", "host": "localhost", "port": 1521, "user": "USER", "password": "PASS", "sid": "ROA"}]'
oracle_servers: Optional[str] = None # Raw JSON string from env
# Parsed server configurations (populated in model_post_init)
_oracle_servers_parsed: List[OracleServerConfig] = []
def model_post_init(self, __context) -> None:
"""Parse ORACLE_SERVERS JSON and build server list.
Oracle passwords are loaded from:
1. secrets/{server_id}.oracle_pass file (preferred, more secure)
2. password field in ORACLE_SERVERS JSON (fallback)
"""
servers = []
secrets_dir = Path(__file__).parent / "secrets"
if self.oracle_servers:
# Parse multi-server JSON configuration
try:
servers_data = json.loads(self.oracle_servers)
if not isinstance(servers_data, list):
raise ValueError("ORACLE_SERVERS must be a JSON array")
for server_data in servers_data:
server_id = server_data.get("id", "default")
# Try to load password from secrets file
pass_file = secrets_dir / f"{server_id}.oracle_pass"
if pass_file.exists():
server_data["password"] = pass_file.read_text().strip()
logger.debug(f"Loaded Oracle password for '{server_id}' from {pass_file}")
elif "password" not in server_data:
logger.warning(f"No password found for server '{server_id}' - check secrets/{server_id}.oracle_pass")
servers.append(OracleServerConfig(**server_data))
logger.info(f"Loaded {len(servers)} Oracle servers from ORACLE_SERVERS config")
for srv in servers:
logger.info(f" - {srv.id}: {srv.name} ({srv.host}:{srv.port})")
except json.JSONDecodeError as e:
logger.error(f"Failed to parse ORACLE_SERVERS JSON: {e}")
raise ValueError(f"Invalid ORACLE_SERVERS JSON format: {e}")
else:
# Backward compatibility: build default server from legacy config
if self.oracle_user:
# Try to load password from secrets file
password = self.oracle_password
pass_file = secrets_dir / "default.oracle_pass"
if pass_file.exists():
password = pass_file.read_text().strip()
logger.debug(f"Loaded Oracle password from {pass_file}")
default_server = OracleServerConfig(
id="default",
name="Default Server",
host=self.oracle_host,
port=self.oracle_port,
user=self.oracle_user,
password=password,
sid=self.oracle_sid,
)
servers.append(default_server)
logger.info("Using legacy single-server Oracle configuration (ORACLE_USER/HOST/etc)")
logger.info(f" - default: {default_server.host}:{default_server.port}/{default_server.sid}")
object.__setattr__(self, '_oracle_servers_parsed', servers)
def get_oracle_servers(self) -> List[OracleServerConfig]:
"""Get list of configured Oracle servers."""
return self._oracle_servers_parsed
def get_oracle_server(self, server_id: str) -> Optional[OracleServerConfig]:
"""Get a specific Oracle server by ID."""
for server in self._oracle_servers_parsed:
if server.id == server_id:
return server
return None
def get_default_oracle_server(self) -> Optional[OracleServerConfig]:
"""Get the default Oracle server (first in list or 'default')."""
if not self._oracle_servers_parsed:
return None
# Try to find server with id='default', otherwise return first
for server in self._oracle_servers_parsed:
if server.id == "default":
return server
return self._oracle_servers_parsed[0]
# ============================================================================
# JWT AUTHENTICATION (Shared by all modules)
# ============================================================================