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

75
.dockerignore Normal file
View File

@@ -0,0 +1,75 @@
# ============================================
# Docker Build Context Ignore
# ============================================
# Version control
.git
.gitignore
# IDE
.vscode
.idea
.claude
.cursor
.augment
.clinerules
.gemini
.opencode
.agents
.agent
# Python
.venv
__pycache__
*.pyc
*.pyo
*.egg-info
dist/
build/
# Node
frontend/node_modules
frontend/.next
# Environment files
.env
.env.*
!.env.example
# Docker (avoid recursive) — keep backend entrypoint and frontend server.js
docker/nginx/
docker/prometheus/
# Kubernetes
k8s/
# Runtime data
uploads/
outputs/
temp/
logs/
data/*.db
data/*.sqlite
# Test
tests/
frontend/src/test/
# Documentation
docs/
*.md
!README.md
# Landing page (separate project)
office-translator-landing-page/
# macOS
.DS_Store
# Backups
backups/
# Build artifacts
*.log
*.png
*.jpg

183
.github/workflows/deploy.yml vendored Normal file
View File

@@ -0,0 +1,183 @@
name: Build and Deploy
on:
push:
branches: [master, production-deployment]
pull_request:
branches: [master]
env:
REGISTRY: ghcr.io
IMAGE_PREFIX: ${{ github.repository }}
jobs:
# ---- Backend Tests ----
test-backend:
name: Backend Tests
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: translate
POSTGRES_PASSWORD: test_password
POSTGRES_DB: translate_test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis:7-alpine
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- name: Set up Python 3.12
uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Run tests
env:
ENV: test
DATABASE_URL: postgresql+asyncpg://translate:test_password@localhost:5432/translate_test
REDIS_URL: redis://localhost:6379/0
JWT_SECRET_KEY: test_jwt_secret_key_for_ci
ADMIN_USERNAME: admin
ADMIN_PASSWORD: test_admin_password
ADMIN_TOKEN_SECRET: test_admin_token_secret_ci
CORS_ORIGINS: http://localhost:3000
run: |
pytest tests/ -v --tb=short -x
# ---- Frontend Build Check ----
test-frontend:
name: Frontend Build Check
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: npm
cache-dependency-path: frontend/package-lock.json
- name: Install dependencies
run: npm ci
- name: Lint
run: npm run lint --if-present
- name: Build
run: npm run build
env:
NEXT_PUBLIC_API_URL: http://localhost:8000
# ---- Build and Push Docker Images ----
build:
name: Build Docker Images
runs-on: ubuntu-latest
needs: [test-backend, test-frontend]
if: github.event_name == 'push' && github.ref == 'refs/heads/production-deployment'
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Log in to Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Extract metadata for backend
id: meta-backend
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}/backend
tags: |
type=sha,prefix=
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push backend
uses: docker/build-push-action@v5
with:
context: .
file: docker/backend/Dockerfile
target: production
push: true
tags: ${{ steps.meta-backend.outputs.tags }}
labels: ${{ steps.meta-backend.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Extract metadata for frontend
id: meta-frontend
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}/frontend
tags: |
type=sha,prefix=
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push frontend
uses: docker/build-push-action@v5
with:
context: .
file: docker/frontend/Dockerfile
target: production
build-args: NEXT_PUBLIC_API_URL=
push: true
tags: ${{ steps.meta-frontend.outputs.tags }}
labels: ${{ steps.meta-frontend.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
# ---- Deploy to Server ----
deploy:
name: Deploy to Server
runs-on: ubuntu-latest
needs: [build]
if: github.ref == 'refs/heads/production-deployment'
environment: production
steps:
- name: Deploy via SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
key: ${{ secrets.SERVER_SSH_KEY }}
script: |
cd ${{ secrets.DEPLOY_PATH }}
docker compose pull
docker compose up -d --remove-orphans
docker compose ps

6
.gitignore vendored
View File

@@ -27,6 +27,12 @@ ENV/
# Environment variables
.env
.env.docker
.env.production
.env.ionos
# Backups
backups/
# IDE
.vscode/

View File

@@ -0,0 +1,31 @@
"""add reset_token and reset_token_expires to users
Revision ID: a1b2c3d4e5f6
Revises: 5206607942a2
Create Date: 2026-05-01 10:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "a1b2c3d4e5f6"
down_revision: Union[str, None] = "004_rename_account_tier"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("users", sa.Column("reset_token", sa.String(length=255), nullable=True))
op.add_column("users", sa.Column("reset_token_expires", sa.DateTime(), nullable=True))
# Make legacy password_hash column nullable (ORM uses hashed_password now)
op.alter_column("users", "password_hash", nullable=True)
def downgrade() -> None:
op.drop_column("users", "reset_token_expires")
op.drop_column("users", "reset_token")
op.alter_column("users", "password_hash", nullable=False)

View File

@@ -7,6 +7,14 @@
"timeout": 30,
"max_retries": 3
},
"google_cloud": {
"enabled": false,
"api_key": "esenaw",
"base_url": null,
"model": null,
"timeout": 30,
"max_retries": 3
},
"deepl": {
"enabled": false,
"api_key": null,
@@ -55,6 +63,15 @@
"timeout": 30,
"max_retries": 3
},
"smtp": {
"enabled": true,
"host": "smtp.ionos.fr",
"port": 587,
"username": "admin@wordly.art",
"password": "Esenaw,121151",
"from_email": "admin@wordly.art",
"use_tls": true
},
"fallback_chain": "google,deepl,openai,ollama,openrouter,zai",
"fallback_chain_classic": "google,deepl",
"fallback_chain_llm": "ollama,openai,openrouter,zai"

View File

@@ -67,8 +67,10 @@ else:
# Kept for backward compatibility until all callers use async; see story 1-1.
# Prefer get_db() / AsyncSessionLocal for new code.
if DATABASE_URL and _is_postgres:
# Pour le sync engine, utiliser psycopg2 (pas asyncpg qui ne supporte pas les appels synchrones)
sync_url = DATABASE_URL.replace("+asyncpg", "")
sync_engine = create_engine(
DATABASE_URL,
sync_url,
poolclass=QueuePool,
pool_size=5,
max_overflow=10,

View File

@@ -83,6 +83,9 @@ class User(Base):
stripe_customer_id = Column(String(255), nullable=True, index=True)
stripe_subscription_id = Column(String(255), nullable=True)
reset_token = Column(String(255), nullable=True)
reset_token_expires = Column(DateTime, nullable=True)
docs_translated_this_month = Column(Integer, default=0)
pages_translated_this_month = Column(Integer, default=0)
api_calls_this_month = Column(Integer, default=0)

466
deploy.sh Executable file
View File

@@ -0,0 +1,466 @@
#!/bin/bash
# ============================================================
# Office Translator - Deployment Script
# ============================================================
# Usage: ./deploy.sh <command> [options]
#
# Commands:
# start Build and start all services
# stop Stop all services
# restart Restart all services
# status Show services status
# logs Show logs (follow mode)
# build Build/rebuild images
# health Run health checks
# backup Backup PostgreSQL database
# clean Remove all containers, volumes, and images
# shell Open a shell in the backend container
# migrate Run database migrations
# help Show this help message
#
# Options:
# --env <file> Use a specific env file (default: .env.docker)
# --prod Use production compose file (docker-compose.yml)
# --no-build Skip build step on start
# --rebuild Force rebuild without cache
#
# Examples:
# ./deploy.sh start # Start with defaults (local)
# ./deploy.sh start --rebuild # Force rebuild
# ./deploy.sh start --prod # Start production stack
# ./deploy.sh logs backend # Show backend logs
# ./deploy.sh logs # Show all logs
# ./deploy.sh backup # Backup database
# ./deploy.sh clean # Full cleanup
# ============================================================
set -euo pipefail
# ---- Colors ----
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
BOLD='\033[1m'
NC='\033[0m'
# ---- Configuration ----
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
COMPOSE_LOCAL="docker-compose.local.yml"
COMPOSE_PROD="docker-compose.yml"
ENV_FILE=".env.docker"
COMPOSE_FILE="$COMPOSE_LOCAL"
COMMAND=""
SERVICE=""
NO_BUILD=false
REBUILD=false
# ---- Helper Functions ----
info() { echo -e "${BLUE}[INFO]${NC} $*"; }
success() { echo -e "${GREEN}[OK]${NC} $*"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
error() { echo -e "${RED}[ERROR]${NC} $*" >&2; }
header() { echo ""; echo -e "${BOLD}${CYAN}========================================${NC}"; echo -e "${BOLD}${CYAN} $*${NC}"; echo -e "${BOLD}${CYAN}========================================${NC}"; }
# Run docker compose with the right file and env
dc() {
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" "$@"
}
# ---- Parse Arguments ----
COMMAND="${1:-help}"
shift 2>/dev/null || true
while [[ $# -gt 0 ]]; do
case $1 in
--env)
ENV_FILE="$2"
shift 2
;;
--prod)
COMPOSE_FILE="$COMPOSE_PROD"
shift
;;
--no-build)
NO_BUILD=true
shift
;;
--rebuild)
REBUILD=true
shift
;;
backend|frontend|postgres|redis|nginx|ollama)
SERVICE="$1"
shift
;;
*)
shift
;;
esac
done
# ---- Prerequisite Checks ----
check_prerequisites() {
if ! command -v docker &> /dev/null; then
error "Docker is not installed. Install it from https://docs.docker.com/get-docker/"
exit 1
fi
if ! docker info &> /dev/null 2>&1; then
error "Docker daemon is not running. Start Docker Desktop or the Docker service."
exit 1
fi
if ! docker compose version &> /dev/null 2>&1; then
error "Docker Compose V2 is not available. Update Docker or install docker-compose."
exit 1
fi
if [ ! -f "$ENV_FILE" ]; then
error "Environment file '$ENV_FILE' not found."
if [ "$ENV_FILE" == ".env.docker" ]; then
info "Creating .env.docker from default settings..."
cat > .env.docker << 'ENVEOF'
ENV=production
LOG_LEVEL=INFO
LOG_FORMAT=console
ENABLE_REQUEST_LOGGING=true
TRANSLATION_SERVICE=google
GOOGLE_TRANSLATE_ENABLED=true
DEEPL_ENABLED=false
OPENAI_ENABLED=false
OLLAMA_ENABLED=false
OPENROUTER_ENABLED=false
PROVIDER_FALLBACK_CHAIN=google,deepl,openai,ollama,openrouter
FALLBACK_CHAIN_CLASSIC=google,deepl
FALLBACK_CHAIN_LLM=ollama,openai
MAX_FILE_SIZE_MB=50
MAX_REQUEST_SIZE_MB=100
REQUEST_TIMEOUT_SECONDS=300
RATE_LIMIT_ENABLED=true
RATE_LIMIT_PER_MINUTE=30
RATE_LIMIT_PER_HOUR=200
TRANSLATIONS_PER_MINUTE=10
TRANSLATIONS_PER_HOUR=50
MAX_CONCURRENT_TRANSLATIONS=5
CLEANUP_ENABLED=true
CLEANUP_INTERVAL_MINUTES=5
FILE_TTL_MINUTES=60
INPUT_FILE_TTL_MINUTES=30
OUTPUT_FILE_TTL_MINUTES=120
DISK_WARNING_THRESHOLD_GB=5.0
DISK_CRITICAL_THRESHOLD_GB=1.0
ENABLE_HSTS=false
CORS_ORIGINS=http://localhost,http://localhost:3000,http://localhost:8000
MAX_MEMORY_PERCENT=80
POSTGRES_USER=translate
POSTGRES_PASSWORD=translate_local_2026
POSTGRES_DB=translate_db
POSTGRES_HOST=postgres
POSTGRES_PORT=5432
DATABASE_ECHO=false
ADMIN_USERNAME=admin
ADMIN_PASSWORD=admin123
ADMIN_TOKEN_SECRET=docker_local_admin_token_secret_2026_abc123xyz
JWT_SECRET_KEY=docker_local_jwt_secret_key_2026_x7k9m3p5q8r2s4t6
FRONTEND_URL=http://localhost
NEXT_PUBLIC_API_URL=
BACKEND_URL=http://backend:8000
HTTP_PORT=80
ENVEOF
success "Created .env.docker with default local settings"
else
exit 1
fi
fi
# Check for port conflicts
if command -v lsof &> /dev/null; then
for PORT in 80 3000 5432 6379; do
if lsof -i ":$PORT" -sTCP:LISTEN &> /dev/null 2>&1; then
warn "Port $PORT is already in use. This may cause conflicts."
fi
done
fi
success "Prerequisites OK (Docker + Compose + $ENV_FILE)"
}
# ---- Command Functions ----
cmd_start() {
header "Office Translator - Starting"
check_prerequisites
if [ "$REBUILD" = true ]; then
info "Building images (no cache)..."
dc build --no-cache ${SERVICE:-}
elif [ "$NO_BUILD" = false ]; then
info "Building images..."
dc build ${SERVICE:-}
else
info "Skipping build (--no-build)"
fi
info "Starting services..."
dc up -d ${SERVICE:-}
info "Waiting for services to be ready..."
local max_wait=120
local elapsed=0
while [ $elapsed -lt $max_wait ]; do
local backend_ok=false
local frontend_ok=false
if curl -sf http://localhost:8000/health > /dev/null 2>&1; then
backend_ok=true
fi
if curl -sf http://localhost:3000 > /dev/null 2>&1; then
frontend_ok=true
fi
if [ "$backend_ok" = true ] && [ "$frontend_ok" = true ]; then
break
fi
sleep 3
elapsed=$((elapsed + 3))
printf "\r Waiting... (%ds/%ds) backend=%s frontend=%s" \
"$elapsed" "$max_wait" "$backend_ok" "$frontend_ok"
done
echo ""
# Health check
echo ""
cmd_health
# Show status
echo ""
dc ps --format "table {{.Name}}\t{{.Status}}\t{{.Ports}}"
echo ""
success "Application is running!"
echo ""
info "Access points:"
echo " Frontend: http://localhost"
echo " Backend: http://localhost:8000"
echo " API docs: http://localhost:8000/docs"
echo " Admin: http://localhost/admin (admin / admin123)"
echo " Health: http://localhost/health"
echo ""
info "Useful commands:"
echo " ./deploy.sh logs Follow logs"
echo " ./deploy.sh status Check status"
echo " ./deploy.sh stop Stop all"
}
cmd_stop() {
header "Stopping Services"
dc down --remove-orphans ${SERVICE:-}
success "Services stopped"
}
cmd_restart() {
header "Restarting Services"
info "Stopping..."
dc restart ${SERVICE:-}
success "Services restarted"
sleep 5
cmd_health
}
cmd_status() {
header "Service Status"
dc ps --format "table {{.Name}}\t{{.Status}}\t{{.Ports}}"
echo ""
# Quick health
info "Health checks:"
local backend_code=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:8000/health 2>/dev/null || echo "000")
local frontend_code=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:3000 2>/dev/null || echo "000")
local nginx_code=$(curl -s -o /dev/null -w "%{http_code}" http://localhost/health 2>/dev/null || echo "000")
[ "$backend_code" = "200" ] && success "Backend (HTTP $backend_code)" || error "Backend (HTTP $backend_code)"
[ "$frontend_code" = "200" ] && success "Frontend (HTTP $frontend_code)" || error "Frontend (HTTP $frontend_code)"
[ "$nginx_code" = "200" ] && success "Nginx (HTTP $nginx_code)" || error "Nginx (HTTP $nginx_code)"
}
cmd_logs() {
local target="${SERVICE:-}"
if [ -n "$target" ]; then
dc logs -f --tail 100 "$target"
else
dc logs -f --tail 50
fi
}
cmd_build() {
header "Building Images"
check_prerequisites
if [ "$REBUILD" = true ]; then
info "Building without cache..."
dc build --no-cache ${SERVICE:-}
else
info "Building..."
dc build ${SERVICE:-}
fi
success "Build complete"
}
cmd_health() {
info "Running health checks..."
# Backend
local backend_response=$(curl -sf http://localhost:8000/health 2>/dev/null || echo '{"status":"unreachable"}')
if echo "$backend_response" | python3 -c "import sys,json; d=json.load(sys.stdin); exit(0 if d.get('status')=='healthy' else 1)" 2>/dev/null; then
success "Backend: healthy"
echo "$backend_response" | python3 -m json.tool 2>/dev/null | sed 's/^/ /'
else
local backend_code=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:8000/health 2>/dev/null || echo "000")
error "Backend: unhealthy (HTTP $backend_code)"
fi
# Frontend
if curl -sf http://localhost:3000 > /dev/null 2>&1; then
success "Frontend: responding"
else
error "Frontend: not responding"
fi
# Nginx
if curl -sf http://localhost/health > /dev/null 2>&1; then
success "Nginx: proxying correctly"
else
warn "Nginx: not reachable on port 80"
fi
# PostgreSQL
if dc exec -T postgres pg_isready -U translate -d translate_db &> /dev/null; then
success "PostgreSQL: ready"
else
error "PostgreSQL: not ready"
fi
# Redis
if dc exec -T redis redis-cli ping &> /dev/null; then
success "Redis: ready (PONG)"
else
error "Redis: not ready"
fi
}
cmd_backup() {
header "Database Backup"
local timestamp=$(date +%Y%m%d_%H%M%S)
local backup_dir="backups"
mkdir -p "$backup_dir"
local backup_file="$backup_dir/translate_db_${timestamp}.sql.gz"
info "Backing up PostgreSQL to $backup_file..."
dc exec -T postgres pg_dump -U translate translate_db | gzip > "$backup_file"
local size=$(du -h "$backup_file" | cut -f1)
success "Backup complete: $backup_file ($size)"
}
cmd_clean() {
header "Full Cleanup"
warn "This will remove all containers, volumes, and images for this project."
read -p "Are you sure? [y/N] " -n 1 -r
echo ""
if [[ $REPLY =~ ^[Yy]$ ]]; then
info "Stopping and removing containers..."
dc down -v --rmi local --remove-orphans
info "Pruning dangling resources..."
docker system prune -f
success "Cleanup complete"
else
info "Cancelled"
fi
}
cmd_shell() {
local target="${SERVICE:-backend}"
info "Opening shell in $target container..."
dc exec "$target" /bin/bash || dc exec "$target" /bin/sh
}
cmd_migrate() {
header "Running Database Migrations"
info "Running alembic upgrade head..."
dc exec backend alembic upgrade head
success "Migrations complete"
}
cmd_help() {
echo ""
echo -e "${BOLD}Office Translator - Deployment Script${NC}"
echo ""
echo "Usage: ./deploy.sh <command> [options]"
echo ""
echo -e "${BOLD}Commands:${NC}"
echo " start Build and start all services"
echo " stop Stop all services"
echo " restart Restart all services"
echo " status Show services status"
echo " logs [svc] Show logs (optional: backend, frontend, postgres, redis, nginx)"
echo " build Build/rebuild Docker images"
echo " health Run health checks on all services"
echo " backup Backup PostgreSQL database"
echo " clean Remove all containers, volumes, and images"
echo " shell [svc] Open shell in a container (default: backend)"
echo " migrate Run database migrations"
echo " help Show this help message"
echo ""
echo -e "${BOLD}Options:${NC}"
echo " --env <file> Use a specific env file (default: .env.docker)"
echo " --prod Use production compose file"
echo " --no-build Skip build step"
echo " --rebuild Force rebuild without Docker cache"
echo ""
echo -e "${BOLD}Examples:${NC}"
echo " ./deploy.sh start"
echo " ./deploy.sh start --rebuild"
echo " ./deploy.sh start --prod --env .env.production"
echo " ./deploy.sh logs backend"
echo " ./deploy.sh shell postgres"
echo " ./deploy.sh backup"
echo ""
}
# ---- Main ----
case "$COMMAND" in
start) cmd_start ;;
stop) cmd_stop ;;
restart) cmd_restart ;;
status) cmd_status ;;
logs) cmd_logs ;;
build) cmd_build ;;
health) cmd_health ;;
backup) cmd_backup ;;
clean) cmd_clean ;;
shell) cmd_shell ;;
migrate) cmd_migrate ;;
help|--help|-h)
cmd_help
;;
*)
error "Unknown command: $COMMAND"
cmd_help
exit 1
;;
esac

180
docker-compose.local.yml Normal file
View File

@@ -0,0 +1,180 @@
# ============================================================
# Office Translator - Local Docker Compose (Mac Testing)
# ============================================================
# Usage: ./deploy.sh start (or: docker compose -f docker-compose.local.yml --env-file .env.docker up -d)
# Access: http://localhost (Nginx) | http://localhost:8000 (Backend direct) | http://localhost:3000 (Frontend direct)
#
# Services: PostgreSQL 16, Redis 7, Backend (FastAPI), Frontend (Next.js), Nginx (HTTP reverse proxy)
# ============================================================
services:
# ===========================================
# PostgreSQL Database
# ===========================================
postgres:
image: postgres:16-alpine
container_name: translate-postgres
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER:-translate}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-translate_local_2026}
POSTGRES_DB: ${POSTGRES_DB:-translate_db}
PGDATA: /var/lib/postgresql/data/pgdata
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "5432:5432"
networks:
- translate-network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-translate} -d ${POSTGRES_DB:-translate_db}"]
interval: 5s
timeout: 5s
retries: 10
start_period: 10s
# ===========================================
# Redis (Caching, Sessions, Rate Limiting)
# ===========================================
redis:
image: redis:7-alpine
container_name: translate-redis
restart: unless-stopped
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru --loglevel warning
volumes:
- redis_data:/data
ports:
- "6379:6379"
networks:
- translate-network
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 5s
retries: 5
start_period: 5s
# ===========================================
# Backend API (FastAPI) - Production build
# ===========================================
backend:
build:
context: .
dockerfile: docker/backend/Dockerfile
target: production
container_name: translate-backend
restart: unless-stopped
extra_hosts:
- "host.docker.internal:host-gateway"
env_file:
- .env.docker
environment:
# Override DB URL to use Docker service name
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-translate}:${POSTGRES_PASSWORD:-translate_local_2026}@postgres:5432/${POSTGRES_DB:-translate_db}
REDIS_URL: redis://redis:6379/0
ENV: production
PORT: "8000"
WORKERS: "2"
volumes:
- uploads_data:/app/uploads
- outputs_data:/app/outputs
- logs_data:/app/logs
- ./data:/app/data
ports:
- "8000:8000"
networks:
- translate-network
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 15s
timeout: 10s
retries: 5
start_period: 30s
# ===========================================
# Frontend (Next.js) - Production build
# ===========================================
frontend:
build:
context: .
dockerfile: docker/frontend/Dockerfile
target: production
args:
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-}
BACKEND_URL: http://backend:8000
container_name: translate-frontend
restart: unless-stopped
environment:
NODE_ENV: production
PORT: "3000"
HOSTNAME: "0.0.0.0"
# Server-side proxy: backend accessible via Docker network
BACKEND_URL: http://backend:8000
ports:
- "${FRONTEND_PORT:-3010}:3000"
networks:
- translate-network
depends_on:
backend:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "node -e \"fetch('http://localhost:3000/').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\""]
interval: 15s
timeout: 10s
retries: 3
start_period: 15s
# ===========================================
# Nginx Reverse Proxy (HTTP only for local)
# ===========================================
nginx:
image: nginx:alpine
container_name: translate-nginx
restart: unless-stopped
ports:
- "${HTTP_PORT:-80}:80"
volumes:
- ./docker/nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./docker/nginx/conf.d/local.conf:/etc/nginx/conf.d/default.conf:ro
- nginx_cache:/var/cache/nginx
networks:
- translate-network
depends_on:
backend:
condition: service_healthy
frontend:
condition: service_healthy
healthcheck:
test: ["CMD", "nginx", "-t"]
interval: 30s
timeout: 10s
retries: 3
# ===========================================
# Networks
# ===========================================
networks:
translate-network:
driver: bridge
# ===========================================
# Volumes
# ===========================================
volumes:
postgres_data:
driver: local
redis_data:
driver: local
uploads_data:
driver: local
outputs_data:
driver: local
logs_data:
driver: local
nginx_cache:
driver: local

View File

@@ -1,7 +1,7 @@
# Document Translation API - Backend Dockerfile
# Multi-stage build for optimized production image
FROM python:3.11-slim as builder
FROM python:3.12-slim AS builder
WORKDIR /app
@@ -22,7 +22,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
pip install --no-cache-dir -r requirements.txt
# Production stage
FROM python:3.11-slim as production
FROM python:3.12-slim AS production
WORKDIR /app

View File

@@ -1,49 +1,55 @@
# Document Translation Frontend - Dockerfile
# Office Translator - Frontend Dockerfile
# Multi-stage build for optimized production
# Build stage
# ---- Build stage ----
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files
# Copy package files first (better layer caching)
COPY frontend/package*.json ./
# Install dependencies
RUN npm ci --only=production=false
# Install ALL dependencies (dev needed for build)
RUN npm install
# Copy source code
COPY frontend/ .
# Build arguments for environment
ARG NEXT_PUBLIC_API_URL=http://localhost:8000
# Build arguments
# NEXT_PUBLIC_API_URL: client-side API base (empty = relative URLs via proxy)
# BACKEND_URL: server-side proxy target (must be resolvable from container)
ARG NEXT_PUBLIC_API_URL=
ARG BACKEND_URL=http://backend:8000
ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
ENV BACKEND_URL=${BACKEND_URL}
# Build the application
# Build the application (with standalone output)
RUN npm run build
# Production stage
# ---- Production stage ----
FROM node:20-alpine AS production
WORKDIR /app
# Set NODE_ENV before copying
ENV NODE_ENV=production
# Create non-root user
RUN addgroup -g 1001 -S nodejs && \
adduser -S nextjs -u 1001
# Copy built assets from builder
# Copy standalone output from builder
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
# Set correct permissions
# Set correct ownership
RUN chown -R nextjs:nodejs /app
USER nextjs
# Environment
ENV NODE_ENV=production \
PORT=3000 \
# Runtime environment
ENV PORT=3000 \
HOSTNAME="0.0.0.0"
EXPOSE 3000

66
docker/frontend/server.js Normal file
View File

@@ -0,0 +1,66 @@
/**
* Custom Next.js standalone server.
* Proxies /api/* requests to BACKEND_URL (resolved at RUNTIME, not build time).
* This is needed because next.config.ts rewrites are baked in at build time.
*/
const { createServer } = require("http");
const { parse } = require("url");
const next = require("next");
const port = parseInt(process.env.PORT || "3000", 10);
const hostname = process.env.HOSTNAME || "0.0.0.0";
const dev = process.env.NODE_ENV !== "production";
const backendUrl = (process.env.BACKEND_URL || "http://127.0.0.1:8000").replace(/\/$/, "");
const app = next({ dev, hostname, port });
const handle = app.getRequestHandler();
app.prepare().then(() => {
createServer(async (req, res) => {
try {
const parsedUrl = parse(req.url, true);
const { pathname } = parsedUrl;
// Proxy /api/* to backend
if (pathname.startsWith("/api/")) {
const http = require("http");
const targetUrl = new URL(req.url, backendUrl);
const proxyReq = http.request(
targetUrl,
{
method: req.method,
headers: {
...req.headers,
host: targetUrl.host,
},
},
(proxyRes) => {
res.writeHead(proxyRes.statusCode, proxyRes.headers);
proxyRes.pipe(res, { end: true });
}
);
proxyReq.on("error", (err) => {
console.error(`Proxy error for ${req.url}:`, err.message);
res.writeHead(502);
res.end("Bad Gateway");
});
req.pipe(proxyReq, { end: true });
return;
}
// All other requests -> Next.js
await handle(req, res, parsedUrl);
} catch (err) {
console.error("Error occurred handling", req.url, err);
res.writeHead(500);
res.end("internal server error");
}
}).listen(port, hostname, () => {
console.log(`> Ready on http://${hostname}:${port}`);
console.log(`> API proxy: ${backendUrl}`);
});
});

View File

@@ -109,11 +109,9 @@ server {
proxy_set_header Connection "";
}
# Admin endpoints
# Admin UI -> Frontend (Next.js page)
location /admin {
limit_req zone=api_limit burst=10 nodelay;
proxy_pass http://backend/admin;
proxy_pass http://frontend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;

View File

@@ -0,0 +1,123 @@
# Office Translator - Local Nginx Config (HTTP only, no SSL)
# Used by: docker-compose.local.yml
# Upstreams (backend, frontend) and rate limit zones are defined in nginx.conf
server {
listen 80;
listen [::]:80;
server_name _;
# File upload size
client_max_body_size 100M;
client_body_timeout 300s;
# Proxy settings
proxy_connect_timeout 60s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
proxy_buffer_size 128k;
proxy_buffers 4 256k;
proxy_busy_buffers_size 256k;
# API routes -> Backend
location /api/ {
limit_req zone=api_limit burst=20 nodelay;
limit_conn conn_limit 10;
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
# CORS headers
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always;
add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Requested-With, X-API-Key" always;
add_header Access-Control-Allow-Credentials "true" always;
if ($request_method = 'OPTIONS') {
return 204;
}
}
# File upload / translate endpoint
location /translate {
limit_req zone=upload_limit burst=5 nodelay;
limit_conn conn_limit 5;
proxy_pass http://backend/translate;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 60s;
proxy_send_timeout 600s;
proxy_read_timeout 600s;
}
# Health check endpoint
location /health {
proxy_pass http://backend/health;
proxy_http_version 1.1;
proxy_set_header Connection "";
}
# Readiness check
location /ready {
proxy_pass http://backend/ready;
proxy_http_version 1.1;
proxy_set_header Connection "";
}
# OpenAPI docs
location /docs {
proxy_pass http://backend/docs;
proxy_http_version 1.1;
proxy_set_header Host $host;
}
location /redoc {
proxy_pass http://backend/redoc;
proxy_http_version 1.1;
proxy_set_header Host $host;
}
location /openapi.json {
proxy_pass http://backend/openapi.json;
proxy_http_version 1.1;
proxy_set_header Host $host;
}
# Admin UI -> Frontend (Next.js page)
location /admin {
proxy_pass http://frontend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Frontend static assets with caching
location /_next/static/ {
proxy_pass http://frontend;
add_header Cache-Control "public, max-age=31536000, immutable";
}
# Frontend -> Next.js
location / {
proxy_pass http://frontend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}

View File

@@ -1,10 +1,12 @@
import type { NextConfig } from "next";
// Cible du backend pour le proxy : résolvable depuis le process Node (Next), pas depuis le navigateur.
// En Docker Compose : http://backend:8000. En local : http://127.0.0.1:8000
// BACKEND_URL: resolved at build time for rewrites. Must be resolvable from the container.
// In Docker: passed as build arg (http://backend:8000). Local dev: http://127.0.0.1:8000
const backendUrl = (process.env.BACKEND_URL || "http://127.0.0.1:8000").replace(/\/$/, "");
const nextConfig: NextConfig = {
// Docker: standalone output for optimized production images
output: "standalone",
// Turbopack ne résout pas le require() dynamique de lightningcss → "Module not found".
// Toujours lancer avec Webpack : npm run dev ou next dev --webpack (pas "next dev" seul).
serverExternalPackages: ["lightningcss", "@tailwindcss/postcss", "@tailwindcss/node"],

View File

@@ -4451,6 +4451,66 @@
"node": ">=14.0.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.6.0",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.1.0",
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.6.0",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.1.0",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
"version": "1.0.7",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.5.0",
"@emnapi/runtime": "^1.5.0",
"@tybys/wasm-util": "^0.10.1"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
"version": "2.8.1",
"dev": true,
"inBundle": true,
"license": "0BSD",
"optional": true
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.1.17",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.17.tgz",

View File

@@ -1,7 +1,7 @@
"use client";
import { useState, useEffect } from "react";
import { Settings, Save, Loader2, CheckCircle, XCircle, RefreshCw, FlaskConical, KeyRound } from "lucide-react";
import { Settings, Save, Loader2, CheckCircle, XCircle, RefreshCw, FlaskConical, KeyRound, Mail } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -10,6 +10,7 @@ import { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useNotification } from "@/components/ui/notification";
import { ModelCombobox, ModelOption } from "@/components/ui/model-combobox";
import { useTranslationStore } from "@/lib/store";
import { API_BASE } from "@/lib/config";
@@ -22,6 +23,16 @@ interface ProviderConfig {
max_retries?: number;
}
interface SmtpConfig {
enabled: boolean;
host?: string;
port: number;
username?: string;
password?: string;
from_email?: string;
use_tls: boolean;
}
interface SettingsConfig {
google: ProviderConfig;
google_cloud: ProviderConfig;
@@ -31,6 +42,7 @@ interface SettingsConfig {
openrouter: ProviderConfig;
openrouter_premium: ProviderConfig;
zai: ProviderConfig;
smtp: SmtpConfig;
fallback_chain: string;
fallback_chain_classic: string;
fallback_chain_llm: string;
@@ -44,6 +56,7 @@ interface EnvInfo {
zai: boolean;
ollama: boolean;
google_cloud: boolean;
smtp: boolean;
}
interface OllamaModel {
@@ -61,6 +74,7 @@ const defaultConfig: SettingsConfig = {
openrouter: { enabled: false, api_key: "", model: "deepseek/deepseek-chat" },
openrouter_premium: { enabled: false, api_key: "", model: "openai/gpt-4o-mini" },
zai: { enabled: false, api_key: "", base_url: "https://api.x.ai/v1", model: "grok-2-1212" },
smtp: { enabled: false, host: "", port: 587, username: "", password: "", from_email: "", use_tls: true },
fallback_chain: "google,deepl,openai,ollama,openrouter,openrouter_premium,zai",
fallback_chain_classic: "google,deepl",
fallback_chain_llm: "ollama,openai,openrouter,zai",
@@ -74,6 +88,7 @@ const defaultEnvInfo: EnvInfo = {
zai: false,
ollama: false,
google_cloud: false,
smtp: false,
};
export default function AdminSettingsPage() {
@@ -85,6 +100,13 @@ export default function AdminSettingsPage() {
const [testMessages, setTestMessages] = useState<Record<string, string>>({});
const [ollamaModels, setOllamaModels] = useState<OllamaModel[]>([]);
const [isLoadingModels, setIsLoadingModels] = useState(false);
const [openaiModels, setOpenaiModels] = useState<ModelOption[]>([]);
const [openrouterModels, setOpenrouterModels] = useState<ModelOption[]>([]);
const [zaiModels, setZaiModels] = useState<ModelOption[]>([]);
const [loadingModelsProvider, setLoadingModelsProvider] = useState<string | null>(null);
const [isSendingTestEmail, setIsSendingTestEmail] = useState(false);
const [testEmailResult, setTestEmailResult] = useState<"idle" | "ok" | "error">("idle");
const [testEmailMessage, setTestEmailMessage] = useState<string>("");
const { success, error, info } = useNotification();
useEffect(() => {
@@ -147,11 +169,19 @@ export default function AdminSettingsPage() {
setTestResults((prev) => ({ ...prev, [provider]: "testing" }));
setTestMessages((prev) => ({ ...prev, [provider]: "" }));
try {
// For SMTP, send current form values so unsaved changes are tested
const smtpBody = provider === "smtp"
? { body: JSON.stringify(config.smtp) }
: {};
const response = await fetch(
`${API_BASE}/api/v1/admin/providers/${provider}/test`,
{
method: "POST",
headers: { Authorization: `Bearer ${getToken()}` },
headers: {
Authorization: `Bearer ${getToken()}`,
...(provider === "smtp" ? { "Content-Type": "application/json" } : {}),
},
...smtpBody,
}
);
const data = await response.json();
@@ -174,10 +204,12 @@ export default function AdminSettingsPage() {
const fetchOllamaModels = async () => {
setIsLoadingModels(true);
try {
const response = await fetch(
`${API_BASE}/api/v1/admin/providers/ollama/models`,
{ headers: { Authorization: `Bearer ${getToken()}` } }
);
const url = config.ollama.base_url
? `${API_BASE}/api/v1/admin/providers/ollama/models?base_url=${encodeURIComponent(config.ollama.base_url)}`
: `${API_BASE}/api/v1/admin/providers/ollama/models`;
const response = await fetch(url, {
headers: { Authorization: `Bearer ${getToken()}` },
});
if (response.ok) {
const data = await response.json();
setOllamaModels(data.data || []);
@@ -195,7 +227,32 @@ export default function AdminSettingsPage() {
}
};
type ProviderKey = keyof Omit<SettingsConfig, "fallback_chain" | "fallback_chain_classic" | "fallback_chain_llm">;
const fetchModels = async (provider: string) => {
setLoadingModelsProvider(provider);
try {
const response = await fetch(
`${API_BASE}/api/v1/admin/providers/${provider}/models`,
{ headers: { Authorization: `Bearer ${getToken()}` } }
);
if (response.ok) {
const data = await response.json();
const models: ModelOption[] = data.data || [];
if (provider === "openai") setOpenaiModels(models);
else if (provider === "openrouter") setOpenrouterModels(models);
else if (provider === "zai") setZaiModels(models);
info({ title: `${models.length} modèles ${provider} trouvés` });
} else {
const body = await response.json().catch(() => ({}));
error({ title: `Erreur ${provider}`, description: body.message || `HTTP ${response.status}` });
}
} catch (e) {
error({ title: `Erreur ${provider}`, description: "Impossible de contacter le serveur." });
} finally {
setLoadingModelsProvider(null);
}
};
type ProviderKey = keyof Omit<SettingsConfig, "fallback_chain" | "fallback_chain_classic" | "fallback_chain_llm" | "smtp">;
const updateProvider = (provider: ProviderKey, updates: Partial<ProviderConfig>) => {
setConfig((prev) => ({
...prev,
@@ -203,6 +260,45 @@ export default function AdminSettingsPage() {
}));
};
const updateSmtp = (updates: Partial<SmtpConfig>) => {
setConfig((prev) => ({
...prev,
smtp: { ...prev.smtp, ...updates },
}));
};
const sendTestEmail = async () => {
setIsSendingTestEmail(true);
setTestEmailResult("idle");
setTestEmailMessage("");
try {
const response = await fetch(
`${API_BASE}/api/v1/admin/providers/smtp/test-send`,
{
method: "POST",
headers: {
Authorization: `Bearer ${getToken()}`,
"Content-Type": "application/json",
},
body: JSON.stringify(config.smtp),
}
);
const data = await response.json();
if (data.available) {
setTestEmailResult("ok");
setTestEmailMessage(data.test_result || "Email envoyé avec succès");
} else {
setTestEmailResult("error");
setTestEmailMessage(data.error || "Échec de l'envoi");
}
} catch {
setTestEmailResult("error");
setTestEmailMessage("Erreur réseau");
} finally {
setIsSendingTestEmail(false);
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
@@ -298,15 +394,29 @@ export default function AdminSettingsPage() {
testMessage={testMessages.openai}
envKeySet={envInfo.openai}
>
<div className="space-y-2">
<Label htmlFor="openai-key">Clé API</Label>
<Input
id="openai-key"
type="password"
placeholder={envInfo.openai ? "Clé configurée dans .env (laisser vide pour l'utiliser)" : "sk-..."}
value={config.openai.api_key || ""}
onChange={(e) => updateProvider("openai", { api_key: e.target.value })}
/>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="openai-key">Clé API</Label>
<Input
id="openai-key"
type="password"
placeholder={envInfo.openai ? "Clé configurée dans .env (laisser vide pour l'utiliser)" : "sk-..."}
value={config.openai.api_key || ""}
onChange={(e) => updateProvider("openai", { api_key: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="openai-model">Modèle</Label>
<ModelCombobox
value={config.openai.model || ""}
onChange={(v) => updateProvider("openai", { model: v })}
models={openaiModels}
isLoading={loadingModelsProvider === "openai"}
onFetchModels={() => fetchModels("openai")}
providerLabel="OpenAI"
placeholder="gpt-4o"
/>
</div>
</div>
</ProviderCard>
@@ -409,11 +519,14 @@ export default function AdminSettingsPage() {
</div>
<div className="space-y-2">
<Label htmlFor="openrouter-model">Modèle Essentiel</Label>
<Input
id="openrouter-model"
placeholder="deepseek/deepseek-chat"
<ModelCombobox
value={config.openrouter.model || ""}
onChange={(e) => updateProvider("openrouter", { model: e.target.value })}
onChange={(v) => updateProvider("openrouter", { model: v })}
models={openrouterModels}
isLoading={loadingModelsProvider === "openrouter"}
onFetchModels={() => fetchModels("openrouter")}
providerLabel="OpenRouter"
placeholder="deepseek/deepseek-chat"
/>
<p className="text-xs text-muted-foreground">Recommandé : <code>deepseek/deepseek-chat</code> (~0.04/doc)</p>
</div>
@@ -432,11 +545,14 @@ export default function AdminSettingsPage() {
>
<div className="space-y-2">
<Label htmlFor="openrouter-premium-model">Modèle Premium</Label>
<Input
id="openrouter-premium-model"
placeholder="openai/gpt-4o-mini"
<ModelCombobox
value={config.openrouter_premium.model || ""}
onChange={(e) => updateProvider("openrouter_premium", { model: e.target.value })}
onChange={(v) => updateProvider("openrouter_premium", { model: v })}
models={openrouterModels}
isLoading={loadingModelsProvider === "openrouter"}
onFetchModels={() => fetchModels("openrouter")}
providerLabel="OpenRouter"
placeholder="openai/gpt-4o-mini"
/>
<p className="text-xs text-muted-foreground">
Recommandé : <code>openai/gpt-4o-mini</code> (~0.15/doc) ou <code>anthropic/claude-3.5-haiku</code> (~0.20/doc)
@@ -467,11 +583,14 @@ export default function AdminSettingsPage() {
</div>
<div className="space-y-2">
<Label htmlFor="zai-model">Modèle</Label>
<Input
id="zai-model"
placeholder="grok-2-1212"
<ModelCombobox
value={config.zai.model || ""}
onChange={(e) => updateProvider("zai", { model: e.target.value })}
onChange={(v) => updateProvider("zai", { model: v })}
models={zaiModels}
isLoading={loadingModelsProvider === "zai"}
onFetchModels={() => fetchModels("zai")}
providerLabel="xAI"
placeholder="grok-2-1212"
/>
</div>
</div>
@@ -513,6 +632,143 @@ export default function AdminSettingsPage() {
</div>
</CardContent>
</Card>
<Card className={config.smtp.enabled ? "border-primary/30 overflow-visible" : "overflow-visible"}>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-blue-600/20 rounded-md flex items-center justify-center">
<Mail className="w-4 h-4 text-blue-400" />
</div>
<CardTitle className="text-base">Email SMTP</CardTitle>
<Badge variant={config.smtp.enabled ? "default" : "secondary"} className="text-xs">
{config.smtp.enabled ? "Activé" : "Désactivé"}
</Badge>
{envInfo.smtp && (
<Badge variant="outline" className="text-xs gap-1 border-green-500/40 text-green-400">
<KeyRound className="size-3" />
Config dans .env
</Badge>
)}
</div>
<div className="flex items-center gap-3">
<Button
variant="outline"
size="sm"
onClick={() => testProvider("smtp")}
disabled={testResults.smtp === "testing"}
className="h-8"
>
{testResults.smtp === "testing" ? (
<><Loader2 className="size-3 animate-spin mr-1" />Test...</>
) : testResults.smtp === "ok" ? (
<><CheckCircle className="size-3 text-green-500 mr-1" />OK</>
) : testResults.smtp === "error" ? (
<><XCircle className="size-3 text-red-500 mr-1" />Erreur</>
) : (
<><FlaskConical className="size-3 mr-1" />Tester</>
)}
</Button>
<Switch checked={config.smtp.enabled} onCheckedChange={(enabled) => updateSmtp({ enabled })} />
</div>
</div>
<CardDescription>
Configuration du serveur SMTP pour l&apos;envoi d&apos;emails (mot de passe oublié, notifications, etc.)
</CardDescription>
{testMessages.smtp && (
<p className={`text-xs mt-1 ${testResults.smtp === "ok" ? "text-green-400" : "text-red-400"}`}>
{testMessages.smtp}
</p>
)}
</CardHeader>
<CardContent className="pt-0 space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="smtp-host">Hôte SMTP</Label>
<Input
id="smtp-host"
placeholder={envInfo.smtp ? "Configuré dans .env" : "smtp.example.com"}
value={config.smtp.host || ""}
onChange={(e) => updateSmtp({ host: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="smtp-port">Port</Label>
<Input
id="smtp-port"
type="number"
placeholder="587"
value={config.smtp.port}
onChange={(e) => updateSmtp({ port: parseInt(e.target.value) || 587 })}
/>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="smtp-username">Nom d&apos;utilisateur</Label>
<Input
id="smtp-username"
placeholder={envInfo.smtp ? "Configuré dans .env" : "user@example.com"}
value={config.smtp.username || ""}
onChange={(e) => updateSmtp({ username: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="smtp-password">Mot de passe</Label>
<Input
id="smtp-password"
type="password"
placeholder="••••••••"
value={config.smtp.password || ""}
onChange={(e) => updateSmtp({ password: e.target.value })}
/>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="smtp-from">Adresse d&apos;expédition</Label>
<Input
id="smtp-from"
type="email"
placeholder={envInfo.smtp ? "Configuré dans .env" : "noreply@example.com"}
value={config.smtp.from_email || ""}
onChange={(e) => updateSmtp({ from_email: e.target.value })}
/>
</div>
<div className="flex items-center gap-3 pt-6">
<Switch
checked={config.smtp.use_tls}
onCheckedChange={(checked) => updateSmtp({ use_tls: checked })}
/>
<Label>Utiliser TLS</Label>
</div>
</div>
<div className="flex items-center gap-3 pt-2">
<Button
variant="outline"
size="sm"
onClick={sendTestEmail}
disabled={isSendingTestEmail || !config.smtp.enabled}
className="h-8"
>
{isSendingTestEmail ? (
<><Loader2 className="size-3 animate-spin mr-1" />Envoi...</>
) : testEmailResult === "ok" ? (
<><CheckCircle className="size-3 text-green-500 mr-1" />Envoyé</>
) : testEmailResult === "error" ? (
<><XCircle className="size-3 text-red-500 mr-1" />Échec</>
) : (
<><Mail className="size-3 mr-1" />Envoyer un email de test</>
)}
</Button>
{testEmailMessage && (
<p className={`text-xs ${testEmailResult === "ok" ? "text-green-400" : "text-red-400"}`}>
{testEmailMessage}
</p>
)}
</div>
</CardContent>
</Card>
</div>
<div className="flex justify-end">
@@ -558,7 +814,7 @@ function ProviderCard({
children?: React.ReactNode;
}) {
return (
<Card className={enabled ? "border-primary/30" : ""}>
<Card className={enabled ? "border-primary/30 overflow-visible" : "overflow-visible"}>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">

View File

@@ -0,0 +1,157 @@
'use client';
import { useState, Suspense } from 'react';
import Link from 'next/link';
import { Mail, Loader2, Languages, ArrowLeft, CheckCircle } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { apiClient } from '@/lib/apiClient';
function ForgotPasswordForm() {
const [email, setEmail] = useState('');
const [loading, setLoading] = useState(false);
const [sent, setSent] = useState(false);
const [error, setError] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (!email) {
setError('Veuillez entrer votre adresse email');
return;
}
setLoading(true);
try {
await apiClient.post('/api/v1/auth/forgot-password', { email });
setSent(true);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Une erreur s'est produite";
setError(message);
} finally {
setLoading(false);
}
};
return (
<Card variant="elevated" className="w-full max-w-md mx-auto" hover={false}>
<CardHeader className="text-center pb-6">
<Link href="/" className="inline-flex items-center gap-3 mb-6 group">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary text-primary-foreground shadow-lg group-hover:shadow-xl group-hover:shadow-primary/25 transition-all duration-300 group-hover:-translate-y-0.5">
<Languages className="h-6 w-6" />
</div>
<span className="text-2xl font-semibold text-foreground group-hover:text-primary transition-colors duration-300">
Office Translator
</span>
</Link>
<CardTitle className="text-2xl font-bold">Mot de passe oublie</CardTitle>
<CardDescription>
{sent
? 'Verifiez votre boite mail'
: 'Entrez votre email pour recevoir un lien de reinitialisation'}
</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
{sent ? (
<div className="rounded-lg bg-green-50 border border-green-200 p-4 dark:bg-green-950/20 dark:border-green-900">
<div className="flex items-start gap-3">
<CheckCircle className="h-5 w-5 text-green-600 flex-shrink-0 mt-0.5" />
<p className="text-sm text-green-800 dark:text-green-200">
Si un compte existe avec cette adresse, un email de reinitialisation a ete envoye.
</p>
</div>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="rounded-lg bg-destructive/10 border border-destructive/30 p-4">
<p className="text-sm text-destructive">{error}</p>
</div>
)}
<div className="space-y-2">
<Label htmlFor="email">Adresse email</Label>
<Input
id="email"
type="email"
placeholder="vous@exemple.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
leftIcon={<Mail className="h-4 w-4" />}
required
autoComplete="email"
/>
</div>
<Button
type="submit"
variant="premium"
size="lg"
className="w-full"
disabled={loading || !email}
loading={loading}
>
{loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Envoi en cours...
</>
) : (
'Envoyer le lien de reinitialisation'
)}
</Button>
</form>
)}
<p className="text-center text-sm text-muted-foreground">
<Link href="/auth/login" className="text-primary hover:underline font-medium inline-flex items-center gap-1">
<ArrowLeft className="h-3 w-3" />
Retour a la connexion
</Link>
</p>
</CardContent>
</Card>
);
}
function LoadingFallback() {
return (
<div className="w-full max-w-md mx-auto">
<div className="rounded-xl bg-card border border-border shadow-lg p-8">
<div className="flex flex-col items-center justify-center py-8 space-y-4">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary text-primary-foreground">
<Languages className="h-6 w-6" />
</div>
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<p className="text-muted-foreground">Chargement...</p>
</div>
</div>
</div>
);
}
export default function ForgotPasswordPage() {
return (
<div className="min-h-screen bg-gradient-to-br from-surface via-surface-elevated to-background flex items-center justify-center p-4 relative overflow-hidden">
<div className="absolute inset-0">
<div className="absolute inset-0 bg-gradient-to-br from-primary/5 via-transparent to-accent/5" />
<div className="absolute inset-0 bg-[url('/grid.svg')] opacity-5" />
<div className="absolute inset-0 bg-gradient-to-t from-background via-background/90 to-background/50" />
</div>
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<div className="absolute top-20 left-10 w-32 h-32 bg-primary/5 rounded-full blur-3xl animate-pulse" />
<div className="absolute bottom-20 right-20 w-24 h-24 bg-accent/5 rounded-full blur-2xl animate-pulse" />
</div>
<Suspense fallback={<LoadingFallback />}>
<ForgotPasswordForm />
</Suspense>
</div>
);
}

View File

@@ -27,7 +27,10 @@ function validateEmail(email: string) {
}
function validatePassword(password: string) {
return password.length >= 8;
return password.length >= 8
&& /[A-Z]/.test(password)
&& /[a-z]/.test(password)
&& /[0-9]/.test(password);
}
function getPasswordStrength(password: string) {
@@ -79,7 +82,7 @@ export function RegisterForm() {
: undefined;
const passwordError = touched.password && password.length > 0 && !validatePassword(password)
? 'Mot de passe trop court (minimum 8 caractères)'
? 'Le mot de passe doit contenir au moins 8 caractères, une majuscule, une minuscule et un chiffre'
: undefined;
const confirmError = touched.confirmPassword && confirmPassword.length > 0 && password !== confirmPassword

View File

@@ -0,0 +1,253 @@
'use client';
import { useState, Suspense } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import Link from 'next/link';
import { Lock, Loader2, Languages, Eye, EyeOff, CheckCircle, ArrowLeft } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { apiClient } from '@/lib/apiClient';
function validatePassword(password: string) {
return password.length >= 8
&& /[A-Z]/.test(password)
&& /[a-z]/.test(password)
&& /[0-9]/.test(password);
}
function ResetPasswordForm() {
const router = useRouter();
const searchParams = useSearchParams();
const token = searchParams.get('token');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [showConfirm, setShowConfirm] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState(false);
const passwordError = password.length > 0 && !validatePassword(password)
? 'Le mot de passe doit contenir au moins 8 caracteres, une majuscule, une minuscule et un chiffre'
: '';
const confirmError = confirmPassword.length > 0 && password !== confirmPassword
? 'Les mots de passe ne correspondent pas'
: '';
const isFormValid = validatePassword(password) && password === confirmPassword;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (!token) {
setError('Token manquant. Veuillez utiliser le lien recu par email.');
return;
}
if (!isFormValid) return;
setLoading(true);
try {
await apiClient.post('/api/v1/auth/reset-password', { token, password });
setSuccess(true);
setTimeout(() => {
router.push('/auth/login');
}, 3000);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Une erreur s'est produite";
setError(message);
} finally {
setLoading(false);
}
};
if (!token) {
return (
<Card variant="elevated" className="w-full max-w-md mx-auto" hover={false}>
<CardHeader className="text-center pb-6">
<Link href="/" className="inline-flex items-center gap-3 mb-6 group">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary text-primary-foreground shadow-lg">
<Languages className="h-6 w-6" />
</div>
<span className="text-2xl font-semibold text-foreground">
Office Translator
</span>
</Link>
<CardTitle className="text-2xl font-bold">Lien invalide</CardTitle>
</CardHeader>
<CardContent className="space-y-5">
<div className="rounded-lg bg-destructive/10 border border-destructive/30 p-4">
<p className="text-sm text-destructive">
Ce lien de reinitialisation est invalide. Veuillez redemander un nouveau lien.
</p>
</div>
<p className="text-center text-sm text-muted-foreground">
<Link href="/auth/forgot-password" className="text-primary hover:underline font-medium">
Redemander un lien
</Link>
</p>
</CardContent>
</Card>
);
}
return (
<Card variant="elevated" className="w-full max-w-md mx-auto" hover={false}>
<CardHeader className="text-center pb-6">
<Link href="/" className="inline-flex items-center gap-3 mb-6 group">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary text-primary-foreground shadow-lg group-hover:shadow-xl group-hover:shadow-primary/25 transition-all duration-300 group-hover:-translate-y-0.5">
<Languages className="h-6 w-6" />
</div>
<span className="text-2xl font-semibold text-foreground group-hover:text-primary transition-colors duration-300">
Office Translator
</span>
</Link>
<CardTitle className="text-2xl font-bold">
{success ? 'Mot de passe reinitialise' : 'Nouveau mot de passe'}
</CardTitle>
<CardDescription>
{success
? 'Vous allez etre redirige vers la connexion'
: 'Definissez votre nouveau mot de passe'}
</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
{success ? (
<div className="rounded-lg bg-green-50 border border-green-200 p-4 dark:bg-green-950/20 dark:border-green-900">
<div className="flex items-start gap-3">
<CheckCircle className="h-5 w-5 text-green-600 flex-shrink-0 mt-0.5" />
<p className="text-sm text-green-800 dark:text-green-200">
Votre mot de passe a ete reinitialise avec succes. Vous allez etre redirige vers la page de connexion.
</p>
</div>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="rounded-lg bg-destructive/10 border border-destructive/30 p-4">
<p className="text-sm text-destructive">{error}</p>
</div>
)}
<div className="space-y-2">
<Label htmlFor="password">Nouveau mot de passe</Label>
<div className="relative">
<Input
id="password"
type={showPassword ? 'text' : 'password'}
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
leftIcon={<Lock className="h-4 w-4" />}
error={passwordError}
required
autoComplete="new-password"
/>
</div>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="text-xs text-muted-foreground hover:text-foreground"
>
{showPassword ? 'Masquer' : 'Afficher'} le mot de passe
</button>
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">Confirmer le mot de passe</Label>
<div className="relative">
<Input
id="confirmPassword"
type={showConfirm ? 'text' : 'password'}
placeholder="••••••••"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
leftIcon={<Lock className="h-4 w-4" />}
error={confirmError}
required
autoComplete="new-password"
/>
</div>
<button
type="button"
onClick={() => setShowConfirm(!showConfirm)}
className="text-xs text-muted-foreground hover:text-foreground"
>
{showConfirm ? 'Masquer' : 'Afficher'} le mot de passe
</button>
</div>
<Button
type="submit"
variant="premium"
size="lg"
className="w-full"
disabled={loading || !isFormValid}
loading={loading}
>
{loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Reinitialisation...
</>
) : (
'Reinitialiser le mot de passe'
)}
</Button>
</form>
)}
<p className="text-center text-sm text-muted-foreground">
<Link href="/auth/login" className="text-primary hover:underline font-medium inline-flex items-center gap-1">
<ArrowLeft className="h-3 w-3" />
Retour a la connexion
</Link>
</p>
</CardContent>
</Card>
);
}
function LoadingFallback() {
return (
<div className="w-full max-w-md mx-auto">
<div className="rounded-xl bg-card border border-border shadow-lg p-8">
<div className="flex flex-col items-center justify-center py-8 space-y-4">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary text-primary-foreground">
<Languages className="h-6 w-6" />
</div>
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<p className="text-muted-foreground">Chargement...</p>
</div>
</div>
</div>
);
}
export default function ResetPasswordPage() {
return (
<div className="min-h-screen bg-gradient-to-br from-surface via-surface-elevated to-background flex items-center justify-center p-4 relative overflow-hidden">
<div className="absolute inset-0">
<div className="absolute inset-0 bg-gradient-to-br from-primary/5 via-transparent to-accent/5" />
<div className="absolute inset-0 bg-[url('/grid.svg')] opacity-5" />
<div className="absolute inset-0 bg-gradient-to-t from-background via-background/90 to-background/50" />
</div>
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<div className="absolute top-20 left-10 w-32 h-32 bg-primary/5 rounded-full blur-3xl animate-pulse" />
<div className="absolute bottom-20 right-20 w-24 h-24 bg-accent/5 rounded-full blur-2xl animate-pulse" />
</div>
<Suspense fallback={<LoadingFallback />}>
<ResetPasswordForm />
</Suspense>
</div>
);
}

View File

@@ -0,0 +1,172 @@
"use client";
import React, { useState, useRef, useEffect, useCallback } from "react";
import { Search, List, Loader2, Check } from "lucide-react";
import { Input } from "@/components/ui/input";
export interface ModelOption {
id: string;
name?: string;
owned_by?: string;
context_length?: number;
pricing?: { prompt?: string; completion?: string };
}
interface ModelComboboxProps {
value: string;
onChange: (value: string) => void;
models: ModelOption[];
isLoading: boolean;
onFetchModels: () => void;
providerLabel: string;
placeholder?: string;
}
export function ModelCombobox({
value,
onChange,
models,
isLoading,
onFetchModels,
providerLabel,
placeholder = "nom-du-modele",
}: ModelComboboxProps) {
const [isOpen, setIsOpen] = useState(false);
const [filter, setFilter] = useState("");
const containerRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLUListElement>(null);
const [highlightIndex, setHighlightIndex] = useState(-1);
const filtered = React.useMemo(() => {
if (!filter.trim()) return models;
const q = filter.toLowerCase();
return models.filter(
(m) =>
m.id.toLowerCase().includes(q) ||
(m.name && m.name.toLowerCase().includes(q))
);
}, [models, filter]);
useEffect(() => {
function handleClick(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setIsOpen(false);
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, []);
useEffect(() => { setHighlightIndex(-1); }, [filter]);
useEffect(() => {
if (highlightIndex >= 0 && listRef.current) {
(listRef.current.children[highlightIndex] as HTMLElement)?.scrollIntoView({ block: "nearest" });
}
}, [highlightIndex]);
const handleBrowse = useCallback(() => {
if (!isOpen) onFetchModels();
setIsOpen(!isOpen);
}, [isOpen, onFetchModels]);
const handleSelect = useCallback(
(model: ModelOption) => {
onChange(model.id);
setFilter("");
setIsOpen(false);
},
[onChange]
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (!isOpen || filtered.length === 0) return;
if (e.key === "ArrowDown") { e.preventDefault(); setHighlightIndex((p) => Math.min(p + 1, filtered.length - 1)); }
else if (e.key === "ArrowUp") { e.preventDefault(); setHighlightIndex((p) => Math.max(p - 1, 0)); }
else if (e.key === "Enter" && highlightIndex >= 0) { e.preventDefault(); handleSelect(filtered[highlightIndex]); }
else if (e.key === "Escape") { setIsOpen(false); }
},
[isOpen, filtered, highlightIndex, handleSelect]
);
return (
<div ref={containerRef} className="relative">
<div className="flex gap-2">
<div className="flex-1">
<Input
placeholder={placeholder}
value={value}
onChange={(e) => onChange(e.target.value)}
onFocus={() => { if (models.length > 0) setIsOpen(true); }}
onKeyDown={handleKeyDown}
leftIcon={<Search className="size-4" />}
loading={isLoading}
/>
</div>
<button
type="button"
onClick={handleBrowse}
disabled={isLoading}
className="flex items-center gap-1.5 px-3 h-10 rounded-lg border border-border-subtle bg-surface text-sm text-muted-foreground hover:bg-surface-elevated hover:text-foreground transition-colors disabled:opacity-50"
>
{isLoading ? <Loader2 className="size-4 animate-spin" /> : <List className="size-4" />}
<span className="hidden sm:inline">Parcourir</span>
</button>
</div>
{isOpen && (
<div className="absolute z-50 mt-1 w-full rounded-lg border border-border-subtle bg-card shadow-xl max-h-72 overflow-hidden flex flex-col">
{/* Filter */}
<div className="p-2 border-b border-border-subtle">
<Input
placeholder={`Filtrer...`}
value={filter}
onChange={(e) => setFilter(e.target.value)}
leftIcon={<Search className="size-3.5" />}
className="h-8 text-xs"
/>
</div>
{/* List */}
<ul ref={listRef} className="overflow-y-auto flex-1">
{filtered.length === 0 ? (
<li className="px-3 py-4 text-xs text-muted-foreground text-center">
{models.length === 0 ? "Aucun modele recupere" : "Aucun modele ne correspond"}
</li>
) : (
filtered.map((model, i) => (
<li
key={model.id}
onClick={() => handleSelect(model)}
onMouseEnter={() => setHighlightIndex(i)}
className={`flex items-center justify-between px-3 py-2 text-xs cursor-pointer transition-colors ${
i === highlightIndex ? "bg-primary/10 text-foreground" : "text-muted-foreground hover:bg-surface-elevated"
} ${value === model.id ? "bg-primary/5" : ""}`}
>
<div className="flex-1 min-w-0">
<div className="font-mono truncate text-foreground">{model.id}</div>
{model.name && model.name !== model.id && (
<div className="text-muted-foreground truncate mt-0.5">{model.name}</div>
)}
{model.context_length && (
<span className="text-muted-foreground">ctx: {(model.context_length / 1000).toFixed(0)}k</span>
)}
</div>
{value === model.id && <Check className="size-3.5 text-primary flex-shrink-0 ml-2" />}
</li>
))
)}
</ul>
{models.length > 0 && (
<div className="px-3 py-1.5 border-t border-border-subtle text-xs text-muted-foreground text-center bg-card">
{filtered.length} modele{filtered.length !== 1 ? "s" : ""}
{filter ? ` sur ${models.length}` : ""} nom libre possible
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -18,10 +18,12 @@ ipykernel==6.27.1
openai>=1.0.0
psutil==5.9.8
python-magic-bin==0.4.14
python-magic>=0.4.27
# python-magic-bin==0.4.14 # Windows only - use python-magic on Linux/macOS
PyJWT==2.8.0
passlib[bcrypt]==1.7.4
bcrypt<4.1
stripe==7.0.0
redis==5.0.1
@@ -33,7 +35,10 @@ alembic==1.13.1
aiosqlite>=0.19.0
asyncpg>=0.29.0
psycopg2-binary>=2.9.0
greenlet>=3.0.0
aiosmtplib>=3.0.0
pytest>=7.0.0
pytest-asyncio>=0.21.0

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

119
services/email_service.py Normal file
View File

@@ -0,0 +1,119 @@
"""
Email service for sending transactional emails via SMTP.
Supports both sync (smtplib) and async (aiosmtplib) sending.
Configuration is resolved from settings JSON file, then env vars.
"""
import os
import logging
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from typing import Optional
logger = logging.getLogger(__name__)
def _get_smtp_config() -> dict:
"""Resolve SMTP config from admin settings file then env vars."""
config = {}
# Read from admin provider settings (same file as /admin/settings page)
try:
import json
from pathlib import Path
settings_path = Path("data/provider_settings.json")
if settings_path.exists():
with open(settings_path, "r") as f:
settings = json.load(f)
smtp = settings.get("smtp", {})
if smtp and smtp.get("host"):
config["host"] = smtp.get("host", "")
config["port"] = smtp.get("port", 587)
config["user"] = smtp.get("username", "")
config["password"] = smtp.get("password", "")
config["from_email"] = smtp.get("from_email", "") or smtp.get("username", "")
config["use_tls"] = smtp.get("use_tls", True)
except Exception:
pass
# Env vars fill gaps
config.setdefault("host", os.getenv("SMTP_HOST", ""))
config.setdefault("port", int(os.getenv("SMTP_PORT", "587")))
config.setdefault("user", os.getenv("SMTP_USER", os.getenv("SMTP_USERNAME", "")))
config.setdefault("password", os.getenv("SMTP_PASSWORD", ""))
config.setdefault("from_email", os.getenv("SMTP_FROM_EMAIL", os.getenv("SMTP_USER", os.getenv("SMTP_USERNAME", ""))))
config.setdefault("use_tls", os.getenv("SMTP_USE_TLS", "true").lower() == "true")
return config
def is_smtp_configured() -> bool:
"""Check if SMTP is properly configured."""
cfg = _get_smtp_config()
return bool(cfg.get("host") and cfg.get("user"))
def send_email(to: str, subject: str, body: str) -> bool:
"""Send an email via SMTP (sync). Returns True if sent successfully."""
cfg = _get_smtp_config()
if not cfg.get("host"):
logger.warning("SMTP not configured, email not sent")
return False
try:
msg = MIMEMultipart("alternative")
msg["Subject"] = subject
msg["From"] = cfg["from_email"]
msg["To"] = to
msg.attach(MIMEText(body, "html"))
port = int(cfg.get("port", 587))
use_tls = cfg.get("use_tls", True)
if use_tls:
server = smtplib.SMTP(cfg["host"], port)
server.starttls()
else:
server = smtplib.SMTP(cfg["host"], port)
if cfg.get("user") and cfg.get("password"):
server.login(cfg["user"], cfg["password"])
server.sendmail(cfg["from_email"], [to], msg.as_string())
server.quit()
logger.info(f"Email sent to {to}: {subject}")
return True
except Exception as e:
logger.error(f"Failed to send email to {to}: {e}")
return False
async def send_email_async(to: str, subject: str, body: str) -> bool:
"""Send an email via SMTP (async). Returns True if sent successfully."""
cfg = _get_smtp_config()
if not cfg.get("host"):
logger.warning("SMTP not configured, email not sent")
return False
try:
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
msg = MIMEMultipart("alternative")
msg["Subject"] = subject
msg["From"] = cfg["from_email"]
msg["To"] = to
msg.attach(MIMEText(body, "html"))
port = int(cfg.get("port", 587))
# Use sync smtplib inside a thread for reliability (aiosmtplib has quirks with raw messages)
import asyncio
return await asyncio.get_event_loop().run_in_executor(
None, send_email, to, subject, body
)
except Exception as e:
logger.error(f"Failed to send email (async) to {to}: {e}")
return False