feat: fix registration 500, add forgot-password flow, frontend validation
Some checks failed
Build and Deploy / Backend Tests (push) Has been cancelled
Build and Deploy / Frontend Build Check (push) Has been cancelled
Build and Deploy / Build Docker Images (push) Has been cancelled
Build and Deploy / Deploy to Server (push) Has been cancelled

- Fix MissingGreenlet: sync_engine now uses psycopg2 instead of asyncpg
- Fix bcrypt/passlib compat: pin bcrypt<4.1 in requirements
- Fix legacy password_hash NOT NULL: alter column to nullable in migration
- Add frontend password validation (uppercase + lowercase + digit)
- Add forgot-password and reset-password backend endpoints
- Add forgot-password and reset-password frontend pages
- Add email_service.py (SMTP via admin settings)
- Add reset_token/reset_token_expires columns to User model
- Migrate legacy JSON-only users to DB on password reset request
- Mount data/ volume in docker-compose.local.yml for persistence
- Add production deployment config (Dockerfile, nginx, deploy.sh)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Sepehr Ramezani
2026-05-01 16:23:51 +02:00
parent 26bd096a06
commit 2f7347b4db
25 changed files with 2765 additions and 64 deletions

View File

@@ -789,6 +789,16 @@ class ProviderSettings(BaseModel):
max_retries: int = 3
class SmtpSettings(BaseModel):
enabled: bool = False
host: Optional[str] = None # SMTP_HOST
port: int = 587 # SMTP_PORT
username: Optional[str] = None # SMTP_USERNAME
password: Optional[str] = None # SMTP_PASSWORD
from_email: Optional[str] = None # SMTP_FROM_EMAIL
use_tls: bool = True
class SettingsConfig(BaseModel):
google: ProviderSettings = ProviderSettings(enabled=True)
google_cloud: ProviderSettings = ProviderSettings() # Cloud Translation API v2 (clé API)
@@ -798,6 +808,7 @@ class SettingsConfig(BaseModel):
openrouter: ProviderSettings = ProviderSettings() # "Traduction IA Essentielle"
openrouter_premium: ProviderSettings = ProviderSettings() # "Traduction IA Premium"
zai: ProviderSettings = ProviderSettings()
smtp: SmtpSettings = SmtpSettings()
fallback_chain: str = "google,google_cloud,deepl,openai,ollama,openrouter,openrouter_premium,zai"
fallback_chain_classic: str = "google,deepl"
fallback_chain_llm: str = "openrouter,openrouter_premium,openai,zai,ollama"
@@ -868,6 +879,26 @@ async def get_settings(admin_id: str = Depends(require_admin)):
payload["ollama"] = _merge_env(settings.ollama, url_env="OLLAMA_BASE_URL", model_env="OLLAMA_MODEL", default_url="http://localhost:11434", default_model="llama3")
payload["google_cloud"] = _merge_env(settings.google_cloud, key_env="GOOGLE_CLOUD_API_KEY")
# SMTP: merge from env vars, but never expose password
smtp_data = settings.smtp.model_dump()
if not smtp_data["host"]:
smtp_data["host"] = os.getenv("SMTP_HOST", "").strip() or None
if not smtp_data["username"]:
smtp_data["username"] = os.getenv("SMTP_USERNAME", "").strip() or None
smtp_data["password"] = None # never expose
if not smtp_data["from_email"]:
smtp_data["from_email"] = os.getenv("SMTP_FROM_EMAIL", "").strip() or None
smtp_env_port = os.getenv("SMTP_PORT", "").strip()
if smtp_env_port and smtp_data["port"] == 587:
try:
smtp_data["port"] = int(smtp_env_port)
except ValueError:
pass
smtp_env_tls = os.getenv("SMTP_USE_TLS", "").strip().lower()
if smtp_env_tls in ("false", "0", "no"):
smtp_data["use_tls"] = False
payload["smtp"] = smtp_data
# Inform the frontend which providers have API keys configured via env vars
# (boolean only — never expose actual values)
has_openrouter = bool(os.getenv("OPENROUTER_API_KEY", "").strip())
@@ -879,6 +910,7 @@ async def get_settings(admin_id: str = Depends(require_admin)):
"zai": bool(os.getenv("ZAI_API_KEY", "").strip()),
"ollama": bool(os.getenv("OLLAMA_BASE_URL", "").strip()),
"google_cloud": bool(os.getenv("GOOGLE_CLOUD_API_KEY", "").strip()),
"smtp": bool(os.getenv("SMTP_HOST", "").strip()),
}
return JSONResponse(
status_code=200,
@@ -890,6 +922,14 @@ async def get_settings(admin_id: str = Depends(require_admin)):
async def update_settings(
settings: SettingsConfig, admin_id: str = Depends(require_admin)
):
# Preserve SMTP password: frontend always sends null (never exposed via GET)
existing = load_settings()
if settings.smtp.password is None and existing.smtp.password:
settings.smtp.password = existing.smtp.password
# If frontend sends a new non-empty password, keep it; if empty string, clear it
if settings.smtp.password is not None:
settings.smtp.password = settings.smtp.password.strip() or None
save_settings(settings)
logger.info(f"admin_settings_updated by {admin_id}")
return JSONResponse(
@@ -897,10 +937,25 @@ async def update_settings(
)
class SmtpTestRequest(BaseModel):
"""Optional body for SMTP test — allows testing unsaved form values."""
host: Optional[str] = None
port: Optional[int] = None
username: Optional[str] = None
password: Optional[str] = None
from_email: Optional[str] = None
use_tls: Optional[bool] = None
@router.post("/providers/{provider}/test")
async def test_provider(provider: str, admin_id: str = Depends(require_admin)):
async def test_provider(
provider: str,
admin_id: str = Depends(require_admin),
smtp_body: Optional[SmtpTestRequest] = None,
):
"""Test a provider connection. Works even when provider is disabled.
Always falls back to env vars when the JSON api_key is empty."""
Always falls back to env vars when the JSON api_key is empty.
For SMTP, accepts an optional JSON body with current form values."""
settings = load_settings()
provider_config = getattr(settings, provider, None)
@@ -1074,6 +1129,54 @@ async def test_provider(provider: str, admin_id: str = Depends(require_admin)):
content={"available": False, "error": "Clé xAI invalide"},
)
elif provider == "smtp":
import smtplib as _smtplib
# Priority: request body (form values) > saved settings > env vars
host = ((smtp_body and smtp_body.host) or "").strip() or (provider_config.host or "").strip() or os.getenv("SMTP_HOST", "").strip()
if not host:
return JSONResponse(
status_code=400,
content={"available": False, "error": "Aucun hôte SMTP configuré (JSON ou .env SMTP_HOST)"},
)
port = (smtp_body and smtp_body.port) or provider_config.port or 587
try:
port = int(os.getenv("SMTP_PORT", "").strip() or port)
except ValueError:
port = 587
username = ((smtp_body and smtp_body.username) or "").strip() or (provider_config.username or "").strip() or os.getenv("SMTP_USERNAME", "").strip()
password = ((smtp_body and smtp_body.password) or "").strip() or (provider_config.password or "").strip() or os.getenv("SMTP_PASSWORD", "").strip()
use_tls = (smtp_body and smtp_body.use_tls) if (smtp_body and smtp_body.use_tls is not None) else (provider_config.use_tls if provider_config.use_tls is not None else True)
try:
server = _smtplib.SMTP(host, port, timeout=10)
server.ehlo()
if use_tls:
server.starttls()
server.ehlo()
if username and password:
server.login(username, password)
server.quit()
return JSONResponse(
status_code=200,
content={"available": True, "test_result": f"Connexion SMTP OK ({host}:{port})"},
)
except _smtplib.SMTPAuthenticationError as e:
return JSONResponse(
status_code=401,
content={"available": False, "error": f"Authentification SMTP échouée: {e}"},
)
except _smtplib.SMTPConnectError as e:
return JSONResponse(
status_code=502,
content={"available": False, "error": f"Connexion SMTP échouée: {e}"},
)
except Exception as e:
return JSONResponse(
status_code=500,
content={"available": False, "error": f"Erreur SMTP: {str(e)[:200]}"},
)
else:
return JSONResponse(
status_code=404,
@@ -1087,21 +1190,101 @@ async def test_provider(provider: str, admin_id: str = Depends(require_admin)):
)
@router.post("/providers/smtp/test-send")
async def test_send_email(
smtp_body: Optional[SmtpTestRequest] = None,
admin_id: str = Depends(require_admin),
):
"""Envoie un email de test a l'adresse from_email configuree.
Accepte un body JSON optionnel avec les valeurs du formulaire en cours."""
import smtplib as _smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
settings = load_settings()
smtp = settings.smtp
# Priority: request body (form values) > saved settings > env vars
host = ((smtp_body and smtp_body.host) or "").strip() or (smtp.host or "").strip() or os.getenv("SMTP_HOST", "").strip()
if not host:
return JSONResponse(
status_code=400,
content={"available": False, "error": "Aucun hôte SMTP configuré"},
)
port = (smtp_body and smtp_body.port) or smtp.port or 587
try:
port = int(os.getenv("SMTP_PORT", "").strip() or port)
except ValueError:
port = 587
username = ((smtp_body and smtp_body.username) or "").strip() or (smtp.username or "").strip() or os.getenv("SMTP_USERNAME", "").strip()
password = ((smtp_body and smtp_body.password) or "").strip() or (smtp.password or "").strip() or os.getenv("SMTP_PASSWORD", "").strip()
from_email = ((smtp_body and smtp_body.from_email) or "").strip() or (smtp.from_email or "").strip() or os.getenv("SMTP_FROM_EMAIL", "").strip() or username
use_tls = (smtp_body and smtp_body.use_tls) if (smtp_body and smtp_body.use_tls is not None) else (smtp.use_tls if smtp.use_tls is not None else True)
if not from_email:
return JSONResponse(
status_code=400,
content={"available": False, "error": "Aucune adresse d'expédition configurée (from_email ou SMTP_FROM_EMAIL)"},
)
msg = MIMEMultipart()
msg["From"] = from_email
msg["To"] = from_email
msg["Subject"] = "Test Office Translator — Email SMTP"
msg.attach(MIMEText(
"Ceci est un email de test envoyé depuis la page d'administration Office Translator.\n\n"
"Si vous recevez cet email, la configuration SMTP est correcte.",
"plain", "utf-8"
))
try:
server = _smtplib.SMTP(host, port, timeout=15)
server.ehlo()
if use_tls:
server.starttls()
server.ehlo()
if username and password:
server.login(username, password)
server.sendmail(from_email, from_email, msg.as_string())
server.quit()
logger.info(f"admin_smtp_test_email sent to {from_email}")
return JSONResponse(
status_code=200,
content={"available": True, "test_result": f"Email de test envoyé à {from_email}"},
)
except _smtplib.SMTPAuthenticationError as e:
return JSONResponse(
status_code=401,
content={"available": False, "error": f"Authentification SMTP échouée: {e}"},
)
except Exception as e:
logger.error(f"SMTP test-send failed: {e}")
return JSONResponse(
status_code=500,
content={"available": False, "error": f"Erreur envoi email: {str(e)[:200]}"},
)
@router.get("/providers/ollama/models")
async def list_ollama_models(admin_id: str = Depends(require_admin)):
"""List available models from Ollama server"""
async def list_ollama_models(
base_url: Optional[str] = Query(None),
admin_id: str = Depends(require_admin),
):
"""List available models from Ollama server. Accepts optional base_url query param
so the frontend can pass the URL currently being edited (before save)."""
import requests
from config import config as app_config
settings = load_settings()
base_url = (
settings.ollama.base_url
resolved = (
base_url
or settings.ollama.base_url
or app_config.OLLAMA_BASE_URL
or "http://localhost:11434"
)
try:
response = requests.get(f"{base_url}/api/tags", timeout=5)
response = requests.get(f"{resolved}/api/tags", timeout=5)
if response.ok:
data = response.json()
models = []
@@ -1126,7 +1309,7 @@ async def list_ollama_models(admin_id: str = Depends(require_admin)):
status_code=503,
content={
"error": "OLLAMA_CONNECTION_ERROR",
"message": f"Cannot connect to Ollama at {base_url}",
"message": f"Cannot connect to Ollama at {resolved}",
},
)
except Exception as e:
@@ -1137,6 +1320,107 @@ async def list_ollama_models(admin_id: str = Depends(require_admin)):
)
@router.get("/providers/openai/models")
async def list_openai_models(admin_id: str = Depends(require_admin)):
"""List available models from OpenAI API"""
settings = load_settings()
api_key = (settings.openai.api_key or "").strip() or os.getenv("OPENAI_API_KEY", "").strip()
if not api_key:
return JSONResponse(
status_code=400,
content={"error": "NO_API_KEY", "message": "Aucune clé API OpenAI trouvée (JSON ou .env)"},
)
try:
import openai as _openai
client = _openai.OpenAI(api_key=api_key)
raw_models = list(client.models.list())
models = [
{"id": m.id, "owned_by": m.owned_by, "created": m.created}
for m in raw_models
]
return JSONResponse(
status_code=200,
content={"data": models, "meta": {"total": len(models)}},
)
except Exception as e:
logger.error(f"List OpenAI models failed: {e}")
return JSONResponse(
status_code=500,
content={"error": "INTERNAL_ERROR", "message": str(e)},
)
@router.get("/providers/openrouter/models")
async def list_openrouter_models(admin_id: str = Depends(require_admin)):
"""List available models from OpenRouter (public endpoint, no API key needed)"""
try:
import requests as _requests
resp = _requests.get(
"https://openrouter.ai/api/v1/models",
timeout=15,
)
if not resp.ok:
return JSONResponse(
status_code=502,
content={"error": "OPENROUTER_ERROR", "message": f"OpenRouter returned HTTP {resp.status_code}"},
)
data = resp.json()
raw = data.get("data", [])
models = [
{
"id": m.get("id", ""),
"name": m.get("name", ""),
"context_length": m.get("context_length"),
"pricing": m.get("pricing", {}),
}
for m in raw
]
return JSONResponse(
status_code=200,
content={"data": models, "meta": {"total": len(models)}},
)
except Exception as e:
logger.error(f"List OpenRouter models failed: {e}")
return JSONResponse(
status_code=500,
content={"error": "INTERNAL_ERROR", "message": str(e)},
)
@router.get("/providers/zai/models")
async def list_zai_models(admin_id: str = Depends(require_admin)):
"""List available models from xAI / zAI (OpenAI-compatible API)"""
settings = load_settings()
api_key = (settings.zai.api_key or "").strip() or os.getenv("ZAI_API_KEY", "").strip()
if not api_key:
return JSONResponse(
status_code=400,
content={"error": "NO_API_KEY", "message": "Aucune clé API xAI trouvée (JSON ou .env)"},
)
try:
import openai as _openai
base_url = (settings.zai.base_url or "").strip() or os.getenv("ZAI_BASE_URL", "https://api.x.ai/v1")
client = _openai.OpenAI(api_key=api_key, base_url=base_url)
raw_models = list(client.models.list())
models = [
{"id": m.id, "owned_by": m.owned_by, "created": m.created}
for m in raw_models
]
return JSONResponse(
status_code=200,
content={"data": models, "meta": {"total": len(models)}},
)
except Exception as e:
logger.error(f"List xAI models failed: {e}")
return JSONResponse(
status_code=500,
content={"error": "INTERNAL_ERROR", "message": str(e)},
)
# ============================================================
# PRICING MANAGEMENT (Admin only)
# ============================================================