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)
|
||||
# ============================================================
|
||||
|
||||
@@ -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) ==============
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user