feat: fix registration 500, add forgot-password flow, frontend validation
Some checks failed
Some checks failed
- 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:
@@ -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)
|
||||
# ============================================================
|
||||
|
||||
Reference in New Issue
Block a user