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

@@ -80,23 +80,71 @@ class UserAuthService:
'timestamp': datetime.now()
}
logger.debug(f"Cached data for user {username}")
async def verify_user_credentials(self, username: str, password: str) -> bool:
async def get_username_by_email(
self,
email: str,
server_id: Optional[str] = None
) -> Optional[str]:
"""
Obține username-ul Oracle corespunzător unui email.
Necesar pentru login cu email - convertește email-ul în username-ul
real din tabelul UTILIZATORI pentru autentificare cu pack_drepturi.
Args:
email: Email-ul utilizatorului
server_id: ID-ul serverului Oracle (pentru multi-server mode)
Returns:
Username-ul Oracle sau None dacă email-ul nu există
"""
try:
async with oracle_pool.get_connection(server_id) as connection:
with connection.cursor() as cursor:
cursor.execute("""
SELECT UTILIZATOR
FROM CONTAFIN_ORACLE.UTILIZATORI
WHERE LOWER(EMAIL) = :email
AND INACTIV = 0
AND STERS = 0
""", {'email': email.lower().strip()})
row = cursor.fetchone()
if row:
username = row[0]
logger.info(f"Resolved email '{email}' to username '{username}' on server '{server_id}'")
return username
else:
logger.warning(f"No username found for email '{email}' on server '{server_id}'")
return None
except Exception as e:
logger.error(f"Database error resolving email '{email}' to username: {str(e)}")
return None
async def verify_user_credentials(
self,
username: str,
password: str,
server_id: Optional[str] = None
) -> bool:
"""
Verifică credențialele utilizatorului folosind pack_drepturi.verificautilizator
Args:
username: Numele utilizatorului
password: Parola utilizatorului
server_id: ID-ul serverului Oracle (opțional, pentru multi-server mode)
Returns:
True dacă credențialele sunt corecte, False altfel
Raises:
AuthenticationError: Dacă apar erori în procesul de verificare
"""
try:
async with oracle_pool.get_connection() as connection:
async with oracle_pool.get_connection(server_id) as connection:
with connection.cursor() as cursor:
# Apelarea procedurii pack_drepturi.verificautilizator
# Această procedură returnează ID-ul utilizatorului cu checksum pentru succes, -1 pentru eșec
@@ -110,7 +158,10 @@ class UserAuthService:
result = cursor.fetchone()
verification_result = result[0] if result else -1
# DEBUG: Log the exact result from Oracle
logger.info(f"[DEBUG] verificautilizator('{username.upper()}', '***') on server '{server_id}' = {verification_result}")
# Interpretarea rezultatului conform logicii VFP:
# -1 = invalid credentials
# > 0 = valid user ID with checksum
@@ -136,27 +187,33 @@ class UserAuthService:
logger.error(f"Database error during authentication for user {username}: {str(e)}")
raise AuthenticationError(f"Database authentication error: {str(e)}")
async def get_user_companies(self, username: str) -> List[str]:
async def get_user_companies(
self,
username: str,
server_id: Optional[str] = None
) -> List[str]:
"""
Obține lista firmelor la care utilizatorul are acces din V_NOM_FIRME
folosind ID-ul utilizatorului din UTILIZATORI
Args:
username: Numele utilizatorului
server_id: ID-ul serverului Oracle (opțional, pentru multi-server mode)
Returns:
Lista codurilor firmelor la care utilizatorul are acces
Raises:
AuthenticationError: Dacă apar erori în procesul de obținere
"""
# Verifică cache-ul mai întâi
cached_data = self._get_cached_user_data(username)
# Verifică cache-ul mai întâi (include server_id în cheie pentru multi-server)
cache_key_suffix = f"_{server_id}" if server_id else ""
cached_data = self._get_cached_user_data(f"{username}{cache_key_suffix}")
if cached_data and 'companies' in cached_data:
return cached_data['companies']
try:
async with oracle_pool.get_connection() as connection:
async with oracle_pool.get_connection(server_id) as connection:
with connection.cursor() as cursor:
try:
# Debug: să vedem ce utilizatori există în tabela UTILIZATORI
@@ -222,85 +279,111 @@ class UserAuthService:
# În caz de eroare, returnăm listă goală în loc de TEST_COMPANY
return []
# Cache rezultatul
self._cache_user_data(username, {'companies': companies})
# Cache rezultatul (include server_id pentru multi-server)
cache_key = f"{username}{cache_key_suffix}"
self._cache_user_data(cache_key, {'companies': companies})
return companies
except Exception as e:
logger.error(f"Database error getting companies for user {username}: {str(e)}")
raise AuthenticationError(f"Error retrieving user companies: {str(e)}")
async def get_user_permissions(self, username: str, company: str) -> List[str]:
async def get_user_permissions(
self,
username: str,
company: str,
server_id: Optional[str] = None
) -> List[str]:
"""
Obține permisiunile utilizatorului pentru o anumită firmă
Args:
username: Numele utilizatorului
company: Codul firmei
server_id: ID-ul serverului Oracle (pentru multi-server mode)
Returns:
Lista permisiunilor pentru firma specificată
"""
# Implementare de bază - poate fi extinsă în viitor
companies = await self.get_user_companies(username)
companies = await self.get_user_companies(username, server_id)
# Dacă nu există companii sau compania nu este în listă, returnează permisiuni minime
if not companies or company not in companies:
return ["read"] if not companies else []
# Pentru moment, toți utilizatorii autentificați au permisiuni de citire
# Acest sistem poate fi extins cu permisiuni granulare în viitor
return ["read", "reports"]
async def authenticate_and_create_tokens(
self,
username: str,
password: str
self,
username: str,
password: str,
server_id: Optional[str] = None
) -> Tuple[bool, Optional[TokenResponse], Optional[str]]:
"""
Autentifică utilizatorul și creează token-urile JWT
Suportă atât username clasic cât și email pentru login.
Dacă input-ul conține '@', se tratează ca email și se convertește
în username-ul Oracle corespunzător.
Args:
username: Numele utilizatorului
username: Numele utilizatorului sau email-ul
password: Parola utilizatorului
server_id: ID-ul serverului Oracle (opțional, pentru multi-server mode)
Returns:
Tuple cu (success, token_response, error_message)
"""
try:
# Verifică credențialele
is_valid = await self.verify_user_credentials(username, password)
# Detectăm dacă input-ul este email sau username clasic
actual_username = username
if '@' in username:
# Este email - convertim în username Oracle
resolved_username = await self.get_username_by_email(username, server_id)
if not resolved_username:
logger.warning(f"Could not resolve email '{username}' to username on server '{server_id}'")
return False, None, "Invalid username or password"
actual_username = resolved_username
logger.info(f"Login with email '{username}' resolved to username '{actual_username}'")
# Verifică credențialele pe serverul specificat
is_valid = await self.verify_user_credentials(actual_username, password, server_id)
if not is_valid:
return False, None, "Invalid username or password"
# Obține firmele utilizatorului
companies = await self.get_user_companies(username)
# Obține firmele utilizatorului de pe serverul specificat
companies = await self.get_user_companies(actual_username, server_id)
# Nu blocăm login-ul dacă utilizatorul nu are firme - îl lăsăm să vadă mesajul în frontend
if not companies:
logger.info(f"User {username} has no companies assigned - allowing login but with empty companies list")
logger.info(f"User {actual_username} has no companies assigned - allowing login but with empty companies list")
# Obține permisiunile (pentru prima firmă ca default sau lista goală)
permissions = await self.get_user_permissions(username, companies[0] if companies else "")
permissions = await self.get_user_permissions(actual_username, companies[0] if companies else "", server_id)
# Creează token-urile folosind jwt_handler
# Include server_id în JWT pentru ca request-urile ulterioare să știe pe care server să execute query-uri
jwt_tokens = jwt_handler.create_token_response(
username=username,
username=actual_username,
companies=companies,
user_id=None, # Poate fi adăugat în viitor dacă avem user_id în DB
permissions=permissions
permissions=permissions,
server_id=server_id
)
# Creează obiectul CurrentUser
current_user = CurrentUser(
username=username,
username=actual_username,
user_id=None,
companies=companies,
permissions=permissions
)
# Creează TokenResponse-ul complet cu user info
token_response = TokenResponse(
access_token=jwt_tokens.access_token,
@@ -309,10 +392,10 @@ class UserAuthService:
expires_in=jwt_tokens.expires_in,
user=current_user
)
logger.info(f"Successfully created tokens for user {username}")
logger.info(f"Successfully created tokens for user {actual_username} on server {server_id or 'default'}")
return True, token_response, None
except AuthenticationError as e:
logger.error(f"Authentication error for user {username}: {str(e)}")
return False, None, str(e)