Compare commits

..
19 Commits
Author SHA1 Message Date
xavier 22c640837c feat: intégration du paramétrage obligatoire SMTP et optionnel des clés API lors du setup initial
Build and Push Docker Image / build-and-push (push) Successful in 42s
2026-10-07 10:17:23 +02:00
xavier 8dd8d65b42 fix: intégration de la route API de renommage des projets dans main.py
Build and Push Docker Image / build-and-push (push) Successful in 41s
2026-10-07 09:54:06 +02:00
xavier 3226886190 docs: mise à jour de la roadmap avec les nouvelles exigences métier (V1)
Build and Push Docker Image / build-and-push (push) Successful in 41s
2026-10-07 09:17:36 +02:00
xavier 71e80f93c6 feat: intégration complète de l'API de messagerie dans main.py
Build and Push Docker Image / build-and-push (push) Successful in 41s
2026-10-07 08:42:16 +02:00
xavier c8a38f3eb8 feat: ajout de l'épinglage et de la suppression des projets avec mise à jour UI
Build and Push Docker Image / build-and-push (push) Successful in 43s
2026-10-07 08:33:52 +02:00
xavier cac70a5aaa ci: intégration de docker/metadata-action pour générer dynamiquement les tags SemVer et latest
Build and Push Docker Image / build-and-push (push) Failing after 27s
2026-10-07 08:24:14 +02:00
xavier d41edde9bb feat: création de l'API de gestion des projets et liaison dynamique avec le Dashboard
Build and Push Docker Image / build-and-push (push) Successful in 39s
2026-10-07 08:17:41 +02:00
xavier fd53c75dee modif QRCode
Build and Push Docker Image / build-and-push (push) Successful in 41s
2026-10-07 08:10:14 +02:00
xavier e1d7ae87e3 correction authentification
Build and Push Docker Image / build-and-push (push) Successful in 44s
2026-10-07 08:02:17 +02:00
xavier 74b87a3b27 feat: création des modèles de base de données Project et Message
Build and Push Docker Image / build-and-push (push) Successful in 38s
2026-10-07 07:42:24 +02:00
xavier 02ba8bfff3 fix: configuration de StaticFiles sur FastAPI et mise à jour des liens du logo en local
Build and Push Docker Image / build-and-push (push) Successful in 39s
2026-10-07 02:04:48 +02:00
xavier 554f00f2b1 feat: ajout de la barre latérale rétractable, du champ de texte auto-extensible et intégration des logos
Build and Push Docker Image / build-and-push (push) Successful in 37s
2026-10-07 01:49:23 +02:00
xavier defa47ae45 feat: création de la structure UI du Dashboard (style Gemini) et liaison avec la route protégée
Build and Push Docker Image / build-and-push (push) Successful in 37s
2026-10-07 01:26:21 +02:00
xavier d712d2845d docs: mise à jour du README avec les objectifs de sécurité et d'infrastructure validés
Build and Push Docker Image / build-and-push (push) Successful in 39s
2026-10-07 01:19:41 +02:00
xavier c3b10508d9 feat: implémentation des sessions JWT avec cookies sécurisés (expiration 60 min)
Build and Push Docker Image / build-and-push (push) Successful in 1m24s
2026-10-07 01:09:55 +02:00
xavier b48853d186 fix: remplacement du canvas par une balise image pour garantir l'affichage du QR code Vue.js
Build and Push Docker Image / build-and-push (push) Successful in 38s
2026-10-07 00:56:00 +02:00
xavier afd0a88fc0 Suppression de passlib par bcrypt et modification des fichiers liés
Build and Push Docker Image / build-and-push (push) Successful in 1m14s
2026-10-07 00:44:25 +02:00
xavier 70b9bdc19f ajout email-validator
Build and Push Docker Image / build-and-push (push) Successful in 1m16s
2026-10-07 00:33:11 +02:00
xavier b1983077cb modification requirements.txt
Build and Push Docker Image / build-and-push (push) Successful in 1m17s
2026-10-07 00:25:39 +02:00
13 changed files with 898 additions and 134 deletions

No files matched your search

+14 -1
View File
@@ -4,6 +4,8 @@ on:
push:
branches:
- main
tags:
- 'v*' # Déclenche aussi le pipeline lors de la création d'un tag
jobs:
build-and-push:
@@ -16,6 +18,16 @@ jobs:
- name: Checkout du code
uses: actions/checkout@v3
# NOUVEAU : Génération dynamique des tags (SemVer + Latest)
- name: Docker meta
id: meta
uses: docker/metadata-action@v4
with:
images: gitea.aethas38.duckdns.org/xavier/multi-ia-codage
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=semver,pattern={{version}}
- name: Connexion au registre Gitea
uses: docker/login-action@v2
with:
@@ -28,4 +40,5 @@ jobs:
with:
context: .
push: true
tags: gitea.aethas38.duckdns.org/xavier/multi-ia-codage:latest
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+15 -3
View File
@@ -2,7 +2,8 @@ name: Build and Publish Docker Image
on:
push:
branches: [ "main" ] # Modifiez si votre branche s'appelle "master"
branches: [ "main" ]
tags: [ 'v*' ] # Déclencheur sur les tags ajouté ici aussi
env:
REGISTRY: ghcr.io
@@ -13,12 +14,22 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write # Indispensable pour publier le paquet sur le dépôt
packages: write
steps:
- name: Checkout du code
uses: actions/checkout@v4
# NOUVEAU : Identique à Gitea, gère ghcr.io
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=semver,pattern={{version}}
- name: Connexion au registre GitHub (ghcr.io)
uses: docker/login-action@v3
with:
@@ -31,4 +42,5 @@ jobs:
with:
context: .
push: true
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+3
View File
@@ -30,3 +30,6 @@ env/
.vscode/
.DS_Store
Thumbs.db
#perso
consignes.txt
+39 -29
View File
@@ -1,50 +1,60 @@
# AETHAS38 - Orchestrateur Multi-IA 🤖
Ce projet est un laboratoire d'intelligence artificielle personnel[cite: 5]. Il agit comme un orchestrateur avancé (Rédacteur ➔ Travailleurs ➔ Concaténeur) permettant de solliciter jusqu'à 5 modèles en parallèle via une interface unifiée, moderne et hautement sécurisée[cite: 5].
Ce projet est un laboratoire d'intelligence artificielle personnel. Il agit comme un orchestrateur avancé (Rédacteur ➔ Travailleurs ➔ Concaténeur) permettant de solliciter jusqu'à 5 modèles en parallèle via une interface unifiée, moderne et hautement sécurisée.
L'interface visuelle est inspirée de l'ergonomie de Gemini, avec le logo stylisé A38 (cerveau en réseau neuronal néon) comme charte graphique centrale[cite: 6].
L'interface visuelle est inspirée de l'ergonomie de Gemini, avec le logo stylisé A38 (cerveau en réseau neuronal néon) comme charte graphique centrale.
## 🏗️ Stack Technique & Architecture
- **Backend :** FastAPI (Python) modulaire (une fonction = un module).
- **Frontend :** Vue.js + Tailwind CSS (Responsive / Mobile-first).
- **Base de Données :** PostgreSQL (gestion des utilisateurs, historiques, suivi financier).
- **Traitement Documentaire :** LibreOffice Headless (génération/conversion Excel, Word, PDF).
- **Déploiement :** Docker, géré via Dockge et Gitea, sécurisé sur grappe RAID[cite: 5].
- **Déploiement :** Docker, géré via Dockge et Gitea, sécurisé sur grappe RAID.
## 📌 Règles de Versioning (SemVer)
Le développement suit un versioning strict (format `vX.Y.Z`) synchronisé avec les tags Gitea et GitHub, affiché dynamiquement sur l'interface[cite: 5] :
- **Majeur (X) :** Changement profond d'architecture ou refonte globale[cite: 5].
- **Mineur (Y) :** Validation et ajout d'une nouvelle fonctionnalité de la roadmap[cite: 5].
- **Patch (Z) :** Corrections de bugs, correctifs de sécurité ou ajustements visuels mineurs[cite: 5].
Le développement suit un versioning strict (format `vX.Y.Z`) synchronisé avec les tags Gitea et GitHub, affiché dynamiquement sur l'interface :
- **Majeur (X) :** Changement profond d'architecture ou refonte globale.
- **Mineur (Y) :** Validation et ajout d'une nouvelle fonctionnalité de la roadmap.
- **Patch (Z) :** Corrections de bugs, correctifs de sécurité ou ajustements visuels mineurs.
## 🚀 ROADMAP & CAHIER DES CHARGES
### Sécurité & Infrastructure
- [ ] Installation stricte via Dockge et Gitea (création automatique des dossiers inclus).
- [ ] Sécurisation de la totalité du projet (fichiers, BDD PostgreSQL) sur le stockage RAID.
- [ ] Mots de passe cryptés, protection anti-brute force et anti-DDOS.
- [ ] Authentification 2FA obligatoire (Keepassium, Authenticator, etc.) sans accès SSH pour les utilisateurs.
- [ ] Déconnexion automatique (timer invisible) après 60 minutes d'inactivité.
- [x] Installation stricte via Dockge et Gitea (création automatique des dossiers inclus).
- [x] Sécurisation de la totalité du projet (fichiers, BDD PostgreSQL) sur le stockage RAID.
- [x] Mots de passe cryptés (bcrypt), protection anti-brute force.
- [x] Authentification 2FA obligatoire (Keepassium, Authenticator, etc.).
- [x] Déconnexion automatique (timer invisible / session JWT) après 60 minutes d'inactivité.
- [x] Configuration de GitHub Actions / Gitea Actions pour la création automatique des paquets Docker (Tags SemVer + latest).
- [x] Horodatage UTC en base de données pour immunité au changement d'heure français (Prévention crash 2FA).
- [ ] Procédure de réinitialisation/modification du mot de passe (Admin et Utilisateurs).
- [ ] Configuration de GitHub Actions pour la création automatique des paquets Docker.
### Administration & Gestion des Utilisateurs
- [ ] Page d'administration complète : changement des clés, ajout de fournisseurs, configuration et test du serveur mail.
- [ ] Assistant de première installation (création du compte admin complet), puis auto-destruction du script.
- [ ] Système d'invitation par mail : création de compte avec génération de QR Code 2FA à la première connexion.
- [ ] Vérification du budget API toutes les 15 minutes et actualisation après chaque requête.
- [ ] Suivi financier détaillé par fournisseur d'IA.
- [x] Assistant de première installation (création du compte admin complet).
- [ ] Refonte du setup initial (Super Admin) : Obligation de configurer le serveur mail (aide opérateurs FR/Gmail) et proposition de saisie des clés API.
- [ ] Intégration de la roue crantée (Paramètres) en bas de la barre latérale.
- [ ] Page de configuration Utilisateur : changement d'email (avec validation via lien envoyé) et de mot de passe.
- [ ] Page de configuration Admin : ajout/modification de fournisseurs, clés API, et serveur mail en permanence.
- [ ] Hiérarchie des rôles : Un Admin peut proposer l'élévation d'un User en Admin (formulaire soumis à la validation exclusive du Super Admin).
- [ ] Export de l'activité Admin : Fichier Excel avec KPI, graphiques "camembert", et suivi financier par utilisateur.
- [ ] Suivi financier en temps réel et vérification du budget API.
- [ ] Envoi de rapport d'activité automatisé chaque jeudi à 04h00.
### Expérience Utilisateur (UI/UX)
- [ ] Thème moderne (Gemini-like) et 100% compatible smartphones.
- [ ] Refonte de la gestion des projets : séparation Épinglés (en haut) / Non Épinglés, avec le dernier utilisé en tête de liste.
- [ ] Plus de création automatique de nouveau projet à l'ouverture.
- [ ] Pré-filtre et classement automatique des modèles par catégorie (Texte/Code, Photo/Image, Vidéo, Son) avec l'aide de l'API Gemini lors de l'import.
- [ ] Intégration permanente du logo AETHAS38 (hébergé sur Drive) dans l'interface.
- [x] Thème moderne (Gemini-like) et 100% compatible smartphones.
- [x] Refonte de la gestion des projets : séparation Épinglés (en haut) / Non Épinglés (Récents).
- [x] Arrêt de la création automatique de nouveau projet à l'ouverture.
- [x] Intégration permanente du logo AETHAS38 en local (Setup / Login / Dashboard).
- [ ] Indicateur visuel (Loader/Animation) signalant que l'IA AETHAS38 "réfléchit" ou travaille.
- [ ] Fonctionnalité de renommage manuel des discussions/projets.
### Moteur IA & Fonctionnalités d'Export
- [ ] Récupération dynamique de la liste des modèles directement auprès des 9 fournisseurs par défaut (OpenRouter, Groq, Gemini, DeepSeek, Mistral, Cloudflare, HuggingFace, etc.).
- [ ] Pipeline multi-agents : 1 Rédacteur de prompt, jusqu'à 5 Travailleurs en parallèle, 1 Concaténeur final.
- [ ] Prise en charge de modèles IA pour le traitement de texte, la génération d'images, la synthèse vocale, la musique (calage sur vidéo) et la vidéo.
- [ ] Téléchargement des prompts, des réponses, et de l'historique complet en PDF, TXT, MD ou JSON.
- [ ] Bouton dédié pour télécharger les fichiers générés (Images, Vidéos, Excel mis en forme via LibreOffice).
### Moteur IA, Modèles & Fonctionnalités
- [ ] Pipeline multi-agents : 1 Rédacteur de prompt, jusqu'à 5 Travailleurs, 1 Concaténeur final.
- [ ] Extraction automatisée des modèles disponibles chez les fournisseurs (sauvegarde txt horodatée ex: "07/10/2026 à 10h00").
- [ ] Tâche planifiée (CRON) pour la mise à jour des modèles toutes les 12h (le timer se reset si mise à jour manuelle).
- [ ] Classement, filtrage (Gratuit/Payant) et catégorisation des modèles (Texte, Code, Image, Audio, Vidéo).
- [ ] Upload manuel du fichier d'extraction des modèles avec intégration dans l'interface filtrable.
- [ ] Traduction automatisée des descriptions des modèles via Ollama (local) ou Gemini.
- [ ] Interface d'aide à la décision pour le choix des modèles selon la tâche souhaitée.
- [ ] Mécanisme de fallback / termes génériques pour les appels API (protection contre le dépassement des quotas Gemini).
- [ ] Téléchargement des requêtes, réponses et fichiers générés (Excel, PDF, TXT, Images).
+31 -7
View File
@@ -1,16 +1,14 @@
from passlib.context import CryptContext
import bcrypt
import pyotp
# Configuration du hachage (bcrypt)
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Vérifie si le mot de passe en clair correspond au hash."""
return pwd_context.verify(plain_password, hashed_password)
return bcrypt.checkpw(plain_password.encode('utf-8'), hashed_password.encode('utf-8'))
def get_password_hash(password: str) -> str:
"""Génère le hash d'un mot de passe."""
return pwd_context.hash(password)
"""Génère le hash sécurisé d'un mot de passe."""
salt = bcrypt.gensalt()
return bcrypt.hashpw(password.encode('utf-8'), salt).decode('utf-8')
def generate_totp_secret() -> str:
"""Génère un secret aléatoire pour l'application 2FA (Authenticator, Keepassium)."""
@@ -24,3 +22,29 @@ def verify_totp(secret: str, code: str) -> bool:
"""Vérifie le code à 6 chiffres soumis par l'utilisateur."""
totp = pyotp.TOTP(secret)
return totp.verify(code)
import jwt
from datetime import datetime, timedelta, timezone
import os
# Dans l'idéal, cette clé secrète sera à placer dans votre fichier .env plus tard
SECRET_KEY = os.getenv("SECRET_KEY", r"`*\$Tu*CCc5hglT$mX'HykxERDljBz")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 60
def create_access_token(data: dict):
"""Crée un jeton JWT valable 60 minutes."""
to_encode = data.copy()
expire = datetime.now(timezone.utc) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire})
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
def verify_token(token: str):
"""Vérifie la validité du jeton JWT."""
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
return payload
except jwt.ExpiredSignatureError:
return None # Le jeton a expiré (60 min écoulées)
except jwt.InvalidTokenError:
return None # Le jeton est invalide
+172 -13
View File
@@ -1,19 +1,27 @@
from fastapi import FastAPI, Depends, HTTPException, status
from fastapi import FastAPI, Depends, HTTPException, status, Request, Response
from fastapi.responses import RedirectResponse, FileResponse
from fastapi.staticfiles import StaticFiles
from sqlalchemy.orm import Session
from typing import List
import os
# Importation de nos modules locaux
from .database import engine, Base, get_db
from .models import User
from .auth import get_password_hash, generate_totp_secret, get_totp_uri
from .schemas import AdminCreate
from .models import User, Project, Message
from .auth import get_password_hash, generate_totp_secret, get_totp_uri, verify_password, verify_totp, create_access_token, verify_token
from .schemas import AdminCreate, LoginRequest, ProjectCreate, ProjectResponse, ProjectRename, MessageCreate, MessageResponse
from .models import User, Project, Message, SystemSettings
# Création des tables dans la base de données
Base.metadata.create_all(bind=engine)
app = FastAPI(title="AETHAS38 - Orchestrateur Multi-IA")
# Configuration des fichiers statiques (images, css locaux, etc.)
assets_path = os.path.join(os.getcwd(), "frontend", "assets")
os.makedirs(assets_path, exist_ok=True) # Crée le dossier s'il n'existe pas
app.mount("/static", StaticFiles(directory=assets_path), name="static")
def is_setup_required(db: Session) -> bool:
"""Vérifie si la base de données contient au moins un administrateur."""
admin = db.query(User).filter(User.is_admin == True).first()
@@ -31,7 +39,6 @@ def setup_page(db: Session = Depends(get_db)):
if not is_setup_required(db):
return RedirectResponse(url="/login")
# Chemin vers le fichier HTML depuis la racine de l'application Docker (/app)
frontend_path = os.path.join(os.getcwd(), "frontend", "index.html")
if not os.path.exists(frontend_path):
raise HTTPException(status_code=404, detail="Interface introuvable.")
@@ -43,21 +50,25 @@ def login_page(db: Session = Depends(get_db)):
"""Affiche la page de connexion sécurisée."""
if is_setup_required(db):
return RedirectResponse(url="/setup")
return {"message": "Page de connexion (Interface Vue.js à venir)."}
frontend_path = os.path.join(os.getcwd(), "frontend", "login.html")
if not os.path.exists(frontend_path):
raise HTTPException(status_code=404, detail="Interface de connexion introuvable.")
return FileResponse(frontend_path)
@app.post("/api/setup")
def create_admin(admin_data: AdminCreate, db: Session = Depends(get_db)):
"""Reçoit les données du frontend, crée l'admin et retourne le QR Code 2FA."""
"""Reçoit les données du frontend, crée l'admin, sauvegarde la configuration et retourne le QR Code 2FA."""
if not is_setup_required(db):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="L'installation a déjà été effectuée."
)
# Sécurisation des accès
# Création du Super Admin
hashed_pw = get_password_hash(admin_data.password)
totp_secret = generate_totp_secret()
new_admin = User(
email=admin_data.email,
username=admin_data.username,
@@ -65,16 +76,164 @@ def create_admin(admin_data: AdminCreate, db: Session = Depends(get_db)):
totp_secret=totp_secret,
is_admin=True
)
db.add(new_admin)
# Sauvegarde des paramètres système
new_settings = SystemSettings(
smtp_host=admin_data.smtp_host,
smtp_port=admin_data.smtp_port,
smtp_user=admin_data.smtp_user,
smtp_password=admin_data.smtp_password,
gemini_api_key=admin_data.gemini_api_key,
openrouter_api_key=admin_data.openrouter_api_key
)
db.add(new_settings)
db.commit()
db.refresh(new_admin)
# Génération de l'URI pour l'affichage du QR Code côté frontend
totp_uri = get_totp_uri(totp_secret, new_admin.username)
return {
"message": "Administrateur créé avec succès.",
"message": "Configuration terminée avec succès.",
"totp_secret": totp_secret,
"totp_uri": totp_uri
}
@app.post("/api/login")
def login(login_data: LoginRequest, response: Response, db: Session = Depends(get_db)):
"""Vérifie les identifiants et crée une session de 60 minutes."""
user = db.query(User).filter(User.username == login_data.username).first()
# Vérification de l'utilisateur et du mot de passe
if not user or not verify_password(login_data.password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Nom d'utilisateur ou mot de passe incorrect."
)
# Vérification du code 2FA
if not verify_totp(user.totp_secret, login_data.totp_code):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Code 2FA invalide."
)
# Création du jeton de session
access_token = create_access_token(data={"sub": user.username})
# Injection du jeton dans un cookie sécurisé (invisible pour le JavaScript côté client)
response.set_cookie(
key="session_token",
value=access_token,
httponly=True,
max_age=3600, # 3600 secondes = 60 minutes
samesite="lax"
)
return {"message": "Connexion réussie"}
@app.get("/dashboard")
def dashboard(request: Request):
"""Route protégée : affiche l'interface principale si le cookie est valide."""
token = request.cookies.get("session_token")
if not token:
return RedirectResponse(url="/login")
payload = verify_token(token)
if not payload:
return RedirectResponse(url="/login")
frontend_path = os.path.join(os.getcwd(), "frontend", "dashboard.html")
if not os.path.exists(frontend_path):
raise HTTPException(status_code=404, detail="Interface du tableau de bord introuvable.")
return FileResponse(frontend_path)
# --- GESTION DES PROJETS ---
def get_current_user(request: Request, db: Session = Depends(get_db)):
"""Extrait l'utilisateur actuel à partir du cookie de session JWT."""
token = request.cookies.get("session_token")
if not token:
raise HTTPException(status_code=401, detail="Non authentifié")
payload = verify_token(token)
if not payload:
raise HTTPException(status_code=401, detail="Session expirée")
user = db.query(User).filter(User.username == payload.get("sub")).first()
if not user:
raise HTTPException(status_code=401, detail="Utilisateur introuvable")
return user
@app.get("/api/projects", response_model=List[ProjectResponse])
def get_projects(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
"""Récupère tous les projets de l'utilisateur connecté."""
return db.query(Project).filter(Project.user_id == current_user.id).order_by(Project.created_at.desc()).all()
@app.post("/api/projects", response_model=ProjectResponse)
def create_project(project: ProjectCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
"""Crée un nouveau projet pour l'utilisateur connecté."""
new_project = Project(title=project.title, user_id=current_user.id)
db.add(new_project)
db.commit()
db.refresh(new_project)
return new_project
@app.put("/api/projects/{project_id}/rename", response_model=ProjectResponse)
def rename_project(project_id: int, project_data: ProjectRename, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
"""Renomme un projet existant."""
project = db.query(Project).filter(Project.id == project_id, Project.user_id == current_user.id).first()
if not project:
raise HTTPException(status_code=404, detail="Projet introuvable")
project.title = project_data.title
db.commit()
db.refresh(project)
return project
@app.put("/api/projects/{project_id}/pin", response_model=ProjectResponse)
def toggle_pin_project(project_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
"""Bascule le statut épinglé (is_pinned) d'un projet."""
project = db.query(Project).filter(Project.id == project_id, Project.user_id == current_user.id).first()
if not project:
raise HTTPException(status_code=404, detail="Projet introuvable")
project.is_pinned = not project.is_pinned
db.commit()
db.refresh(project)
return project
@app.delete("/api/projects/{project_id}")
def delete_project(project_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
"""Supprime définitivement un projet et tous ses messages associés."""
project = db.query(Project).filter(Project.id == project_id, Project.user_id == current_user.id).first()
if not project:
raise HTTPException(status_code=404, detail="Projet introuvable")
db.delete(project)
db.commit()
return {"message": "Projet supprimé"}
# --- GESTION DES MESSAGES ---
@app.get("/api/projects/{project_id}/messages", response_model=List[MessageResponse])
def get_messages(project_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
"""Récupère tous les messages d'un projet spécifique."""
project = db.query(Project).filter(Project.id == project_id, Project.user_id == current_user.id).first()
if not project:
raise HTTPException(status_code=404, detail="Projet introuvable")
return db.query(Message).filter(Message.project_id == project_id).order_by(Message.created_at.asc()).all()
@app.post("/api/projects/{project_id}/messages", response_model=MessageResponse)
def create_message(project_id: int, message: MessageCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
"""Ajoute un message à un projet."""
project = db.query(Project).filter(Project.id == project_id, Project.user_id == current_user.id).first()
if not project:
raise HTTPException(status_code=404, detail="Projet introuvable")
new_message = Message(role=message.role, content=message.content, project_id=project_id)
db.add(new_message)
db.commit()
db.refresh(new_message)
return new_message
+45 -4
View File
@@ -1,4 +1,5 @@
from sqlalchemy import Column, Integer, String, Boolean, DateTime
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Text
from sqlalchemy.orm import relationship
from datetime import datetime, timezone
from .database import Base
@@ -9,8 +10,48 @@ class User(Base):
email = Column(String, unique=True, index=True, nullable=False)
username = Column(String, unique=True, index=True, nullable=False)
hashed_password = Column(String, nullable=False)
totp_secret = Column(String, nullable=True) # Secret pour Keepassium/Authenticator
totp_secret = Column(String, nullable=False)
is_admin = Column(Boolean, default=False)
is_active = Column(Boolean, default=True)
# Relation : Un utilisateur peut avoir plusieurs projets
projects = relationship("Project", back_populates="owner", cascade="all, delete-orphan")
class Project(Base):
__tablename__ = "projects"
id = Column(Integer, primary_key=True, index=True)
title = Column(String, nullable=False)
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
last_activity = Column(DateTime, nullable=True) # Servira pour la déconnexion après 60 min
is_pinned = Column(Boolean, default=False)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
# Relations
owner = relationship("User", back_populates="projects")
messages = relationship("Message", back_populates="project", cascade="all, delete-orphan")
class Message(Base):
__tablename__ = "messages"
id = Column(Integer, primary_key=True, index=True)
role = Column(String, nullable=False) # Ex: 'user', 'assistant', 'worker_1', 'system'
content = Column(Text, nullable=False)
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
project_id = Column(Integer, ForeignKey("projects.id"), nullable=False)
# Relation
project = relationship("Project", back_populates="messages")
class SystemSettings(Base):
__tablename__ = "system_settings"
id = Column(Integer, primary_key=True, index=True)
# Serveur Mail (Obligatoire)
smtp_host = Column(String, nullable=False)
smtp_port = Column(Integer, nullable=False)
smtp_user = Column(String, nullable=False)
smtp_password = Column(String, nullable=False)
# Clés API (Optionnelles)
gemini_api_key = Column(String, nullable=True)
openrouter_api_key = Column(String, nullable=True)
+46
View File
@@ -1,6 +1,52 @@
from pydantic import BaseModel, EmailStr
from datetime import datetime
from typing import Optional
class AdminCreate(BaseModel):
email: EmailStr
username: str
password: str
# Serveur Mail (Obligatoire)
smtp_host: str
smtp_port: int
smtp_user: str
smtp_password: str
# Clés API (Optionnelles)
gemini_api_key: Optional[str] = None
openrouter_api_key: Optional[str] = None
class LoginRequest(BaseModel):
username: str
password: str
totp_code: str
# --- NOUVEAU : Schémas pour les Projets ---
class ProjectBase(BaseModel):
title: str
class ProjectCreate(ProjectBase):
pass
class ProjectResponse(ProjectBase):
id: int
created_at: datetime
is_pinned: bool
class Config:
from_attributes = True
# --- NOUVEAU : Schémas pour les Messages ---
class MessageBase(BaseModel):
role: str
content: str
class MessageCreate(MessageBase):
pass
class MessageResponse(MessageBase):
id: int
created_at: datetime
project_id: int
class ProjectRename(BaseModel):
title: str
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

+322
View File
@@ -0,0 +1,322 @@
<!DOCTYPE html>
<html lang="fr" class="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AETHAS38 - Dashboard</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: { extend: { colors: { aethas: { dark: '#0f172a', panel: '#1e293b', cyan: '#06b6d4', purple: '#a855f7' } } } }
}
</script>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script src="https://unpkg.com/lucide@latest"></script>
<style>
body { background-color: #0f172a; color: #f8fafc; font-family: 'Inter', sans-serif; }
.neon-text { background: linear-gradient(to right, #06b6d4, #a855f7); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #334155; border-radius: 10px; }
</style>
</head>
<body class="h-screen flex overflow-hidden">
<div id="app" class="flex w-full h-full relative bg-aethas-dark">
<div v-if="isSidebarOpen" @click="toggleSidebar" class="md:hidden fixed inset-0 bg-black/60 z-40 transition-opacity"></div>
<!-- BARRE LATÉRALE -->
<div :class="['transition-all duration-300 flex-shrink-0 relative z-50', isSidebarOpen ? 'w-72' : 'w-0']">
<aside :class="['absolute top-0 left-0 h-full w-72 bg-aethas-panel border-r border-gray-700 flex flex-col transition-transform duration-300', isSidebarOpen ? 'translate-x-0' : '-translate-x-full']">
<div class="p-4 border-b border-gray-700 flex items-center justify-between">
<div class="flex items-center gap-3">
<img src="/static/logo.png" alt="Logo" class="w-10 h-10 rounded-full border border-aethas-cyan object-cover bg-gray-800 flex-shrink-0">
<span class="font-bold text-lg tracking-wider neon-text">AETHAS38</span>
</div>
<button @click="toggleSidebar" class="md:hidden text-gray-400 hover:text-white"><i data-lucide="x" class="w-5 h-5"></i></button>
</div>
<div class="p-4">
<button @click="createNewProject" :disabled="isLoading" class="w-full bg-gray-800 hover:bg-gray-700 text-white border border-gray-600 rounded-lg py-2.5 px-4 flex items-center justify-center gap-2 transition-colors disabled:opacity-50 shadow-md">
<i data-lucide="plus" class="w-5 h-5 text-aethas-cyan"></i> Nouveau Projet IA
</button>
</div>
<div class="flex-1 overflow-y-auto p-4 space-y-6">
<!-- Projets Épinglés -->
<div v-if="pinnedProjects.length > 0">
<h3 class="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3">Épinglés</h3>
<ul class="space-y-1">
<li v-for="project in pinnedProjects" :key="project.id" @click="selectProject(project)" :class="['group flex items-center justify-between p-2 rounded cursor-pointer text-sm transition-colors', activeProject && activeProject.id === project.id ? 'bg-gray-700 text-white' : 'hover:bg-gray-800 text-gray-300']">
<div class="flex items-center gap-2 truncate">
<i data-lucide="pin" class="w-4 h-4 text-aethas-cyan fill-aethas-cyan flex-shrink-0"></i>
<span class="truncate">{{ project.title }}</span>
</div>
<div class="hidden group-hover:flex items-center gap-1 flex-shrink-0 bg-gray-800 pl-2 rounded">
<button @click.stop="renameProject(project)" class="text-gray-400 hover:text-aethas-cyan p-1 rounded hover:bg-gray-700" title="Renommer"><i data-lucide="edit-2" class="w-3.5 h-3.5"></i></button>
<button @click.stop="togglePin(project.id)" class="text-gray-400 hover:text-white p-1 rounded hover:bg-gray-700" title="Désépingler"><i data-lucide="pin-off" class="w-3.5 h-3.5"></i></button>
<button @click.stop="deleteProject(project.id)" class="text-gray-400 hover:text-red-400 p-1 rounded hover:bg-gray-700" title="Supprimer"><i data-lucide="trash-2" class="w-3.5 h-3.5"></i></button>
</div>
</li>
</ul>
</div>
<!-- Projets Récents -->
<div>
<h3 class="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3">Récents</h3>
<ul class="space-y-1">
<li v-if="recentProjects.length === 0 && pinnedProjects.length === 0" class="text-sm text-gray-500 italic p-2">Aucun projet.</li>
<li v-for="project in recentProjects" :key="project.id" @click="selectProject(project)" :class="['group flex items-center justify-between p-2 rounded cursor-pointer text-sm transition-colors', activeProject && activeProject.id === project.id ? 'bg-gray-700 text-white' : 'hover:bg-gray-800 text-gray-300']">
<div class="flex items-center gap-2 truncate">
<i data-lucide="message-square" class="w-4 h-4 text-gray-500 flex-shrink-0"></i>
<span class="truncate">{{ project.title }}</span>
</div>
<div class="hidden group-hover:flex items-center gap-1 flex-shrink-0 bg-gray-800 pl-2 rounded">
<button @click.stop="renameProject(project)" class="text-gray-400 hover:text-aethas-cyan p-1 rounded hover:bg-gray-700" title="Renommer"><i data-lucide="edit-2" class="w-3.5 h-3.5"></i></button>
<button @click.stop="togglePin(project.id)" class="text-gray-400 hover:text-white p-1 rounded hover:bg-gray-700" title="Épingler"><i data-lucide="pin" class="w-3.5 h-3.5"></i></button>
<button @click.stop="deleteProject(project.id)" class="text-gray-400 hover:text-red-400 p-1 rounded hover:bg-gray-700" title="Supprimer"><i data-lucide="trash-2" class="w-3.5 h-3.5"></i></button>
</div>
</li>
</ul>
</div>
</div>
<div class="p-4 border-t border-gray-700 flex items-center gap-2">
<div class="w-8 h-8 rounded-full bg-gradient-to-tr from-aethas-cyan to-aethas-purple flex items-center justify-center text-sm font-bold shadow-md">{{ username.charAt(0).toUpperCase() }}</div>
<span class="text-sm font-medium">{{ username }}</span>
</div>
</aside>
</div>
<!-- ZONE PRINCIPALE -->
<main class="flex-1 flex flex-col min-w-0 z-10 transition-all duration-300">
<header class="flex items-center justify-between p-4 border-b border-gray-700 bg-aethas-panel h-[73px]">
<div class="flex items-center gap-3">
<button @click="toggleSidebar" class="text-gray-400 hover:text-white transition-colors p-1.5 rounded hover:bg-gray-700"><i data-lucide="menu" class="w-6 h-6"></i></button>
<div v-show="!isSidebarOpen" class="flex items-center gap-2 transition-opacity duration-300">
<img src="/static/logo.png" alt="Logo" class="w-8 h-8 rounded-full border border-aethas-cyan object-cover bg-gray-800 flex-shrink-0">
<span class="font-bold text-lg neon-text">AETHAS38</span>
</div>
</div>
<div v-if="activeProject" class="text-sm font-medium text-gray-300 flex items-center gap-2">
{{ activeProject.title }}
<button @click="renameProject(activeProject)" class="text-gray-500 hover:text-aethas-cyan"><i data-lucide="edit-2" class="w-4 h-4"></i></button>
</div>
</header>
<!-- Affichage par défaut -->
<div v-if="!activeProject" class="flex-1 overflow-y-auto p-4 md:p-8 flex flex-col items-center justify-center">
<img src="/static/logo.png" alt="AETHAS38 Logo" class="w-24 h-24 mb-6 rounded-full border-2 border-aethas-cyan shadow-[0_0_15px_rgba(6,182,212,0.5)] object-cover bg-gray-800 flex-shrink-0">
<h2 class="text-2xl font-bold text-gray-200 mb-2 text-center">Comment puis-je vous aider aujourd'hui ?</h2>
<p class="text-gray-500 text-sm text-center">Sélectionnez ou créez un projet pour commencer.</p>
</div>
<!-- Zone de Chat -->
<div v-else class="flex-1 overflow-y-auto p-4 md:p-8 space-y-6 flex flex-col" id="chat-container">
<div v-if="messages.length === 0" class="flex-1 flex items-center justify-center text-gray-500 italic">Démarrez la conversation ci-dessous.</div>
<div v-for="msg in messages" :key="msg.id" :class="['max-w-3xl rounded-2xl p-4', msg.role === 'user' ? 'bg-gray-800 ml-auto border border-gray-700' : 'bg-aethas-panel mr-auto border border-aethas-cyan/30']">
<div class="flex items-center gap-2 mb-2">
<i v-if="msg.role === 'user'" data-lucide="user" class="w-4 h-4 text-gray-400"></i>
<i v-else data-lucide="bot" class="w-4 h-4 text-aethas-cyan"></i>
<span class="text-xs font-semibold uppercase tracking-wider text-gray-400">{{ msg.role === 'user' ? 'Vous' : 'AETHAS38' }}</span>
</div>
<div class="text-gray-200 whitespace-pre-wrap text-sm leading-relaxed">{{ msg.content }}</div>
</div>
<!-- Indicateur de chargement IA -->
<div v-if="isAiThinking" class="max-w-3xl rounded-2xl p-4 bg-aethas-panel mr-auto border border-aethas-cyan/30 w-24">
<div class="flex items-center gap-2 mb-2">
<i data-lucide="bot" class="w-4 h-4 text-aethas-cyan"></i>
<span class="text-xs font-semibold uppercase tracking-wider text-gray-400">AETHAS38</span>
</div>
<div class="flex space-x-1.5 h-4 items-center">
<div class="w-2 h-2 bg-aethas-cyan rounded-full animate-bounce"></div>
<div class="w-2 h-2 bg-aethas-cyan rounded-full animate-bounce" style="animation-delay: 0.15s"></div>
<div class="w-2 h-2 bg-aethas-cyan rounded-full animate-bounce" style="animation-delay: 0.3s"></div>
</div>
</div>
</div>
<!-- Saisie du Prompt -->
<div class="p-4 md:p-6 w-full max-w-4xl mx-auto">
<div class="relative flex items-end bg-gray-800 border border-gray-600 rounded-2xl p-2 shadow-lg focus-within:border-aethas-cyan focus-within:ring-1 focus-within:ring-aethas-cyan transition-all">
<button class="p-3 text-gray-400 hover:text-aethas-cyan transition-colors flex-shrink-0" :disabled="!activeProject || isAiThinking"><i data-lucide="paperclip" class="w-5 h-5"></i></button>
<textarea
v-model="prompt"
@input="autoResize"
@keydown.enter.exact.prevent="sendMessage"
ref="promptTextarea"
:disabled="!activeProject || isAiThinking"
rows="1"
class="w-full bg-transparent text-gray-200 p-3 max-h-[200px] overflow-y-auto focus:outline-none resize-none leading-relaxed disabled:opacity-50"
placeholder="Posez votre question à l'Orchestrateur..."></textarea>
<button @click="sendMessage" :disabled="!activeProject || !prompt.trim() || isAiThinking" class="p-3 text-aethas-cyan hover:text-white transition-colors flex-shrink-0 disabled:opacity-50 disabled:text-gray-500"><i data-lucide="send" class="w-5 h-5"></i></button>
</div>
<p class="text-center text-xs text-gray-500 mt-3">AETHAS38 peut faire des erreurs. Vérifiez toujours les informations importantes.</p>
</div>
</main>
</div>
<script>
const { createApp, ref, onMounted, nextTick, computed } = Vue;
createApp({
setup() {
const username = ref("Admin");
const isSidebarOpen = ref(true);
const prompt = ref("");
const promptTextarea = ref(null);
const projects = ref([]);
const activeProject = ref(null);
const messages = ref([]);
const isLoading = ref(false);
const isAiThinking = ref(false);
const pinnedProjects = computed(() => projects.value.filter(p => p.is_pinned));
const recentProjects = computed(() => projects.value.filter(p => !p.is_pinned));
const toggleSidebar = () => { isSidebarOpen.value = !isSidebarOpen.value; };
const autoResize = () => {
const el = promptTextarea.value;
if (!el) return;
el.style.height = 'auto';
el.style.height = el.scrollHeight + 'px';
};
const scrollToBottom = () => {
nextTick(() => {
const container = document.getElementById('chat-container');
if (container) container.scrollTop = container.scrollHeight;
});
};
const fetchProjects = async () => {
try {
const response = await fetch('/api/projects');
if (response.ok) {
projects.value = await response.json();
nextTick(() => lucide.createIcons());
}
} catch (error) { console.error(error); }
};
const selectProject = async (project) => {
activeProject.value = project;
messages.value = [];
try {
const response = await fetch(`/api/projects/${project.id}/messages`);
if (response.ok) {
messages.value = await response.json();
scrollToBottom();
nextTick(() => lucide.createIcons());
}
} catch (error) { console.error(error); }
};
const createNewProject = async () => {
isLoading.value = true;
try {
const response = await fetch('/api/projects', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: "Nouvelle discussion" })
});
if (response.ok) {
const newProject = await response.json();
projects.value.unshift(newProject);
selectProject(newProject);
}
} catch (error) { console.error(error); }
finally { isLoading.value = false; }
};
const renameProject = async (project) => {
const newTitle = window.prompt("Nouveau nom du projet :", project.title);
if (!newTitle || newTitle.trim() === "" || newTitle === project.title) return;
try {
const response = await fetch(`/api/projects/${project.id}/rename`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: newTitle.trim() })
});
if (response.ok) {
const updatedProject = await response.json();
const index = projects.value.findIndex(p => p.id === project.id);
if (index !== -1) projects.value[index] = updatedProject;
if (activeProject.value && activeProject.value.id === project.id) {
activeProject.value.title = updatedProject.title;
}
}
} catch (error) { console.error("Erreur de renommage", error); }
};
const sendMessage = async () => {
if (!prompt.value.trim() || !activeProject.value) return;
const userText = prompt.value.trim();
prompt.value = "";
autoResize();
messages.value.push({ id: Date.now(), role: 'user', content: userText });
isAiThinking.value = true;
scrollToBottom();
nextTick(() => lucide.createIcons());
try {
await fetch(`/api/projects/${activeProject.value.id}/messages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ role: 'user', content: userText })
});
// SIMULATION TEMPORAIRE : Attente de l'IA (2 secondes)
setTimeout(() => {
isAiThinking.value = false;
scrollToBottom();
// La prochaine étape remplacera ce setTimeout par le véritable appel au moteur IA !
}, 2000);
} catch (error) {
console.error("Erreur envoi message", error);
isAiThinking.value = false;
}
};
const togglePin = async (projectId) => { /* ... code existant identique ... */
try {
const response = await fetch(`/api/projects/${projectId}/pin`, { method: 'PUT' });
if (response.ok) {
const updatedProject = await response.json();
const index = projects.value.findIndex(p => p.id === projectId);
if (index !== -1) projects.value[index] = updatedProject;
}
} catch (error) { console.error(error); }
};
const deleteProject = async (projectId) => { /* ... code existant identique ... */
if (!confirm("Voulez-vous vraiment supprimer ce projet ?")) return;
try {
const response = await fetch(`/api/projects/${projectId}`, { method: 'DELETE' });
if (response.ok) {
projects.value = projects.value.filter(p => p.id !== projectId);
if (activeProject.value && activeProject.value.id === projectId) activeProject.value = null;
}
} catch (error) { console.error(error); }
};
onMounted(() => {
lucide.createIcons();
fetchProjects();
});
return {
username, isSidebarOpen, toggleSidebar, prompt, autoResize, promptTextarea,
projects, pinnedProjects, recentProjects, activeProject, messages, isAiThinking,
createNewProject, selectProject, renameProject, sendMessage, isLoading, togglePin, deleteProject
};
}
}).mount('#app');
</script>
</body>
</html>
+103 -75
View File
@@ -3,97 +3,131 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AETHAS38 - Installation</title>
<!-- Tailwind CSS -->
<title>AETHAS38 - Installation Complète</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
aethas: {
dark: '#0f172a',
panel: '#1e293b',
cyan: '#06b6d4',
purple: '#a855f7'
}
}
}
}
}
tailwind.config = { darkMode: 'class', theme: { extend: { colors: { aethas: { dark: '#0f172a', panel: '#1e293b', cyan: '#06b6d4', purple: '#a855f7' } } } } }
</script>
<!-- Vue.js 3 -->
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<!-- QRCode.js pour générer le flashcode 2FA -->
<script src="https://cdn.jsdelivr.net/npm/qrcode@1.5.3/build/qrcode.min.js"></script>
<script src="https://unpkg.com/lucide@latest"></script>
<style>
body { background-color: #0f172a; color: #f8fafc; font-family: 'Inter', sans-serif; }
.glass-panel {
background: rgba(30, 41, 59, 0.7);
backdrop-filter: blur(10px);
border: 1px solid rgba(6, 182, 212, 0.2);
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.5);
}
.glass-panel { background: rgba(30, 41, 59, 0.7); backdrop-filter: blur(10px); border: 1px solid rgba(6, 182, 212, 0.2); box-shadow: 0 4px 30px rgba(0, 0, 0, 0.5); }
.neon-text { background: linear-gradient(to right, #06b6d4, #a855f7); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-thumb { background: #334155; border-radius: 10px; }
</style>
</head>
<body class="flex items-center justify-center min-h-screen p-4 sm:p-8">
<body class="flex items-center justify-center min-h-screen p-4 sm:p-8 overflow-y-auto">
<div id="app" class="w-full max-w-md transition-all duration-500">
<!-- En-tête / Logo -->
<div id="app" class="w-full max-w-2xl transition-all duration-500 my-8">
<div class="text-center mb-8">
<!-- Remplacer le src par le lien direct de votre image hébergée sur Google Drive -->
<img src="https://via.placeholder.com/150x150/0f172a/06b6d4?text=Logo+A38" alt="AETHAS38 Logo" class="w-24 h-24 mx-auto mb-4 rounded-full border-2 border-aethas-cyan shadow-[0_0_15px_rgba(6,182,212,0.5)]">
<img src="/static/logo.png" alt="AETHAS38 Logo" class="w-24 h-24 mx-auto mb-4 rounded-full border-2 border-aethas-cyan shadow-[0_0_15px_rgba(6,182,212,0.5)] object-cover bg-gray-800">
<h1 class="text-3xl font-bold tracking-wider neon-text">AETHAS38</h1>
<p class="text-gray-400 text-sm mt-2">Configuration de l'Orchestrateur Multi-IA</p>
<p class="text-gray-400 text-sm mt-2">Configuration Initiale (Super Admin)</p>
</div>
<!-- Panneau principal -->
<div class="glass-panel rounded-2xl p-6 sm:p-8">
<form v-if="!setupComplete" @submit.prevent="submitSetup" class="space-y-8">
<div v-if="errorMessage" class="p-3 bg-red-900/50 border border-red-500 rounded text-red-200 text-sm text-center">{{ errorMessage }}</div>
<!-- Étape 1 : Formulaire -->
<form v-if="!setupComplete" @submit.prevent="submitSetup" class="space-y-5">
<div v-if="errorMessage" class="p-3 bg-red-900/50 border border-red-500 rounded text-red-200 text-sm text-center">
{{ errorMessage }}
</div>
<!-- Section 1 : Compte Admin -->
<div>
<label class="block text-sm font-medium text-gray-300 mb-1">Email Administrateur</label>
<input v-model="form.email" type="email" required class="w-full bg-aethas-dark border border-gray-600 rounded-lg px-4 py-2.5 focus:outline-none focus:border-aethas-cyan focus:ring-1 focus:ring-aethas-cyan transition-colors" placeholder="admin@domaine.com">
<h2 class="text-lg font-semibold text-white border-b border-gray-700 pb-2 mb-4 flex items-center gap-2">
<i data-lucide="user-plus" class="w-5 h-5 text-aethas-cyan"></i> Compte Administrateur
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm text-gray-300 mb-1">Nom d'utilisateur</label>
<input v-model="form.username" type="text" required class="w-full bg-aethas-dark border border-gray-600 rounded-lg px-4 py-2 focus:border-aethas-cyan focus:ring-1 focus:ring-aethas-cyan outline-none" placeholder="Admin">
</div>
<div>
<label class="block text-sm text-gray-300 mb-1">Mot de passe</label>
<input v-model="form.password" type="password" required class="w-full bg-aethas-dark border border-gray-600 rounded-lg px-4 py-2 focus:border-aethas-cyan focus:ring-1 focus:ring-aethas-cyan outline-none" placeholder="••••••••">
</div>
<div class="md:col-span-2">
<label class="block text-sm text-gray-300 mb-1">Email de récupération</label>
<input v-model="form.email" type="email" required class="w-full bg-aethas-dark border border-gray-600 rounded-lg px-4 py-2 focus:border-aethas-cyan focus:ring-1 focus:ring-aethas-cyan outline-none" placeholder="admin@domaine.com">
</div>
</div>
</div>
<!-- Section 2 : Serveur Mail -->
<div>
<label class="block text-sm font-medium text-gray-300 mb-1">Nom d'utilisateur</label>
<input v-model="form.username" type="text" required class="w-full bg-aethas-dark border border-gray-600 rounded-lg px-4 py-2.5 focus:outline-none focus:border-aethas-cyan focus:ring-1 focus:ring-aethas-cyan transition-colors" placeholder="Admin">
<div class="flex items-center justify-between border-b border-gray-700 pb-2 mb-4">
<h2 class="text-lg font-semibold text-white flex items-center gap-2">
<i data-lucide="mail" class="w-5 h-5 text-aethas-cyan"></i> Serveur Mail (Obligatoire)
</h2>
<button type="button" @click="showWiki = !showWiki" class="text-xs text-aethas-cyan hover:text-white underline">Aide FAI Français</button>
</div>
<!-- Wiki d'aide -->
<div v-if="showWiki" class="mb-4 p-4 bg-gray-800 rounded-lg border border-gray-600 text-sm text-gray-300 space-y-2">
<p><strong>Freebox :</strong> Hôte: smtp.free.fr | Port: 465 (SSL) ou 587 (TLS)</p>
<p><strong>Orange :</strong> Hôte: smtp.orange.fr | Port: 465 (SSL)</p>
<p><strong>SFR :</strong> Hôte: smtp.sfr.fr | Port: 465 (SSL)</p>
<p><strong>Bouygues :</strong> Hôte: smtp.bbox.fr | Port: 587 (TLS)</p>
<p><strong>Gmail :</strong> Hôte: smtp.gmail.com | Port: 465 (Nécessite un "Mot de passe d'application" Google)</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div class="md:col-span-3">
<label class="block text-sm text-gray-300 mb-1">Hôte SMTP</label>
<input v-model="form.smtp_host" type="text" required class="w-full bg-aethas-dark border border-gray-600 rounded-lg px-4 py-2 focus:border-aethas-cyan outline-none" placeholder="ex: smtp.free.fr">
</div>
<div>
<label class="block text-sm text-gray-300 mb-1">Port</label>
<input v-model="form.smtp_port" type="number" required class="w-full bg-aethas-dark border border-gray-600 rounded-lg px-4 py-2 focus:border-aethas-cyan outline-none" placeholder="465">
</div>
<div class="md:col-span-2">
<label class="block text-sm text-gray-300 mb-1">Utilisateur SMTP</label>
<input v-model="form.smtp_user" type="text" required class="w-full bg-aethas-dark border border-gray-600 rounded-lg px-4 py-2 focus:border-aethas-cyan outline-none">
</div>
<div class="md:col-span-2">
<label class="block text-sm text-gray-300 mb-1">Mot de passe SMTP</label>
<input v-model="form.smtp_password" type="password" required class="w-full bg-aethas-dark border border-gray-600 rounded-lg px-4 py-2 focus:border-aethas-cyan outline-none">
</div>
</div>
</div>
<!-- Section 3 : Clés API IA -->
<div>
<label class="block text-sm font-medium text-gray-300 mb-1">Mot de passe</label>
<input v-model="form.password" type="password" required class="w-full bg-aethas-dark border border-gray-600 rounded-lg px-4 py-2.5 focus:outline-none focus:border-aethas-cyan focus:ring-1 focus:ring-aethas-cyan transition-colors" placeholder="••••••••">
<h2 class="text-lg font-semibold text-white border-b border-gray-700 pb-2 mb-4 flex items-center gap-2">
<i data-lucide="cpu" class="w-5 h-5 text-aethas-cyan"></i> Moteurs IA (Optionnel)
</h2>
<div class="space-y-4">
<div>
<label class="block text-sm text-gray-300 mb-1">Google Gemini API Key</label>
<input v-model="form.gemini_api_key" type="password" class="w-full bg-aethas-dark border border-gray-600 rounded-lg px-4 py-2 focus:border-aethas-cyan outline-none" placeholder="AIzaSy...">
</div>
<div>
<label class="block text-sm text-gray-300 mb-1">OpenRouter API Key</label>
<input v-model="form.openrouter_api_key" type="password" class="w-full bg-aethas-dark border border-gray-600 rounded-lg px-4 py-2 focus:border-aethas-cyan outline-none" placeholder="sk-or-v1-...">
</div>
</div>
</div>
<button type="submit" :disabled="isSubmitting" class="w-full bg-gradient-to-r from-aethas-cyan to-aethas-purple hover:opacity-90 text-white font-semibold py-3 px-4 rounded-lg transition-all shadow-[0_0_10px_rgba(168,85,247,0.4)] flex justify-center items-center mt-6">
<span v-if="isSubmitting">Création en cours...</span>
<span v-else>Initialiser le système</span>
<button type="submit" :disabled="isSubmitting" class="w-full bg-gradient-to-r from-aethas-cyan to-aethas-purple hover:opacity-90 text-white font-semibold py-3 px-4 rounded-lg transition-all shadow-[0_0_10px_rgba(168,85,247,0.4)] flex justify-center items-center mt-8">
<span v-if="isSubmitting">Initialisation et Chiffrement...</span>
<span v-else>Terminer l'installation</span>
</button>
</form>
<!-- Étape 2 : Validation 2FA -->
<div v-else class="text-center space-y-6 animate-fade-in">
<!-- Code existant pour le succès / QR Code -->
<div class="inline-flex items-center justify-center w-12 h-12 rounded-full bg-green-900/50 text-green-400 mb-2">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
<i data-lucide="check" class="w-6 h-6"></i>
</div>
<h2 class="text-xl font-bold text-white">Compte créé avec succès</h2>
<h2 class="text-xl font-bold text-white">Plateforme Opérationnelle</h2>
<div class="bg-white p-4 rounded-xl inline-block mx-auto">
<canvas id="qrcode"></canvas>
<div class="bg-white p-4 rounded-xl inline-block mx-auto min-w-[200px] min-h-[200px] flex items-center justify-center">
<img v-if="qrCodeUrl" :src="qrCodeUrl" alt="QR Code 2FA" class="w-full h-full object-contain">
<span v-else class="text-gray-500 text-sm">Génération...</span>
</div>
<div class="text-sm text-gray-300 space-y-2">
<p>Scannez ce QR Code avec votre application 2FA (Keepassium, Google Authenticator...).</p>
<p class="text-xs text-gray-500 break-all bg-aethas-dark p-2 rounded">Secret manuel : {{ totpSecret }}</p>
<p class="text-xs text-gray-500 break-all bg-aethas-dark p-2 rounded border border-gray-600">Secret manuel : {{ totpSecret }}</p>
</div>
<a href="/login" class="block w-full border border-aethas-cyan text-aethas-cyan hover:bg-aethas-cyan hover:text-aethas-dark font-semibold py-3 px-4 rounded-lg transition-colors mt-6">
@@ -104,15 +138,23 @@
</div>
<script>
const { createApp, ref, reactive, nextTick } = Vue;
const { createApp, ref, reactive, onMounted } = Vue;
createApp({
setup() {
const form = reactive({ email: '', username: '', password: '' });
const form = reactive({
email: '', username: '', password: '',
smtp_host: '', smtp_port: 465, smtp_user: '', smtp_password: '',
gemini_api_key: '', openrouter_api_key: ''
});
const isSubmitting = ref(false);
const setupComplete = ref(false);
const errorMessage = ref('');
const totpSecret = ref('');
const qrCodeUrl = ref('');
const showWiki = ref(false);
onMounted(() => { lucide.createIcons(); });
const submitSetup = async () => {
isSubmitting.value = true;
@@ -124,37 +166,23 @@
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form)
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.detail || "Erreur lors de l'installation");
}
if (!response.ok) throw new Error(data.detail || "Erreur lors de l'installation");
totpSecret.value = data.totp_secret;
setupComplete.value = true;
qrCodeUrl.value = `https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(data.totp_uri)}&color=0f172a&bgcolor=ffffff`;
// Génération du QR code sur le canvas après le rendu Vue
nextTick(() => {
const canvas = document.getElementById('qrcode');
QRCode.toCanvas(canvas, data.totp_uri, {
width: 200,
margin: 1,
color: { dark: "#0f172a", light: "#ffffff" }
});
});
setTimeout(() => lucide.createIcons(), 100);
} catch (error) {
errorMessage.value = error.message;
} finally {
isSubmitting.value = false;
}
};
return { form, isSubmitting, setupComplete, errorMessage, totpSecret, submitSetup };
return { form, isSubmitting, setupComplete, errorMessage, totpSecret, qrCodeUrl, showWiki, submitSetup };
}
}).mount('#app');
</script>
</body>
</html>
+104
View File
@@ -0,0 +1,104 @@
<!DOCTYPE html>
<html lang="fr" class="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AETHAS38 - Connexion</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
aethas: {
dark: '#0f172a', panel: '#1e293b', cyan: '#06b6d4', purple: '#a855f7'
}
}
}
}
}
</script>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<style>
body { background-color: #0f172a; color: #f8fafc; font-family: 'Inter', sans-serif; }
.glass-panel {
background: rgba(30, 41, 59, 0.7); backdrop-filter: blur(10px);
border: 1px solid rgba(6, 182, 212, 0.2); box-shadow: 0 4px 30px rgba(0, 0, 0, 0.5);
}
.neon-text { background: linear-gradient(to right, #06b6d4, #a855f7); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
</style>
</head>
<body class="flex items-center justify-center min-h-screen p-4 sm:p-8">
<div id="app" class="w-full max-w-md transition-all duration-500">
<div class="text-center mb-8">
<img src="https://via.placeholder.com/150x150/0f172a/06b6d4?text=Logo+A38" alt="AETHAS38 Logo" class="w-24 h-24 mx-auto mb-4 rounded-full border-2 border-aethas-cyan shadow-[0_0_15px_rgba(6,182,212,0.5)]">
<h1 class="text-3xl font-bold tracking-wider neon-text">AETHAS38</h1>
<p class="text-gray-400 text-sm mt-2">Authentification Sécurisée</p>
</div>
<div class="glass-panel rounded-2xl p-6 sm:p-8">
<form @submit.prevent="submitLogin" class="space-y-5">
<div v-if="errorMessage" class="p-3 bg-red-900/50 border border-red-500 rounded text-red-200 text-sm text-center">
{{ errorMessage }}
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-1">Nom d'utilisateur</label>
<input v-model="form.username" type="text" required class="w-full bg-aethas-dark border border-gray-600 rounded-lg px-4 py-2.5 focus:outline-none focus:border-aethas-cyan focus:ring-1 focus:ring-aethas-cyan transition-colors" placeholder="Admin">
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-1">Mot de passe</label>
<input v-model="form.password" type="password" required class="w-full bg-aethas-dark border border-gray-600 rounded-lg px-4 py-2.5 focus:outline-none focus:border-aethas-cyan focus:ring-1 focus:ring-aethas-cyan transition-colors" placeholder="••••••••">
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-1">Code 2FA (6 chiffres)</label>
<input v-model="form.totp_code" type="text" inputmode="numeric" pattern="[0-9]{6}" required maxlength="6" class="w-full bg-aethas-dark border border-gray-600 rounded-lg px-4 py-2.5 focus:outline-none focus:border-aethas-cyan focus:ring-1 focus:ring-aethas-cyan transition-colors text-center tracking-widest text-lg" placeholder="123456">
</div>
<button type="submit" :disabled="isSubmitting" class="w-full bg-gradient-to-r from-aethas-cyan to-aethas-purple hover:opacity-90 text-white font-semibold py-3 px-4 rounded-lg transition-all shadow-[0_0_10px_rgba(168,85,247,0.4)] flex justify-center items-center mt-6">
<span v-if="isSubmitting">Vérification...</span>
<span v-else>Se connecter</span>
</button>
</form>
</div>
</div>
<script>
const { createApp, reactive, ref } = Vue;
createApp({
setup() {
const form = reactive({ username: '', password: '', totp_code: '' });
const isSubmitting = ref(false);
const errorMessage = ref('');
const submitLogin = async () => {
isSubmitting.value = true;
errorMessage.value = '';
try {
const response = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form)
});
const data = await response.json();
if (!response.ok) throw new Error(data.detail || "Erreur de connexion");
// Redirection vers le tableau de bord une fois connecté
window.location.href = "/dashboard";
} catch (error) {
errorMessage.value = error.message;
} finally {
isSubmitting.value = false;
}
};
return { form, isSubmitting, errorMessage, submitLogin };
}
}).mount('#app');
</script>
</body>
</html>
+4 -2
View File
@@ -1,11 +1,13 @@
fastapi
uvicorn
sqlalchemy
psycopg2-binary
psycopg[binary]
requests
pyotp
qrcode
psutil
python-dotenv
python-multipart
passlib[bcrypt]
bcrypt
email-validator
PyJWT