feat: Add A-Z filter for clients/suppliers in Telegram bot

- Add A-Z alphabetical filter keyboard for clients and suppliers lists
  (same pattern as company selection, without emoji)
- Increase clients/suppliers list pagination from 10 to 20 items per page
- Remove emoji from company A-Z filter button for consistency
- Add 6 new callback handlers: clients_alpha_menu, clients_alpha:LETTER,
  clients_alpha_page:PAGE:LETTER, and supplier equivalents
- Dashboard service and models updates
- Telegram bot: email handlers, auth, DB operations, internal API improvements
- Frontend: dashboard cards updates (CashFlow, Clienti, Furnizori, Treasury)
- Frontend: SolduriCompactCard and CollapsibleCard improvements
- DashboardView enhancements
- start.sh and run-with-restart.sh script updates
- IIS web.config and service worker updates

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-02-21 14:34:15 +00:00
parent 1366dbc11c
commit 30f55cf18b
28 changed files with 1671 additions and 520 deletions

View File

@@ -2,7 +2,7 @@
API Router pentru Telegram Bot Integration
Furnizează endpoint-uri pentru autentificare, linking și export rapoarte pentru Telegram bot
"""
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi import APIRouter, Body, Depends, HTTPException, Request
from typing import List, Optional, Dict, Any
# import sys # Removed - no longer needed
import os
@@ -57,6 +57,7 @@ class VerifyUserRequest(BaseModel):
oracle_username: Optional[str] = Field(default=None, description="Username Oracle (pentru auto-linking)")
username: Optional[str] = Field(default=None, description="Username pentru verificare completă")
password: Optional[str] = Field(default=None, description="Parolă pentru verificare completă")
server_id: Optional[str] = Field(default=None, description="Oracle server ID (pentru multi-server mode)")
class VerifyUserResponse(BaseModel):
@@ -100,6 +101,7 @@ class ExportReportResponse(BaseModel):
class VerifyEmailRequest(BaseModel):
"""Request pentru verificarea email-ului în Oracle"""
email: str = Field(description="Adresa de email Oracle")
server_id: Optional[str] = Field(default=None, description="Oracle server ID (pentru multi-server mode)")
class VerifyEmailResponse(BaseModel):
@@ -115,6 +117,7 @@ class TelegramEmailLoginRequest(BaseModel):
password: str = Field(description="Parola Oracle")
telegram_user_id: int = Field(description="ID-ul utilizatorului Telegram")
session_token: str = Field(description="Token de sesiune pentru preveni spoofing")
server_id: Optional[str] = Field(default=None, description="Oracle server ID (pentru multi-server mode)")
class TelegramEmailLoginResponse(BaseModel):
@@ -129,6 +132,21 @@ class TelegramEmailLoginResponse(BaseModel):
message: str = Field(description="Mesaj de status")
class SwitchServerRequest(BaseModel):
"""Request pentru schimbarea serverului Oracle"""
oracle_username: str = Field(description="Username Oracle al utilizatorului curent")
new_server_id: str = Field(description="ID-ul noului server Oracle")
oracle_password: Optional[str] = Field(default=None, description="Parola Oracle pe noul server (obligatorie dacă servere diferite au parole diferite)")
class SwitchServerResponse(BaseModel):
"""Response pentru schimbarea serverului Oracle"""
success: bool = Field(description="True dacă schimbarea a reușit")
access_token: Optional[str] = Field(default=None, description="Noul JWT access token")
refresh_token: Optional[str] = Field(default=None, description="Noul JWT refresh token")
message: str = Field(description="Mesaj de status")
# ==================== Helper Functions ====================
# Rate limiting storage (in-memory)
@@ -212,7 +230,7 @@ def generate_linking_code(length: int = 8) -> str:
return ''.join(secrets.choice(alphabet) for _ in range(length))
async def get_oracle_user_by_username(username: str) -> Optional[Dict[str, Any]]:
async def get_oracle_user_by_username(username: str, server_id: Optional[str] = None) -> Optional[Dict[str, Any]]:
"""
Obține informații despre utilizator din Oracle FĂRĂ verificare parolă.
@@ -221,12 +239,13 @@ async def get_oracle_user_by_username(username: str) -> Optional[Dict[str, Any]]
Args:
username: Username-ul utilizatorului Oracle
server_id: ID-ul serverului Oracle (pentru multi-server mode)
Returns:
Dict cu informații despre utilizator sau None dacă nu există
"""
try:
async with oracle_pool.get_connection() as connection:
async with oracle_pool.get_connection(server_id) as connection:
with connection.cursor() as cursor:
# Obține detalii utilizator
cursor.execute("""
@@ -270,19 +289,20 @@ async def get_oracle_user_by_username(username: str) -> Optional[Dict[str, Any]]
return None
async def verify_oracle_user(username: str, password: str) -> Optional[Dict[str, Any]]:
async def verify_oracle_user(username: str, password: str, server_id: Optional[str] = None) -> Optional[Dict[str, Any]]:
"""
Verifică utilizatorul în Oracle folosind pack_drepturi.verificautilizator
Args:
username: Username-ul utilizatorului
password: Parola utilizatorului
server_id: ID-ul serverului Oracle (pentru multi-server mode)
Returns:
Dict cu informații despre utilizator sau None dacă verificarea eșuează
"""
try:
async with oracle_pool.get_connection() as connection:
async with oracle_pool.get_connection(server_id) as connection:
with connection.cursor() as cursor:
# Verifică autentificarea
cursor.execute("""
@@ -344,6 +364,7 @@ async def verify_oracle_user(username: str, password: str) -> Optional[Dict[str,
@router.post("/auth/generate-code", response_model=GenerateCodeResponse)
async def generate_linking_code_endpoint(
request: Request,
current_user: CurrentUser = Depends(get_current_user)
):
"""
@@ -360,8 +381,12 @@ async def generate_linking_code_endpoint(
- Codul expiră după 15 minute
- Fiecare request generează un cod nou (codurile vechi devin invalide)
- Nu este nevoie de telegram_user_id în acest moment (utilizatorul nu e încă conectat la Telegram)
- server_id este extras automat din JWT (setat de auth middleware în request.state)
"""
try:
# Extrage server_id din JWT (setat de auth middleware)
server_id = getattr(request.state, 'server_id', None)
# Generează cod unic
linking_code = generate_linking_code()
@@ -378,7 +403,8 @@ async def generate_linking_code_endpoint(
"code": linking_code,
"telegram_user_id": 0, # Not known yet (user hasn't linked)
"oracle_username": current_user.username,
"expires_in_minutes": expires_in_minutes
"expires_in_minutes": expires_in_minutes,
"server_id": server_id
}
)
@@ -442,7 +468,7 @@ async def verify_user_endpoint(request: VerifyUserRequest):
try:
# Flow A: Auto-linking (oracle_username provided, no password)
if request.oracle_username and not request.password:
user_data = await get_oracle_user_by_username(request.oracle_username)
user_data = await get_oracle_user_by_username(request.oracle_username, server_id=request.server_id)
if not user_data:
return VerifyUserResponse(
@@ -452,7 +478,7 @@ async def verify_user_endpoint(request: VerifyUserRequest):
# Flow B: Full verification (username + password provided)
elif request.username and request.password:
user_data = await verify_oracle_user(request.username, request.password)
user_data = await verify_oracle_user(request.username, request.password, server_id=request.server_id)
if not user_data:
return VerifyUserResponse(
@@ -467,17 +493,19 @@ async def verify_user_endpoint(request: VerifyUserRequest):
message="Trebuie furnizat fie oracle_username (auto-linking) fie username+password (verificare completă)"
)
# Generează JWT tokens
# Generează JWT tokens (cu server_id pentru multi-server mode)
access_token = jwt_handler.create_access_token(
username=user_data['username'],
companies=user_data['companies'],
user_id=user_data['user_id'],
permissions=user_data['permissions']
permissions=user_data['permissions'],
server_id=request.server_id
)
refresh_token = jwt_handler.create_refresh_token(
username=user_data['username'],
user_id=user_data['user_id']
user_id=user_data['user_id'],
server_id=request.server_id
)
return VerifyUserResponse(
@@ -528,8 +556,8 @@ async def refresh_token_endpoint(request: RefreshTokenRequest):
detail="Refresh token invalid sau expirat"
)
# Obține companiile actualizate din Oracle
async with oracle_pool.get_connection() as connection:
# Obține companiile actualizate din Oracle (folosind server_id din refresh token)
async with oracle_pool.get_connection(token_data.server_id) as connection:
with connection.cursor() as cursor:
cursor.execute("""
SELECT A.ID_FIRMA
@@ -546,12 +574,13 @@ async def refresh_token_endpoint(request: RefreshTokenRequest):
companies_result = cursor.fetchall()
companies = [str(row[0]) for row in companies_result]
# Generează nou access token
# Generează nou access token (păstrează server_id din refresh token)
new_access_token = jwt_handler.create_access_token(
username=token_data.username,
companies=companies,
user_id=token_data.user_id,
permissions=token_data.permissions
permissions=token_data.permissions,
server_id=token_data.server_id
)
return RefreshTokenResponse(
@@ -580,7 +609,7 @@ async def verify_email_endpoint(request: VerifyEmailRequest):
Security: Generic error messages to prevent email enumeration.
"""
try:
async with oracle_pool.get_connection() as connection:
async with oracle_pool.get_connection(request.server_id) as connection:
with connection.cursor() as cursor:
# Query to find username by email
cursor.execute("""
@@ -649,7 +678,7 @@ async def login_with_email_endpoint(request: TelegramEmailLoginRequest):
)
try:
async with oracle_pool.get_connection() as connection:
async with oracle_pool.get_connection(request.server_id) as connection:
with connection.cursor() as cursor:
# 3. Find username by email
cursor.execute("""
@@ -719,18 +748,18 @@ async def login_with_email_endpoint(request: TelegramEmailLoginRequest):
# 6. Get user permissions (default for Telegram)
permissions = ['read', 'reports']
# 7. Generate JWT tokens
token_data = {
"username": username,
"user_id": user_id,
"companies": company_ids,
"permissions": permissions
}
access_token = jwt_handler.create_access_token(**token_data)
# 7. Generate JWT tokens (with server_id for multi-server routing)
access_token = jwt_handler.create_access_token(
username=username,
user_id=user_id,
companies=company_ids,
permissions=permissions,
server_id=request.server_id
)
refresh_token = jwt_handler.create_refresh_token(
username=username,
user_id=user_id
user_id=user_id,
server_id=request.server_id
)
return TelegramEmailLoginResponse(
@@ -754,6 +783,82 @@ async def login_with_email_endpoint(request: TelegramEmailLoginRequest):
)
@router.post("/auth/switch-server", response_model=SwitchServerResponse)
async def switch_server_endpoint(
request: SwitchServerRequest,
current_user: CurrentUser = Depends(get_current_user)
):
"""
Schimbă serverul Oracle activ fără re-autentificare.
Flux:
1. Verifică că oracle_username din request == username din JWT (anti-impersonare)
2. Verifică că utilizatorul există pe noul server (fără parolă, prin get_oracle_user_by_username)
3. Generează JWT nou cu noul server_id
4. Returnează tokenurile noi
Securitate: endpoint protejat cu Bearer JWT valid (Depends(get_current_user)).
"""
# Anti-impersonare: utilizatorul poate schimba serverul doar pentru propriul cont
if request.oracle_username.upper() != current_user.username.upper():
raise HTTPException(status_code=403, detail="Acces interzis: username nepotrivit")
# Verifică că utilizatorul există pe noul server
user_data = await get_oracle_user_by_username(request.oracle_username, request.new_server_id)
if not user_data:
return SwitchServerResponse(
success=False,
message=f"Utilizatorul nu există pe serverul {request.new_server_id}"
)
# Dacă parola e furnizată, verifică-o pe noul server înainte de a emite JWT
if request.oracle_password:
try:
async with oracle_pool.get_connection(request.new_server_id) as connection:
with connection.cursor() as cursor:
cursor.execute("""
SELECT pack_drepturi.verificautilizator(:username, :password)
FROM DUAL
""", {
"username": request.oracle_username.upper(),
"password": request.oracle_password
})
verification_result = cursor.fetchone()[0]
if verification_result == -1:
return SwitchServerResponse(
success=False,
message="Parolă incorectă pentru acest server"
)
except Exception as e:
logger.error(f"Password verification error during server switch: {e}")
return SwitchServerResponse(
success=False,
message="Eroare la verificarea parolei pe noul server"
)
# Generează JWT nou cu noul server_id
access_token = jwt_handler.create_access_token(
username=user_data['username'],
companies=user_data['companies'],
user_id=user_data['user_id'],
permissions=user_data['permissions'],
server_id=request.new_server_id
)
refresh_token = jwt_handler.create_refresh_token(
username=user_data['username'],
user_id=user_data['user_id'],
server_id=request.new_server_id
)
return SwitchServerResponse(
success=True,
access_token=access_token,
refresh_token=refresh_token,
message="Server schimbat cu succes"
)
@router.post("/export", response_model=ExportReportResponse)
async def export_report_endpoint(
request: ExportReportRequest,