From 2f7347b4dbcd77b641eaf1cd1558132083eff0fa Mon Sep 17 00:00:00 2001 From: Sepehr Ramezani Date: Fri, 1 May 2026 16:23:51 +0200 Subject: [PATCH] feat: fix registration 500, add forgot-password flow, frontend validation - 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 --- .dockerignore | 75 +++ .github/workflows/deploy.yml | 183 +++++++ .gitignore | 6 + .../versions/005_add_reset_token_to_users.py | 31 ++ data/provider_settings.json | 17 + database/connection.py | 4 +- database/models.py | 3 + deploy.sh | 466 ++++++++++++++++++ docker-compose.local.yml | 180 +++++++ docker/backend/Dockerfile | 4 +- docker/frontend/Dockerfile | 34 +- docker/frontend/server.js | 66 +++ docker/nginx/conf.d/default.conf | 6 +- docker/nginx/conf.d/local.conf | 123 +++++ frontend/next.config.ts | 6 +- frontend/package-lock.json | 60 +++ frontend/src/app/admin/settings/page.tsx | 314 ++++++++++-- .../src/app/auth/forgot-password/page.tsx | 157 ++++++ .../src/app/auth/register/RegisterForm.tsx | 7 +- frontend/src/app/auth/reset-password/page.tsx | 253 ++++++++++ frontend/src/components/ui/model-combobox.tsx | 172 +++++++ requirements.txt | 7 +- routes/admin_routes.py | 300 ++++++++++- routes/auth_routes.py | 236 ++++++++- services/email_service.py | 119 +++++ 25 files changed, 2765 insertions(+), 64 deletions(-) create mode 100644 .dockerignore create mode 100644 .github/workflows/deploy.yml create mode 100644 alembic/versions/005_add_reset_token_to_users.py create mode 100755 deploy.sh create mode 100644 docker-compose.local.yml create mode 100644 docker/frontend/server.js create mode 100644 docker/nginx/conf.d/local.conf create mode 100644 frontend/src/app/auth/forgot-password/page.tsx create mode 100644 frontend/src/app/auth/reset-password/page.tsx create mode 100644 frontend/src/components/ui/model-combobox.tsx create mode 100644 services/email_service.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..356cfeb --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..61b7b7a --- /dev/null +++ b/.github/workflows/deploy.yml @@ -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 diff --git a/.gitignore b/.gitignore index 78a400f..1608cb8 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,12 @@ ENV/ # Environment variables .env +.env.docker +.env.production +.env.ionos + +# Backups +backups/ # IDE .vscode/ diff --git a/alembic/versions/005_add_reset_token_to_users.py b/alembic/versions/005_add_reset_token_to_users.py new file mode 100644 index 0000000..64f8e68 --- /dev/null +++ b/alembic/versions/005_add_reset_token_to_users.py @@ -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) diff --git a/data/provider_settings.json b/data/provider_settings.json index 4533c9e..be270a5 100644 --- a/data/provider_settings.json +++ b/data/provider_settings.json @@ -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" diff --git a/database/connection.py b/database/connection.py index 4af70b3..de750c0 100644 --- a/database/connection.py +++ b/database/connection.py @@ -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, diff --git a/database/models.py b/database/models.py index dd1b1fb..772ed21 100644 --- a/database/models.py +++ b/database/models.py @@ -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) diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..046b37e --- /dev/null +++ b/deploy.sh @@ -0,0 +1,466 @@ +#!/bin/bash +# ============================================================ +# Office Translator - Deployment Script +# ============================================================ +# Usage: ./deploy.sh [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 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 [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 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 diff --git a/docker-compose.local.yml b/docker-compose.local.yml new file mode 100644 index 0000000..e0f7377 --- /dev/null +++ b/docker-compose.local.yml @@ -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 diff --git a/docker/backend/Dockerfile b/docker/backend/Dockerfile index 0769242..cc994d5 100644 --- a/docker/backend/Dockerfile +++ b/docker/backend/Dockerfile @@ -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 diff --git a/docker/frontend/Dockerfile b/docker/frontend/Dockerfile index 78ae581..4bcd936 100644 --- a/docker/frontend/Dockerfile +++ b/docker/frontend/Dockerfile @@ -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 diff --git a/docker/frontend/server.js b/docker/frontend/server.js new file mode 100644 index 0000000..116c3d7 --- /dev/null +++ b/docker/frontend/server.js @@ -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}`); + }); +}); diff --git a/docker/nginx/conf.d/default.conf b/docker/nginx/conf.d/default.conf index b82e094..c5f7285 100644 --- a/docker/nginx/conf.d/default.conf +++ b/docker/nginx/conf.d/default.conf @@ -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; diff --git a/docker/nginx/conf.d/local.conf b/docker/nginx/conf.d/local.conf new file mode 100644 index 0000000..27ca89d --- /dev/null +++ b/docker/nginx/conf.d/local.conf @@ -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"; + } +} diff --git a/frontend/next.config.ts b/frontend/next.config.ts index f811170..6a1a600 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -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"], diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 705a211..dee306e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/src/app/admin/settings/page.tsx b/frontend/src/app/admin/settings/page.tsx index c5289eb..07fb6b3 100644 --- a/frontend/src/app/admin/settings/page.tsx +++ b/frontend/src/app/admin/settings/page.tsx @@ -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>({}); const [ollamaModels, setOllamaModels] = useState([]); const [isLoadingModels, setIsLoadingModels] = useState(false); + const [openaiModels, setOpenaiModels] = useState([]); + const [openrouterModels, setOpenrouterModels] = useState([]); + const [zaiModels, setZaiModels] = useState([]); + const [loadingModelsProvider, setLoadingModelsProvider] = useState(null); + const [isSendingTestEmail, setIsSendingTestEmail] = useState(false); + const [testEmailResult, setTestEmailResult] = useState<"idle" | "ok" | "error">("idle"); + const [testEmailMessage, setTestEmailMessage] = useState(""); 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; + 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; const updateProvider = (provider: ProviderKey, updates: Partial) => { setConfig((prev) => ({ ...prev, @@ -203,6 +260,45 @@ export default function AdminSettingsPage() { })); }; + const updateSmtp = (updates: Partial) => { + 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 (
@@ -298,15 +394,29 @@ export default function AdminSettingsPage() { testMessage={testMessages.openai} envKeySet={envInfo.openai} > -
- - updateProvider("openai", { api_key: e.target.value })} - /> +
+
+ + updateProvider("openai", { api_key: e.target.value })} + /> +
+
+ + updateProvider("openai", { model: v })} + models={openaiModels} + isLoading={loadingModelsProvider === "openai"} + onFetchModels={() => fetchModels("openai")} + providerLabel="OpenAI" + placeholder="gpt-4o" + /> +
@@ -409,11 +519,14 @@ export default function AdminSettingsPage() {
- 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" />

Recommandé : deepseek/deepseek-chat (~€0.04/doc)

@@ -432,11 +545,14 @@ export default function AdminSettingsPage() { >
- 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" />

Recommandé : openai/gpt-4o-mini (~€0.15/doc) ou anthropic/claude-3.5-haiku (~€0.20/doc) @@ -467,11 +583,14 @@ export default function AdminSettingsPage() {

- 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" />
@@ -513,6 +632,143 @@ export default function AdminSettingsPage() { + + + +
+
+
+ +
+ Email SMTP + + {config.smtp.enabled ? "Activé" : "Désactivé"} + + {envInfo.smtp && ( + + + Config dans .env + + )} +
+
+ + updateSmtp({ enabled })} /> +
+
+ + Configuration du serveur SMTP pour l'envoi d'emails (mot de passe oublié, notifications, etc.) + + {testMessages.smtp && ( +

+ {testMessages.smtp} +

+ )} +
+ +
+
+ + updateSmtp({ host: e.target.value })} + /> +
+
+ + updateSmtp({ port: parseInt(e.target.value) || 587 })} + /> +
+
+
+
+ + updateSmtp({ username: e.target.value })} + /> +
+
+ + updateSmtp({ password: e.target.value })} + /> +
+
+
+
+ + updateSmtp({ from_email: e.target.value })} + /> +
+
+ updateSmtp({ use_tls: checked })} + /> + +
+
+
+ + {testEmailMessage && ( +

+ {testEmailMessage} +

+ )} +
+
+
@@ -558,7 +814,7 @@ function ProviderCard({ children?: React.ReactNode; }) { return ( - +
diff --git a/frontend/src/app/auth/forgot-password/page.tsx b/frontend/src/app/auth/forgot-password/page.tsx new file mode 100644 index 0000000..881d065 --- /dev/null +++ b/frontend/src/app/auth/forgot-password/page.tsx @@ -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 ( + + + +
+ +
+ + Office Translator + + + + Mot de passe oublie + + {sent + ? 'Verifiez votre boite mail' + : 'Entrez votre email pour recevoir un lien de reinitialisation'} + +
+ + + {sent ? ( +
+
+ +

+ Si un compte existe avec cette adresse, un email de reinitialisation a ete envoye. +

+
+
+ ) : ( +
+ {error && ( +
+

{error}

+
+ )} + +
+ + setEmail(e.target.value)} + leftIcon={} + required + autoComplete="email" + /> +
+ + +
+ )} + +

+ + + Retour a la connexion + +

+
+
+ ); +} + +function LoadingFallback() { + return ( +
+
+
+
+ +
+ +

Chargement...

+
+
+
+ ); +} + +export default function ForgotPasswordPage() { + return ( +
+
+
+
+
+
+ +
+
+
+
+ + }> + + +
+ ); +} diff --git a/frontend/src/app/auth/register/RegisterForm.tsx b/frontend/src/app/auth/register/RegisterForm.tsx index 8abec13..56994c1 100644 --- a/frontend/src/app/auth/register/RegisterForm.tsx +++ b/frontend/src/app/auth/register/RegisterForm.tsx @@ -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 diff --git a/frontend/src/app/auth/reset-password/page.tsx b/frontend/src/app/auth/reset-password/page.tsx new file mode 100644 index 0000000..978f625 --- /dev/null +++ b/frontend/src/app/auth/reset-password/page.tsx @@ -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 ( + + + +
+ +
+ + Office Translator + + + Lien invalide +
+ +
+

+ Ce lien de reinitialisation est invalide. Veuillez redemander un nouveau lien. +

+
+

+ + Redemander un lien + +

+
+
+ ); + } + + return ( + + + +
+ +
+ + Office Translator + + + + + {success ? 'Mot de passe reinitialise' : 'Nouveau mot de passe'} + + + {success + ? 'Vous allez etre redirige vers la connexion' + : 'Definissez votre nouveau mot de passe'} + +
+ + + {success ? ( +
+
+ +

+ Votre mot de passe a ete reinitialise avec succes. Vous allez etre redirige vers la page de connexion. +

+
+
+ ) : ( +
+ {error && ( +
+

{error}

+
+ )} + +
+ +
+ setPassword(e.target.value)} + leftIcon={} + error={passwordError} + required + autoComplete="new-password" + /> +
+ +
+ +
+ +
+ setConfirmPassword(e.target.value)} + leftIcon={} + error={confirmError} + required + autoComplete="new-password" + /> +
+ +
+ + +
+ )} + +

+ + + Retour a la connexion + +

+
+
+ ); +} + +function LoadingFallback() { + return ( +
+
+
+
+ +
+ +

Chargement...

+
+
+
+ ); +} + +export default function ResetPasswordPage() { + return ( +
+
+
+
+
+
+ +
+
+
+
+ + }> + + +
+ ); +} diff --git a/frontend/src/components/ui/model-combobox.tsx b/frontend/src/components/ui/model-combobox.tsx new file mode 100644 index 0000000..5428318 --- /dev/null +++ b/frontend/src/components/ui/model-combobox.tsx @@ -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(null); + const listRef = useRef(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 ( +
+
+
+ onChange(e.target.value)} + onFocus={() => { if (models.length > 0) setIsOpen(true); }} + onKeyDown={handleKeyDown} + leftIcon={} + loading={isLoading} + /> +
+ +
+ + {isOpen && ( +
+ {/* Filter */} +
+ setFilter(e.target.value)} + leftIcon={} + className="h-8 text-xs" + /> +
+ + {/* List */} +
    + {filtered.length === 0 ? ( +
  • + {models.length === 0 ? "Aucun modele recupere" : "Aucun modele ne correspond"} +
  • + ) : ( + filtered.map((model, i) => ( +
  • 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" : ""}`} + > +
    +
    {model.id}
    + {model.name && model.name !== model.id && ( +
    {model.name}
    + )} + {model.context_length && ( + ctx: {(model.context_length / 1000).toFixed(0)}k + )} +
    + {value === model.id && } +
  • + )) + )} +
+ + {models.length > 0 && ( +
+ {filtered.length} modele{filtered.length !== 1 ? "s" : ""} + {filter ? ` sur ${models.length}` : ""} — nom libre possible +
+ )} +
+ )} +
+ ); +} diff --git a/requirements.txt b/requirements.txt index fbff7db..c8603f3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/routes/admin_routes.py b/routes/admin_routes.py index 4754bec..fd81fb1 100644 --- a/routes/admin_routes.py +++ b/routes/admin_routes.py @@ -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) # ============================================================ diff --git a/routes/auth_routes.py b/routes/auth_routes.py index 2ee579c..5743b53 100644 --- a/routes/auth_routes.py +++ b/routes/auth_routes.py @@ -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""" + + +

Reinitialisation de votre mot de passe

+

Vous avez demande la reinitialisation de votre mot de passe.

+

Cliquez sur le lien ci-dessous pour definir un nouveau mot de passe :

+

Reinitialiser mon mot de passe

+

Ce lien expire dans 1 heure.

+

Si vous n'avez pas fait cette demande, ignorez cet email.

+ + + """ + + 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) ============== diff --git a/services/email_service.py b/services/email_service.py new file mode 100644 index 0000000..08d864a --- /dev/null +++ b/services/email_service.py @@ -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