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

@@ -14,6 +14,8 @@ import logging
from datetime import datetime
import asyncio
from typing import Optional
from backend.modules.telegram.auth.email_auth import (
is_valid_email_format,
verify_email_in_oracle,
@@ -22,6 +24,7 @@ from backend.modules.telegram.auth.email_auth import (
check_rate_limit,
clear_rate_limit
)
from shared.auth.email_server_cache import email_server_cache
from backend.modules.telegram.utils.email_service import get_email_service
from backend.modules.telegram.db.operations import (
create_email_auth_code,
@@ -39,7 +42,7 @@ from backend.modules.telegram.api.client import get_backend_client
logger = logging.getLogger(__name__)
# Conversation states
AWAITING_EMAIL, AWAITING_CODE, AWAITING_PASSWORD = range(3)
AWAITING_EMAIL, AWAITING_CODE, AWAITING_PASSWORD, AWAITING_SERVER_SELECTION = range(4)
# Constants
MAX_CODE_ATTEMPTS = 3
@@ -261,57 +264,25 @@ async def web_login_info_callback(update: Update, context: ContextTypes.DEFAULT_
# STATE: AWAITING_EMAIL
# ============================================================================
async def receive_email(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Handler pentru primirea email-ului"""
email = update.message.text.strip().lower()
user_id = update.effective_user.id
# ȘTERG mesajul utilizatorului imediat (chat curat)
async def _send_email_code(
context: ContextTypes.DEFAULT_TYPE,
chat_id: int,
email: str,
server_id: Optional[str],
user_id: int
) -> int:
"""
Generate and send email verification code on the specified server.
Returns AWAITING_CODE on success or ConversationHandler.END on failure.
"""
try:
await update.message.delete()
except Exception as e:
logger.warning(f"Could not delete email message: {e}")
# Validare format email
if not is_valid_email_format(email):
# Show error in main message
await edit_login_message(
context=context,
chat_id=update.effective_chat.id,
text="Email invalid\n\nIntrodu o adresă validă (nume@domeniu.ro)",
reply_markup=InlineKeyboardMarkup([
[InlineKeyboardButton("Anulează", callback_data="cancel")]
])
)
return AWAITING_EMAIL
# Check for existing pending code
existing_code = await get_pending_email_code(user_id)
if existing_code:
# Delete old pending code
await delete_user_email_codes(user_id)
logger.info(f"Deleted existing pending code for user {user_id}")
# EDIT login message to show loading
await edit_login_message(
context=context,
chat_id=update.effective_chat.id,
text="Verificare email...",
reply_markup=None
)
try:
# Verifică email în Oracle
username = await verify_email_in_oracle(email)
# Verify email in Oracle (on specific server if known)
username = await verify_email_in_oracle(email, server_id=server_id)
# IMPORTANT: Generic response to prevent email enumeration
# We always say "code sent" even if email doesn't exist
if username:
# Email exists - generate and send code
code = generate_email_code()
# Save code in database
code_saved = await create_email_auth_code(
code=code,
email=email,
@@ -323,27 +294,26 @@ async def receive_email(update: Update, context: ContextTypes.DEFAULT_TYPE):
if not code_saved:
await edit_login_message(
context=context,
chat_id=update.effective_chat.id,
chat_id=chat_id,
text="Eroare la salvarea codului.\n\nIncearcă din nou cu /login"
)
return ConversationHandler.END
# Send email (async with retry)
email_service = get_email_service()
email_sent = await email_service.send_auth_code(email, code, username)
if not email_sent:
logger.error(f"Failed to send email to {email}")
# Don't reveal this to user - they'll timeout naturally
if email_sent:
logger.info(f"[EMAIL-AUTH] ✅ Code sent for {email[:3]}***@*** (user {user_id}, server={server_id})")
else:
logger.error(f"[EMAIL-AUTH] ❌ Failed to send code (user {user_id}, server={server_id})")
# Wait 1 second for better UX (looks like verification happened)
# Wait 1 second for UX (looks like verification happened)
await asyncio.sleep(1)
# ALWAYS show this message (prevent enumeration)
# EDIT same message with success + buttons
# ALWAYS show success (prevent enumeration)
await edit_login_message(
context=context,
chat_id=update.effective_chat.id,
chat_id=chat_id,
text=f"Cod trimis pe {email}\n\nIntrodu codul primit pe email:",
reply_markup=InlineKeyboardMarkup([
[InlineKeyboardButton("Retrimite Cod", callback_data=f"resend:{email}")],
@@ -351,22 +321,135 @@ async def receive_email(update: Update, context: ContextTypes.DEFAULT_TYPE):
])
)
# Save email in context for resend functionality
context.user_data['pending_email'] = email
context.user_data['pending_username'] = username
return AWAITING_CODE
except Exception as e:
logger.error(f"Error in receive_email: {e}", exc_info=True)
logger.error(f"Error sending email code: {e}", exc_info=True)
await edit_login_message(
context=context,
chat_id=update.effective_chat.id,
chat_id=chat_id,
text="Eroare internă.\n\nIncearcă din nou mai târziu."
)
return ConversationHandler.END
async def receive_email(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Handler pentru primirea email-ului"""
email = update.message.text.strip().lower()
user_id = update.effective_user.id
chat_id = update.effective_chat.id
# ȘTERG mesajul utilizatorului imediat (chat curat)
try:
await update.message.delete()
except Exception as e:
logger.warning(f"Could not delete email message: {e}")
# Validare format email
if not is_valid_email_format(email):
await edit_login_message(
context=context,
chat_id=chat_id,
text="Email invalid\n\nIntrodu o adresă validă (nume@domeniu.ro)",
reply_markup=InlineKeyboardMarkup([
[InlineKeyboardButton("Anulează", callback_data="cancel")]
])
)
return AWAITING_EMAIL
# Clean up old pending codes
existing_code = await get_pending_email_code(user_id)
if existing_code:
await delete_user_email_codes(user_id)
logger.info(f"Deleted existing pending code for user {user_id}")
# Show loading
await edit_login_message(
context=context,
chat_id=chat_id,
text="Verificare email...",
reply_markup=None
)
# Check server cache for multi-server routing
try:
await email_server_cache.refresh_if_needed()
servers = email_server_cache.get_servers_for_email(email)
except Exception as e:
logger.warning(f"Could not check email server cache: {e}")
servers = []
if len(servers) > 1:
# Multiple servers — ask user to select before sending code
context.user_data['pending_email'] = email
try:
from backend.config import settings
keyboard = []
for srv_id in servers:
srv = settings.get_oracle_server(srv_id)
srv_name = srv.name if srv else srv_id
keyboard.append([InlineKeyboardButton(srv_name, callback_data=f"select_server:{srv_id}")])
except Exception:
# Fallback: use server IDs as labels
keyboard = [
[InlineKeyboardButton(srv_id, callback_data=f"select_server:{srv_id}")]
for srv_id in servers
]
keyboard.append([InlineKeyboardButton("Anulează", callback_data="cancel")])
await edit_login_message(
context=context,
chat_id=chat_id,
text="Email identificat pe mai multe servere.\n\nSelectează serverul pentru autentificare:",
reply_markup=InlineKeyboardMarkup(keyboard)
)
return AWAITING_SERVER_SELECTION
# Single server or no cache hit — proceed directly
server_id = servers[0] if servers else None
context.user_data['server_id'] = server_id
return await _send_email_code(context, chat_id, email, server_id, user_id)
# ============================================================================
# STATE: AWAITING_SERVER_SELECTION
# ============================================================================
async def handle_server_selected(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Callback pentru selectarea serverului Oracle (mod multi-server)"""
query = update.callback_query
user_id = update.effective_user.id
chat_id = update.effective_chat.id
await query.answer()
# Extract server_id from callback data: "select_server:<id>"
server_id = query.data.split(":", 1)[1]
email = context.user_data.get('pending_email')
if not email:
await edit_login_message(
context=context,
chat_id=chat_id,
text="Sesiune expirată\n\nIncearcă din nou cu /login"
)
return ConversationHandler.END
# Save selected server to context
context.user_data['server_id'] = server_id
logger.info(f"[EMAIL-AUTH] User {user_id} selected server '{server_id}' for {email[:3]}***")
# Clean up old pending codes then send code on selected server
await delete_user_email_codes(user_id)
return await _send_email_code(context, chat_id, email, server_id, user_id)
# ============================================================================
# STATE: AWAITING_CODE
# ============================================================================
@@ -593,12 +676,14 @@ async def receive_password(update: Update, context: ContextTypes.DEFAULT_TYPE):
try:
# Call backend endpoint pentru verificare parolă + JWT
backend_client = get_backend_client()
server_id = context.user_data.get('server_id')
response = await backend_client.login_with_email(
email=email,
password=password,
telegram_user_id=user_id,
session_token=session_token
session_token=session_token,
server_id=server_id
)
if not response.get('success'):
@@ -749,6 +834,9 @@ email_login_handler = ConversationHandler(
AWAITING_EMAIL: [
MessageHandler(filters.TEXT & ~filters.COMMAND, receive_email)
],
AWAITING_SERVER_SELECTION: [
CallbackQueryHandler(handle_server_selected, pattern='^select_server:')
],
AWAITING_CODE: [
MessageHandler(filters.TEXT & ~filters.COMMAND, receive_code),
CallbackQueryHandler(resend_code_callback, pattern='^resend:')