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)
# ============================================================

View File

@@ -4,7 +4,9 @@ Story 3.6: Documentation OpenAPI complète avec exemples et codes d'erreur
"""
import os
from datetime import timedelta
import secrets
import logging
from datetime import timedelta, datetime, timezone
from fastapi import APIRouter, HTTPException, Depends, Header, Request, Query
from fastapi.responses import JSONResponse
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
@@ -44,6 +46,8 @@ from services.payment_service import (
is_stripe_configured,
)
logger = logging.getLogger(__name__)
security = HTTPBearer(auto_error=False)
@@ -895,6 +899,236 @@ async def get_billing_portal_v1(user=Depends(require_user)):
return JSONResponse(status_code=200, content={"data": {"url": url}, "meta": {}})
# ============== Forgot / Reset password ==============
class ForgotPasswordRequest(BaseModel):
email: EmailStr
class ResetPasswordRequest(BaseModel):
token: str
password: str
@router_v1.post(
"/forgot-password",
summary="Demander une reinitialisation de mot de passe",
description="""
Envoie un email de reinitialisation si un compte existe avec cette adresse.
**Securite:** Retourne toujours 200 pour ne pas reveler si l'email existe.
""",
)
async def forgot_password(request: Request):
from services.email_service import is_smtp_configured, send_email_async
if not is_smtp_configured():
return JSONResponse(
status_code=503,
content={
"error": "EMAIL_SERVICE_UNAVAILABLE",
"message": "Service email non configure",
},
)
try:
body = await request.json()
except Exception:
return JSONResponse(
status_code=400,
content={
"error": "INVALID_REQUEST",
"message": "Corps de requete JSON invalide",
},
)
email = body.get("email", "").strip()
if not email:
return JSONResponse(
status_code=400,
content={
"error": "INVALID_REQUEST",
"message": "Email requis",
},
)
# Always return 200 to avoid email enumeration
user = get_user_by_email(email)
if user and user.id:
token = secrets.token_urlsafe(32)
expires = datetime.now(timezone.utc) + timedelta(hours=1)
# Store token in database
try:
from database.connection import get_sync_session
from database.repositories import UserRepository
from database.models import User as DBUser
with get_sync_session() as session:
repo = UserRepository(session)
# Check if user exists in DB; if not (legacy JSON-only user), create DB record
db_user = repo.get_by_email(email)
if not db_user:
db_user = repo.create(
email=user.email,
name=user.name,
hashed_password=user.password_hash or "",
tier="free",
)
repo.update(
db_user.id,
reset_token=token,
reset_token_expires=expires,
)
except Exception as exc:
logger.error(f"Failed to store reset token: {exc}")
# Still return 200 for security
# Send email with reset link
frontend_url = os.getenv("FRONTEND_URL", os.getenv("NEXT_PUBLIC_APP_URL", "http://localhost:3000"))
reset_link = f"{frontend_url}/auth/reset-password?token={token}"
html_body = f"""
<html>
<body style="font-family: Arial, sans-serif; padding: 20px;">
<h2>Reinitialisation de votre mot de passe</h2>
<p>Vous avez demande la reinitialisation de votre mot de passe.</p>
<p>Cliquez sur le lien ci-dessous pour definir un nouveau mot de passe :</p>
<p><a href="{reset_link}" style="background-color: #007bff; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px;">Reinitialiser mon mot de passe</a></p>
<p>Ce lien expire dans 1 heure.</p>
<p>Si vous n'avez pas fait cette demande, ignorez cet email.</p>
</body>
</html>
"""
await send_email_async(
to=email,
subject="Reinitialisation de votre mot de passe - Office Translator",
body=html_body,
)
return JSONResponse(
status_code=200,
content={
"data": {
"message": "Si un compte existe avec cette adresse, un email de reinitialisation a ete envoye."
},
"meta": {},
},
)
@router_v1.post(
"/reset-password",
summary="Reinitialiser le mot de passe",
description="""
Reinitialise le mot de passe a l'aide d'un token valide.
**Parametres requis:**
- `token`: Le token recu par email
- `password`: Le nouveau mot de passe (min 8 caracteres, 1 majuscule, 1 minuscule, 1 chiffre)
""",
)
async def reset_password(request: Request):
try:
body = await request.json()
except Exception:
return JSONResponse(
status_code=400,
content={
"error": "INVALID_REQUEST",
"message": "Corps de requete JSON invalide",
},
)
token = body.get("token", "").strip()
password = body.get("password", "")
if not token or not password:
return JSONResponse(
status_code=400,
content={
"error": "INVALID_REQUEST",
"message": "Token et mot de passe requis",
},
)
# Validate password strength
if len(password) < 8 or not any(c.isupper() for c in password) or not any(c.islower() for c in password) or not any(c.isdigit() for c in password):
return JSONResponse(
status_code=400,
content={
"error": "WEAK_PASSWORD",
"message": "Le mot de passe doit contenir au moins 8 caracteres, une majuscule, une minuscule et un chiffre",
},
)
# Find user by reset token
try:
from database.connection import get_sync_session
from database.models import User as DBUser
from services.auth_service import hash_password
with get_sync_session() as session:
db_user = (
session.query(DBUser)
.filter(DBUser.reset_token == token)
.first()
)
if not db_user:
return JSONResponse(
status_code=400,
content={
"error": "INVALID_TOKEN",
"message": "Token invalide ou expire",
},
)
# Check token expiry
if db_user.reset_token_expires:
expires = db_user.reset_token_expires
if expires.tzinfo is None:
expires = expires.replace(tzinfo=timezone.utc)
if expires < datetime.now(timezone.utc):
return JSONResponse(
status_code=400,
content={
"error": "TOKEN_EXPIRED",
"message": "Token expire, veuillez redemander une reinitialisation",
},
)
# Update password and invalidate token
db_user.hashed_password = hash_password(password)
db_user.reset_token = None
db_user.reset_token_expires = None
db_user.updated_at = datetime.utcnow()
session.commit()
except Exception as exc:
logger.error(f"Reset password failed: {exc}")
return JSONResponse(
status_code=500,
content={
"error": "RESET_FAILED",
"message": "Erreur lors de la reinitialisation",
},
)
return JSONResponse(
status_code=200,
content={
"data": {"message": "Mot de passe reinitialise avec succes"},
"meta": {},
},
)
# ============== Stripe webhook (versioned) ==============