feat: fix registration 500, add forgot-password flow, frontend validation
Some checks failed
Some checks failed
- Fix MissingGreenlet: sync_engine now uses psycopg2 instead of asyncpg - Fix bcrypt/passlib compat: pin bcrypt<4.1 in requirements - Fix legacy password_hash NOT NULL: alter column to nullable in migration - Add frontend password validation (uppercase + lowercase + digit) - Add forgot-password and reset-password backend endpoints - Add forgot-password and reset-password frontend pages - Add email_service.py (SMTP via admin settings) - Add reset_token/reset_token_expires columns to User model - Migrate legacy JSON-only users to DB on password reset request - Mount data/ volume in docker-compose.local.yml for persistence - Add production deployment config (Dockerfile, nginx, deploy.sh) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
75
.dockerignore
Normal file
75
.dockerignore
Normal 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
183
.github/workflows/deploy.yml
vendored
Normal 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
6
.gitignore
vendored
@@ -27,6 +27,12 @@ ENV/
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.docker
|
||||
.env.production
|
||||
.env.ionos
|
||||
|
||||
# Backups
|
||||
backups/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
|
||||
31
alembic/versions/005_add_reset_token_to_users.py
Normal file
31
alembic/versions/005_add_reset_token_to_users.py
Normal 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)
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
466
deploy.sh
Executable 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
180
docker-compose.local.yml
Normal 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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
66
docker/frontend/server.js
Normal 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}`);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
123
docker/nginx/conf.d/local.conf
Normal file
123
docker/nginx/conf.d/local.conf
Normal 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";
|
||||
}
|
||||
}
|
||||
@@ -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"],
|
||||
|
||||
60
frontend/package-lock.json
generated
60
frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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'envoi d'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'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'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">
|
||||
|
||||
157
frontend/src/app/auth/forgot-password/page.tsx
Normal file
157
frontend/src/app/auth/forgot-password/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
253
frontend/src/app/auth/reset-password/page.tsx
Normal file
253
frontend/src/app/auth/reset-password/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
172
frontend/src/components/ui/model-combobox.tsx
Normal file
172
frontend/src/components/ui/model-combobox.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -789,6 +789,16 @@ class ProviderSettings(BaseModel):
|
||||
max_retries: int = 3
|
||||
|
||||
|
||||
class SmtpSettings(BaseModel):
|
||||
enabled: bool = False
|
||||
host: Optional[str] = None # SMTP_HOST
|
||||
port: int = 587 # SMTP_PORT
|
||||
username: Optional[str] = None # SMTP_USERNAME
|
||||
password: Optional[str] = None # SMTP_PASSWORD
|
||||
from_email: Optional[str] = None # SMTP_FROM_EMAIL
|
||||
use_tls: bool = True
|
||||
|
||||
|
||||
class SettingsConfig(BaseModel):
|
||||
google: ProviderSettings = ProviderSettings(enabled=True)
|
||||
google_cloud: ProviderSettings = ProviderSettings() # Cloud Translation API v2 (clé API)
|
||||
@@ -798,6 +808,7 @@ class SettingsConfig(BaseModel):
|
||||
openrouter: ProviderSettings = ProviderSettings() # "Traduction IA Essentielle"
|
||||
openrouter_premium: ProviderSettings = ProviderSettings() # "Traduction IA Premium"
|
||||
zai: ProviderSettings = ProviderSettings()
|
||||
smtp: SmtpSettings = SmtpSettings()
|
||||
fallback_chain: str = "google,google_cloud,deepl,openai,ollama,openrouter,openrouter_premium,zai"
|
||||
fallback_chain_classic: str = "google,deepl"
|
||||
fallback_chain_llm: str = "openrouter,openrouter_premium,openai,zai,ollama"
|
||||
@@ -868,6 +879,26 @@ async def get_settings(admin_id: str = Depends(require_admin)):
|
||||
payload["ollama"] = _merge_env(settings.ollama, url_env="OLLAMA_BASE_URL", model_env="OLLAMA_MODEL", default_url="http://localhost:11434", default_model="llama3")
|
||||
payload["google_cloud"] = _merge_env(settings.google_cloud, key_env="GOOGLE_CLOUD_API_KEY")
|
||||
|
||||
# SMTP: merge from env vars, but never expose password
|
||||
smtp_data = settings.smtp.model_dump()
|
||||
if not smtp_data["host"]:
|
||||
smtp_data["host"] = os.getenv("SMTP_HOST", "").strip() or None
|
||||
if not smtp_data["username"]:
|
||||
smtp_data["username"] = os.getenv("SMTP_USERNAME", "").strip() or None
|
||||
smtp_data["password"] = None # never expose
|
||||
if not smtp_data["from_email"]:
|
||||
smtp_data["from_email"] = os.getenv("SMTP_FROM_EMAIL", "").strip() or None
|
||||
smtp_env_port = os.getenv("SMTP_PORT", "").strip()
|
||||
if smtp_env_port and smtp_data["port"] == 587:
|
||||
try:
|
||||
smtp_data["port"] = int(smtp_env_port)
|
||||
except ValueError:
|
||||
pass
|
||||
smtp_env_tls = os.getenv("SMTP_USE_TLS", "").strip().lower()
|
||||
if smtp_env_tls in ("false", "0", "no"):
|
||||
smtp_data["use_tls"] = False
|
||||
payload["smtp"] = smtp_data
|
||||
|
||||
# Inform the frontend which providers have API keys configured via env vars
|
||||
# (boolean only — never expose actual values)
|
||||
has_openrouter = bool(os.getenv("OPENROUTER_API_KEY", "").strip())
|
||||
@@ -879,6 +910,7 @@ async def get_settings(admin_id: str = Depends(require_admin)):
|
||||
"zai": bool(os.getenv("ZAI_API_KEY", "").strip()),
|
||||
"ollama": bool(os.getenv("OLLAMA_BASE_URL", "").strip()),
|
||||
"google_cloud": bool(os.getenv("GOOGLE_CLOUD_API_KEY", "").strip()),
|
||||
"smtp": bool(os.getenv("SMTP_HOST", "").strip()),
|
||||
}
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
@@ -890,6 +922,14 @@ async def get_settings(admin_id: str = Depends(require_admin)):
|
||||
async def update_settings(
|
||||
settings: SettingsConfig, admin_id: str = Depends(require_admin)
|
||||
):
|
||||
# Preserve SMTP password: frontend always sends null (never exposed via GET)
|
||||
existing = load_settings()
|
||||
if settings.smtp.password is None and existing.smtp.password:
|
||||
settings.smtp.password = existing.smtp.password
|
||||
# If frontend sends a new non-empty password, keep it; if empty string, clear it
|
||||
if settings.smtp.password is not None:
|
||||
settings.smtp.password = settings.smtp.password.strip() or None
|
||||
|
||||
save_settings(settings)
|
||||
logger.info(f"admin_settings_updated by {admin_id}")
|
||||
return JSONResponse(
|
||||
@@ -897,10 +937,25 @@ async def update_settings(
|
||||
)
|
||||
|
||||
|
||||
class SmtpTestRequest(BaseModel):
|
||||
"""Optional body for SMTP test — allows testing unsaved form values."""
|
||||
host: Optional[str] = None
|
||||
port: Optional[int] = None
|
||||
username: Optional[str] = None
|
||||
password: Optional[str] = None
|
||||
from_email: Optional[str] = None
|
||||
use_tls: Optional[bool] = None
|
||||
|
||||
|
||||
@router.post("/providers/{provider}/test")
|
||||
async def test_provider(provider: str, admin_id: str = Depends(require_admin)):
|
||||
async def test_provider(
|
||||
provider: str,
|
||||
admin_id: str = Depends(require_admin),
|
||||
smtp_body: Optional[SmtpTestRequest] = None,
|
||||
):
|
||||
"""Test a provider connection. Works even when provider is disabled.
|
||||
Always falls back to env vars when the JSON api_key is empty."""
|
||||
Always falls back to env vars when the JSON api_key is empty.
|
||||
For SMTP, accepts an optional JSON body with current form values."""
|
||||
settings = load_settings()
|
||||
|
||||
provider_config = getattr(settings, provider, None)
|
||||
@@ -1074,6 +1129,54 @@ async def test_provider(provider: str, admin_id: str = Depends(require_admin)):
|
||||
content={"available": False, "error": "Clé xAI invalide"},
|
||||
)
|
||||
|
||||
elif provider == "smtp":
|
||||
import smtplib as _smtplib
|
||||
|
||||
# Priority: request body (form values) > saved settings > env vars
|
||||
host = ((smtp_body and smtp_body.host) or "").strip() or (provider_config.host or "").strip() or os.getenv("SMTP_HOST", "").strip()
|
||||
if not host:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"available": False, "error": "Aucun hôte SMTP configuré (JSON ou .env SMTP_HOST)"},
|
||||
)
|
||||
port = (smtp_body and smtp_body.port) or provider_config.port or 587
|
||||
try:
|
||||
port = int(os.getenv("SMTP_PORT", "").strip() or port)
|
||||
except ValueError:
|
||||
port = 587
|
||||
username = ((smtp_body and smtp_body.username) or "").strip() or (provider_config.username or "").strip() or os.getenv("SMTP_USERNAME", "").strip()
|
||||
password = ((smtp_body and smtp_body.password) or "").strip() or (provider_config.password or "").strip() or os.getenv("SMTP_PASSWORD", "").strip()
|
||||
use_tls = (smtp_body and smtp_body.use_tls) if (smtp_body and smtp_body.use_tls is not None) else (provider_config.use_tls if provider_config.use_tls is not None else True)
|
||||
|
||||
try:
|
||||
server = _smtplib.SMTP(host, port, timeout=10)
|
||||
server.ehlo()
|
||||
if use_tls:
|
||||
server.starttls()
|
||||
server.ehlo()
|
||||
if username and password:
|
||||
server.login(username, password)
|
||||
server.quit()
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={"available": True, "test_result": f"Connexion SMTP OK ({host}:{port})"},
|
||||
)
|
||||
except _smtplib.SMTPAuthenticationError as e:
|
||||
return JSONResponse(
|
||||
status_code=401,
|
||||
content={"available": False, "error": f"Authentification SMTP échouée: {e}"},
|
||||
)
|
||||
except _smtplib.SMTPConnectError as e:
|
||||
return JSONResponse(
|
||||
status_code=502,
|
||||
content={"available": False, "error": f"Connexion SMTP échouée: {e}"},
|
||||
)
|
||||
except Exception as e:
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"available": False, "error": f"Erreur SMTP: {str(e)[:200]}"},
|
||||
)
|
||||
|
||||
else:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
@@ -1087,21 +1190,101 @@ async def test_provider(provider: str, admin_id: str = Depends(require_admin)):
|
||||
)
|
||||
|
||||
|
||||
@router.post("/providers/smtp/test-send")
|
||||
async def test_send_email(
|
||||
smtp_body: Optional[SmtpTestRequest] = None,
|
||||
admin_id: str = Depends(require_admin),
|
||||
):
|
||||
"""Envoie un email de test a l'adresse from_email configuree.
|
||||
Accepte un body JSON optionnel avec les valeurs du formulaire en cours."""
|
||||
import smtplib as _smtplib
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
|
||||
settings = load_settings()
|
||||
smtp = settings.smtp
|
||||
|
||||
# Priority: request body (form values) > saved settings > env vars
|
||||
host = ((smtp_body and smtp_body.host) or "").strip() or (smtp.host or "").strip() or os.getenv("SMTP_HOST", "").strip()
|
||||
if not host:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"available": False, "error": "Aucun hôte SMTP configuré"},
|
||||
)
|
||||
port = (smtp_body and smtp_body.port) or smtp.port or 587
|
||||
try:
|
||||
port = int(os.getenv("SMTP_PORT", "").strip() or port)
|
||||
except ValueError:
|
||||
port = 587
|
||||
username = ((smtp_body and smtp_body.username) or "").strip() or (smtp.username or "").strip() or os.getenv("SMTP_USERNAME", "").strip()
|
||||
password = ((smtp_body and smtp_body.password) or "").strip() or (smtp.password or "").strip() or os.getenv("SMTP_PASSWORD", "").strip()
|
||||
from_email = ((smtp_body and smtp_body.from_email) or "").strip() or (smtp.from_email or "").strip() or os.getenv("SMTP_FROM_EMAIL", "").strip() or username
|
||||
use_tls = (smtp_body and smtp_body.use_tls) if (smtp_body and smtp_body.use_tls is not None) else (smtp.use_tls if smtp.use_tls is not None else True)
|
||||
|
||||
if not from_email:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"available": False, "error": "Aucune adresse d'expédition configurée (from_email ou SMTP_FROM_EMAIL)"},
|
||||
)
|
||||
|
||||
msg = MIMEMultipart()
|
||||
msg["From"] = from_email
|
||||
msg["To"] = from_email
|
||||
msg["Subject"] = "Test Office Translator — Email SMTP"
|
||||
msg.attach(MIMEText(
|
||||
"Ceci est un email de test envoyé depuis la page d'administration Office Translator.\n\n"
|
||||
"Si vous recevez cet email, la configuration SMTP est correcte.",
|
||||
"plain", "utf-8"
|
||||
))
|
||||
|
||||
try:
|
||||
server = _smtplib.SMTP(host, port, timeout=15)
|
||||
server.ehlo()
|
||||
if use_tls:
|
||||
server.starttls()
|
||||
server.ehlo()
|
||||
if username and password:
|
||||
server.login(username, password)
|
||||
server.sendmail(from_email, from_email, msg.as_string())
|
||||
server.quit()
|
||||
logger.info(f"admin_smtp_test_email sent to {from_email}")
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={"available": True, "test_result": f"Email de test envoyé à {from_email}"},
|
||||
)
|
||||
except _smtplib.SMTPAuthenticationError as e:
|
||||
return JSONResponse(
|
||||
status_code=401,
|
||||
content={"available": False, "error": f"Authentification SMTP échouée: {e}"},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"SMTP test-send failed: {e}")
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"available": False, "error": f"Erreur envoi email: {str(e)[:200]}"},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/providers/ollama/models")
|
||||
async def list_ollama_models(admin_id: str = Depends(require_admin)):
|
||||
"""List available models from Ollama server"""
|
||||
async def list_ollama_models(
|
||||
base_url: Optional[str] = Query(None),
|
||||
admin_id: str = Depends(require_admin),
|
||||
):
|
||||
"""List available models from Ollama server. Accepts optional base_url query param
|
||||
so the frontend can pass the URL currently being edited (before save)."""
|
||||
import requests
|
||||
from config import config as app_config
|
||||
|
||||
settings = load_settings()
|
||||
base_url = (
|
||||
settings.ollama.base_url
|
||||
resolved = (
|
||||
base_url
|
||||
or settings.ollama.base_url
|
||||
or app_config.OLLAMA_BASE_URL
|
||||
or "http://localhost:11434"
|
||||
)
|
||||
|
||||
try:
|
||||
response = requests.get(f"{base_url}/api/tags", timeout=5)
|
||||
response = requests.get(f"{resolved}/api/tags", timeout=5)
|
||||
if response.ok:
|
||||
data = response.json()
|
||||
models = []
|
||||
@@ -1126,7 +1309,7 @@ async def list_ollama_models(admin_id: str = Depends(require_admin)):
|
||||
status_code=503,
|
||||
content={
|
||||
"error": "OLLAMA_CONNECTION_ERROR",
|
||||
"message": f"Cannot connect to Ollama at {base_url}",
|
||||
"message": f"Cannot connect to Ollama at {resolved}",
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
@@ -1137,6 +1320,107 @@ async def list_ollama_models(admin_id: str = Depends(require_admin)):
|
||||
)
|
||||
|
||||
|
||||
@router.get("/providers/openai/models")
|
||||
async def list_openai_models(admin_id: str = Depends(require_admin)):
|
||||
"""List available models from OpenAI API"""
|
||||
settings = load_settings()
|
||||
api_key = (settings.openai.api_key or "").strip() or os.getenv("OPENAI_API_KEY", "").strip()
|
||||
if not api_key:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"error": "NO_API_KEY", "message": "Aucune clé API OpenAI trouvée (JSON ou .env)"},
|
||||
)
|
||||
try:
|
||||
import openai as _openai
|
||||
|
||||
client = _openai.OpenAI(api_key=api_key)
|
||||
raw_models = list(client.models.list())
|
||||
models = [
|
||||
{"id": m.id, "owned_by": m.owned_by, "created": m.created}
|
||||
for m in raw_models
|
||||
]
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={"data": models, "meta": {"total": len(models)}},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"List OpenAI models failed: {e}")
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"error": "INTERNAL_ERROR", "message": str(e)},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/providers/openrouter/models")
|
||||
async def list_openrouter_models(admin_id: str = Depends(require_admin)):
|
||||
"""List available models from OpenRouter (public endpoint, no API key needed)"""
|
||||
try:
|
||||
import requests as _requests
|
||||
|
||||
resp = _requests.get(
|
||||
"https://openrouter.ai/api/v1/models",
|
||||
timeout=15,
|
||||
)
|
||||
if not resp.ok:
|
||||
return JSONResponse(
|
||||
status_code=502,
|
||||
content={"error": "OPENROUTER_ERROR", "message": f"OpenRouter returned HTTP {resp.status_code}"},
|
||||
)
|
||||
data = resp.json()
|
||||
raw = data.get("data", [])
|
||||
models = [
|
||||
{
|
||||
"id": m.get("id", ""),
|
||||
"name": m.get("name", ""),
|
||||
"context_length": m.get("context_length"),
|
||||
"pricing": m.get("pricing", {}),
|
||||
}
|
||||
for m in raw
|
||||
]
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={"data": models, "meta": {"total": len(models)}},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"List OpenRouter models failed: {e}")
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"error": "INTERNAL_ERROR", "message": str(e)},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/providers/zai/models")
|
||||
async def list_zai_models(admin_id: str = Depends(require_admin)):
|
||||
"""List available models from xAI / zAI (OpenAI-compatible API)"""
|
||||
settings = load_settings()
|
||||
api_key = (settings.zai.api_key or "").strip() or os.getenv("ZAI_API_KEY", "").strip()
|
||||
if not api_key:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"error": "NO_API_KEY", "message": "Aucune clé API xAI trouvée (JSON ou .env)"},
|
||||
)
|
||||
try:
|
||||
import openai as _openai
|
||||
|
||||
base_url = (settings.zai.base_url or "").strip() or os.getenv("ZAI_BASE_URL", "https://api.x.ai/v1")
|
||||
client = _openai.OpenAI(api_key=api_key, base_url=base_url)
|
||||
raw_models = list(client.models.list())
|
||||
models = [
|
||||
{"id": m.id, "owned_by": m.owned_by, "created": m.created}
|
||||
for m in raw_models
|
||||
]
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={"data": models, "meta": {"total": len(models)}},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"List xAI models failed: {e}")
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"error": "INTERNAL_ERROR", "message": str(e)},
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# PRICING MANAGEMENT (Admin only)
|
||||
# ============================================================
|
||||
|
||||
@@ -4,7 +4,9 @@ Story 3.6: Documentation OpenAPI complète avec exemples et codes d'erreur
|
||||
"""
|
||||
|
||||
import os
|
||||
from datetime import timedelta
|
||||
import secrets
|
||||
import logging
|
||||
from datetime import timedelta, datetime, timezone
|
||||
from fastapi import APIRouter, HTTPException, Depends, Header, Request, Query
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
@@ -44,6 +46,8 @@ from services.payment_service import (
|
||||
is_stripe_configured,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
security = HTTPBearer(auto_error=False)
|
||||
|
||||
@@ -895,6 +899,236 @@ async def get_billing_portal_v1(user=Depends(require_user)):
|
||||
return JSONResponse(status_code=200, content={"data": {"url": url}, "meta": {}})
|
||||
|
||||
|
||||
# ============== Forgot / Reset password ==============
|
||||
|
||||
|
||||
class ForgotPasswordRequest(BaseModel):
|
||||
email: EmailStr
|
||||
|
||||
|
||||
class ResetPasswordRequest(BaseModel):
|
||||
token: str
|
||||
password: str
|
||||
|
||||
|
||||
@router_v1.post(
|
||||
"/forgot-password",
|
||||
summary="Demander une reinitialisation de mot de passe",
|
||||
description="""
|
||||
Envoie un email de reinitialisation si un compte existe avec cette adresse.
|
||||
|
||||
**Securite:** Retourne toujours 200 pour ne pas reveler si l'email existe.
|
||||
""",
|
||||
)
|
||||
async def forgot_password(request: Request):
|
||||
from services.email_service import is_smtp_configured, send_email_async
|
||||
|
||||
if not is_smtp_configured():
|
||||
return JSONResponse(
|
||||
status_code=503,
|
||||
content={
|
||||
"error": "EMAIL_SERVICE_UNAVAILABLE",
|
||||
"message": "Service email non configure",
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "INVALID_REQUEST",
|
||||
"message": "Corps de requete JSON invalide",
|
||||
},
|
||||
)
|
||||
|
||||
email = body.get("email", "").strip()
|
||||
if not email:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "INVALID_REQUEST",
|
||||
"message": "Email requis",
|
||||
},
|
||||
)
|
||||
|
||||
# Always return 200 to avoid email enumeration
|
||||
user = get_user_by_email(email)
|
||||
|
||||
if user and user.id:
|
||||
token = secrets.token_urlsafe(32)
|
||||
expires = datetime.now(timezone.utc) + timedelta(hours=1)
|
||||
|
||||
# Store token in database
|
||||
try:
|
||||
from database.connection import get_sync_session
|
||||
from database.repositories import UserRepository
|
||||
from database.models import User as DBUser
|
||||
|
||||
with get_sync_session() as session:
|
||||
repo = UserRepository(session)
|
||||
|
||||
# Check if user exists in DB; if not (legacy JSON-only user), create DB record
|
||||
db_user = repo.get_by_email(email)
|
||||
if not db_user:
|
||||
db_user = repo.create(
|
||||
email=user.email,
|
||||
name=user.name,
|
||||
hashed_password=user.password_hash or "",
|
||||
tier="free",
|
||||
)
|
||||
|
||||
repo.update(
|
||||
db_user.id,
|
||||
reset_token=token,
|
||||
reset_token_expires=expires,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error(f"Failed to store reset token: {exc}")
|
||||
# Still return 200 for security
|
||||
|
||||
# Send email with reset link
|
||||
frontend_url = os.getenv("FRONTEND_URL", os.getenv("NEXT_PUBLIC_APP_URL", "http://localhost:3000"))
|
||||
reset_link = f"{frontend_url}/auth/reset-password?token={token}"
|
||||
|
||||
html_body = f"""
|
||||
<html>
|
||||
<body style="font-family: Arial, sans-serif; padding: 20px;">
|
||||
<h2>Reinitialisation de votre mot de passe</h2>
|
||||
<p>Vous avez demande la reinitialisation de votre mot de passe.</p>
|
||||
<p>Cliquez sur le lien ci-dessous pour definir un nouveau mot de passe :</p>
|
||||
<p><a href="{reset_link}" style="background-color: #007bff; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px;">Reinitialiser mon mot de passe</a></p>
|
||||
<p>Ce lien expire dans 1 heure.</p>
|
||||
<p>Si vous n'avez pas fait cette demande, ignorez cet email.</p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
await send_email_async(
|
||||
to=email,
|
||||
subject="Reinitialisation de votre mot de passe - Office Translator",
|
||||
body=html_body,
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={
|
||||
"data": {
|
||||
"message": "Si un compte existe avec cette adresse, un email de reinitialisation a ete envoye."
|
||||
},
|
||||
"meta": {},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router_v1.post(
|
||||
"/reset-password",
|
||||
summary="Reinitialiser le mot de passe",
|
||||
description="""
|
||||
Reinitialise le mot de passe a l'aide d'un token valide.
|
||||
|
||||
**Parametres requis:**
|
||||
- `token`: Le token recu par email
|
||||
- `password`: Le nouveau mot de passe (min 8 caracteres, 1 majuscule, 1 minuscule, 1 chiffre)
|
||||
""",
|
||||
)
|
||||
async def reset_password(request: Request):
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "INVALID_REQUEST",
|
||||
"message": "Corps de requete JSON invalide",
|
||||
},
|
||||
)
|
||||
|
||||
token = body.get("token", "").strip()
|
||||
password = body.get("password", "")
|
||||
|
||||
if not token or not password:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "INVALID_REQUEST",
|
||||
"message": "Token et mot de passe requis",
|
||||
},
|
||||
)
|
||||
|
||||
# Validate password strength
|
||||
if len(password) < 8 or not any(c.isupper() for c in password) or not any(c.islower() for c in password) or not any(c.isdigit() for c in password):
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "WEAK_PASSWORD",
|
||||
"message": "Le mot de passe doit contenir au moins 8 caracteres, une majuscule, une minuscule et un chiffre",
|
||||
},
|
||||
)
|
||||
|
||||
# Find user by reset token
|
||||
try:
|
||||
from database.connection import get_sync_session
|
||||
from database.models import User as DBUser
|
||||
from services.auth_service import hash_password
|
||||
|
||||
with get_sync_session() as session:
|
||||
db_user = (
|
||||
session.query(DBUser)
|
||||
.filter(DBUser.reset_token == token)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not db_user:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "INVALID_TOKEN",
|
||||
"message": "Token invalide ou expire",
|
||||
},
|
||||
)
|
||||
|
||||
# Check token expiry
|
||||
if db_user.reset_token_expires:
|
||||
expires = db_user.reset_token_expires
|
||||
if expires.tzinfo is None:
|
||||
expires = expires.replace(tzinfo=timezone.utc)
|
||||
if expires < datetime.now(timezone.utc):
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "TOKEN_EXPIRED",
|
||||
"message": "Token expire, veuillez redemander une reinitialisation",
|
||||
},
|
||||
)
|
||||
|
||||
# Update password and invalidate token
|
||||
db_user.hashed_password = hash_password(password)
|
||||
db_user.reset_token = None
|
||||
db_user.reset_token_expires = None
|
||||
db_user.updated_at = datetime.utcnow()
|
||||
session.commit()
|
||||
|
||||
except Exception as exc:
|
||||
logger.error(f"Reset password failed: {exc}")
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={
|
||||
"error": "RESET_FAILED",
|
||||
"message": "Erreur lors de la reinitialisation",
|
||||
},
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={
|
||||
"data": {"message": "Mot de passe reinitialise avec succes"},
|
||||
"meta": {},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ============== Stripe webhook (versioned) ==============
|
||||
|
||||
|
||||
|
||||
119
services/email_service.py
Normal file
119
services/email_service.py
Normal 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
|
||||
Reference in New Issue
Block a user