fix
All checks were successful
Deploy to Development / build-and-deploy (push) Successful in 22s
Deploy to Production / build-and-deploy (push) Successful in 4s

This commit is contained in:
EvanChal
2026-01-28 22:31:17 +01:00
parent 8ff0f22682
commit 7ca168c34c
14 changed files with 576 additions and 145 deletions

View File

@@ -6,18 +6,63 @@ from models.notification import PushSubscription
from models.user import User from models.user import User
from schemas.notification import PushSubscriptionCreate, VapidPublicKeyResponse from schemas.notification import PushSubscriptionCreate, VapidPublicKeyResponse
from utils.security import get_current_active_user from utils.security import get_current_active_user
from utils.push_service import is_push_configured, send_push_to_user
import base64
router = APIRouter() router = APIRouter()
def get_vapid_public_key_for_web_push():
"""
Convertit la clé publique VAPID au format attendu par Web Push.
Les clés VAPID peuvent être au format:
1. Base64url brut (65 octets décodés) - Format attendu par Web Push
2. PEM/DER complet (commence par MFk...) - Doit être converti
Web Push attend la clé publique non compressée (65 octets = 0x04 + X + Y)
"""
public_key = settings.VAPID_PUBLIC_KEY
if not public_key:
return None
# Nettoyer la clé (enlever les éventuels espaces/newlines)
public_key = public_key.strip()
# Si la clé commence par MFk, c'est au format DER/SubjectPublicKeyInfo
# On doit extraire les 65 derniers octets
if public_key.startswith('MFk'):
try:
# Décoder le DER
# Ajouter le padding si nécessaire
padding = 4 - len(public_key) % 4
if padding != 4:
public_key += '=' * padding
der_bytes = base64.b64decode(public_key)
# La clé publique non compressée est les 65 derniers octets
# (SubjectPublicKeyInfo header + la clé)
if len(der_bytes) >= 65:
raw_key = der_bytes[-65:]
# Ré-encoder en base64url sans padding
return base64.urlsafe_b64encode(raw_key).rstrip(b'=').decode('ascii')
except Exception as e:
print(f"Erreur conversion clé VAPID: {e}")
return public_key
return public_key
@router.get("/vapid-public-key", response_model=VapidPublicKeyResponse) @router.get("/vapid-public-key", response_model=VapidPublicKeyResponse)
async def get_vapid_public_key(current_user: User = Depends(get_current_active_user)): async def get_vapid_public_key_endpoint(current_user: User = Depends(get_current_active_user)):
"""Get the VAPID public key for push notifications.""" """Get the VAPID public key for push notifications."""
if not settings.VAPID_PUBLIC_KEY: public_key = get_vapid_public_key_for_web_push()
if not public_key:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="VAPID keys not configured on server" detail="VAPID keys not configured on server"
) )
return {"public_key": settings.VAPID_PUBLIC_KEY} return {"public_key": public_key}
@router.post("/subscribe") @router.post("/subscribe")
async def subscribe_push( async def subscribe_push(
@@ -66,3 +111,60 @@ async def unsubscribe_push(
return {"message": "Unsubscribed successfully"} return {"message": "Unsubscribed successfully"}
@router.post("/test")
async def test_push_notification(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""Send a test push notification to the current user."""
if not is_push_configured():
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Push notifications not configured. Check VAPID keys."
)
# Vérifier que l'utilisateur a au moins un abonnement
sub_count = db.query(PushSubscription).filter(
PushSubscription.user_id == current_user.id
).count()
if sub_count == 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Aucun appareil enregistré pour les notifications push"
)
# Envoyer la notification de test
sent = send_push_to_user(
db,
current_user.id,
"🔔 Test de notification",
"Les notifications push fonctionnent correctement !",
"/"
)
return {
"message": f"Notification de test envoyée à {sent} appareil(s)",
"devices_registered": sub_count,
"sent_successfully": sent
}
@router.get("/status")
async def get_push_status(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""Get the push notification configuration status."""
sub_count = db.query(PushSubscription).filter(
PushSubscription.user_id == current_user.id
).count()
return {
"configured": is_push_configured(),
"vapid_public_key_set": bool(settings.VAPID_PUBLIC_KEY),
"vapid_private_key_set": bool(settings.VAPID_PRIVATE_KEY),
"user_subscriptions": sub_count
}

View File

@@ -203,6 +203,21 @@ app.add_middleware(
expose_headers=["Content-Range", "Accept-Ranges"], expose_headers=["Content-Range", "Accept-Ranges"],
) )
# Middleware de debug pour les requêtes POST (à désactiver en production stable)
@app.middleware("http")
async def debug_auth_middleware(request: Request, call_next):
"""Log les informations d'authentification pour debug."""
if request.method == "POST" and "/api/" in request.url.path:
auth_header = request.headers.get("authorization", "")
has_auth = bool(auth_header)
auth_preview = auth_header[:30] + "..." if len(auth_header) > 30 else auth_header
print(f"🔍 DEBUG POST {request.url.path}: auth_header={'present' if has_auth else 'MISSING'}, preview={auth_preview}")
response = await call_next(request)
return response
# Endpoint personnalisé pour servir les vidéos avec support Range # Endpoint personnalisé pour servir les vidéos avec support Range
@app.get("/uploads/{file_path:path}") @app.get("/uploads/{file_path:path}")
async def serve_media_with_range(request: Request, file_path: str): async def serve_media_with_range(request: Request, file_path: str):

View File

@@ -0,0 +1,70 @@
#!/usr/bin/env python3
"""
Script pour générer des clés VAPID pour les notifications push.
Usage:
python scripts/generate_vapid_keys.py
Les clés générées doivent être ajoutées aux variables d'environnement:
VAPID_PUBLIC_KEY=<clé_publique>
VAPID_PRIVATE_KEY=<clé_privée>
VAPID_CLAIMS_EMAIL=mailto:admin@example.com
"""
import base64
import os
try:
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import serialization
except ImportError:
print("❌ cryptography n'est pas installé.")
print(" Installez-le avec: pip install cryptography")
exit(1)
def generate_vapid_keys():
"""Génère une paire de clés VAPID au format base64url."""
# Générer une clé privée EC P-256
private_key = ec.generate_private_key(ec.SECP256R1(), default_backend())
# Extraire la clé privée brute (32 octets)
private_numbers = private_key.private_numbers()
private_bytes = private_numbers.private_value.to_bytes(32, byteorder='big')
private_key_b64 = base64.urlsafe_b64encode(private_bytes).rstrip(b'=').decode('ascii')
# Extraire la clé publique non compressée (65 octets = 0x04 + X + Y)
public_key = private_key.public_key()
public_bytes = public_key.public_bytes(
encoding=serialization.Encoding.X962,
format=serialization.PublicFormat.UncompressedPoint
)
public_key_b64 = base64.urlsafe_b64encode(public_bytes).rstrip(b'=').decode('ascii')
return public_key_b64, private_key_b64
if __name__ == "__main__":
print("🔐 Génération des clés VAPID pour Web Push...\n")
public_key, private_key = generate_vapid_keys()
print("=" * 70)
print("Clés VAPID générées avec succès !")
print("=" * 70)
print()
print("Ajoutez ces variables à votre fichier .env ou configuration:")
print()
print(f'VAPID_PUBLIC_KEY={public_key}')
print()
print(f'VAPID_PRIVATE_KEY={private_key}')
print()
print('VAPID_CLAIMS_EMAIL=mailto:admin@lediscord.com')
print()
print("=" * 70)
print()
print("⚠️ IMPORTANT:")
print(" - Gardez la clé privée SECRÈTE")
print(" - Les clés doivent être les mêmes en dev et prod")
print(" - Après changement de clés, les utilisateurs devront se réabonner")

View File

@@ -2,7 +2,7 @@ from datetime import datetime, timedelta
from typing import Optional from typing import Optional
from jose import JWTError, jwt from jose import JWTError, jwt
from passlib.context import CryptContext from passlib.context import CryptContext
from fastapi import Depends, HTTPException, status from fastapi import Depends, HTTPException, status, Request
from fastapi.security import OAuth2PasswordBearer from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from config.settings import settings from config.settings import settings
@@ -11,7 +11,9 @@ from models.user import User
from schemas.user import TokenData from schemas.user import TokenData
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
# OAuth2 scheme avec auto_error=False pour pouvoir logger les erreurs
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login", auto_error=False)
def verify_password(plain_password: str, hashed_password: str) -> bool: def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Verify a plain password against a hashed password.""" """Verify a plain password against a hashed password."""
@@ -45,17 +47,42 @@ def verify_token(token: str, credentials_exception) -> TokenData:
except JWTError: except JWTError:
raise credentials_exception raise credentials_exception
async def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)) -> User: async def get_current_user(
token: Optional[str] = Depends(oauth2_scheme),
db: Session = Depends(get_db)
) -> User:
"""Get the current authenticated user.""" """Get the current authenticated user."""
credentials_exception = HTTPException( credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials", detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"},
) )
token_data = verify_token(token, credentials_exception)
# Log si pas de token
if token is None:
print("❌ AUTH: No token provided in Authorization header")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated - no token provided",
headers={"WWW-Authenticate": "Bearer"},
)
# Log le token (premiers caractères seulement pour la sécurité)
print(f"🔐 AUTH: Token received, length={len(token)}, preview={token[:20]}...")
try:
token_data = verify_token(token, credentials_exception)
print(f"✅ AUTH: Token valid for user_id={token_data.user_id}")
except HTTPException as e:
print(f"❌ AUTH: Token validation failed - {e.detail}")
raise
user = db.query(User).filter(User.id == token_data.user_id).first() user = db.query(User).filter(User.id == token_data.user_id).first()
if user is None: if user is None:
print(f"❌ AUTH: User not found for id={token_data.user_id}")
raise credentials_exception raise credentials_exception
print(f"✅ AUTH: User authenticated: {user.username}")
return user return user
async def get_current_active_user(current_user: User = Depends(get_current_user)) -> User: async def get_current_active_user(current_user: User = Depends(get_current_user)) -> User:

File diff suppressed because one or more lines are too long

View File

@@ -28,56 +28,58 @@
</div> </div>
<!-- Content --> <!-- Content -->
<div class="px-6 py-6"> <div class="px-4 sm:px-6 py-4 sm:py-6">
<div v-if="isIOS" class="space-y-6"> <!-- iOS Instructions -->
<div v-if="instructionType === 'ios'" class="space-y-4 sm:space-y-6">
<!-- Step 1 --> <!-- Step 1 -->
<div class="flex items-start space-x-4"> <div class="flex items-start space-x-3 sm:space-x-4">
<div class="flex-shrink-0"> <div class="flex-shrink-0">
<div class="w-10 h-10 rounded-full bg-gradient-to-br from-primary-500 to-purple-600 flex items-center justify-center text-white font-bold text-lg shadow-lg"> <div class="w-8 h-8 sm:w-10 sm:h-10 rounded-full bg-gradient-to-br from-primary-500 to-purple-600 flex items-center justify-center text-white font-bold text-sm sm:text-lg shadow-lg">
1 1
</div> </div>
</div> </div>
<div class="flex-1 pt-1"> <div class="flex-1 pt-0.5 sm:pt-1">
<h4 class="font-semibold text-gray-900 mb-2">Appuyez sur le bouton de partage</h4> <h4 class="font-semibold text-gray-900 mb-1 sm:mb-2 text-sm sm:text-base">Appuyez sur le bouton de partage</h4>
<p class="text-sm text-gray-600 mb-3"> <p class="text-xs sm:text-sm text-gray-600 mb-2 sm:mb-3">
En bas de l'écran, dans la barre d'outils Safari En bas de l'écran Safari, cherchez l'icône carrée avec une flèche vers le haut ⬆️
</p> </p>
<div class="bg-gray-50 rounded-lg p-4 border-2 border-dashed border-gray-200"> <div class="bg-blue-50 border border-blue-200 rounded-lg p-3 sm:p-4">
<div class="flex items-center justify-center space-x-2"> <div class="flex items-center space-x-3">
<div class="w-12 h-12 bg-gray-200 rounded-lg flex items-center justify-center"> <div class="w-10 h-10 sm:w-12 sm:h-12 bg-blue-100 rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5 sm:w-6 sm:h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg> </svg>
</div> </div>
<ArrowDown class="w-5 h-5 text-gray-400" /> <p class="text-xs sm:text-sm text-blue-700">
<p class="text-xs text-gray-500">Bouton de partage</p> <strong>Astuce :</strong> Si vous ne voyez pas la barre, faites défiler vers le haut pour la faire apparaître
</p>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Step 2 --> <!-- Step 2 -->
<div class="flex items-start space-x-4"> <div class="flex items-start space-x-3 sm:space-x-4">
<div class="flex-shrink-0"> <div class="flex-shrink-0">
<div class="w-10 h-10 rounded-full bg-gradient-to-br from-primary-500 to-purple-600 flex items-center justify-center text-white font-bold text-lg shadow-lg"> <div class="w-8 h-8 sm:w-10 sm:h-10 rounded-full bg-gradient-to-br from-primary-500 to-purple-600 flex items-center justify-center text-white font-bold text-sm sm:text-lg shadow-lg">
2 2
</div> </div>
</div> </div>
<div class="flex-1 pt-1"> <div class="flex-1 pt-0.5 sm:pt-1">
<h4 class="font-semibold text-gray-900 mb-2">Sélectionnez "Sur l'écran d'accueil"</h4> <h4 class="font-semibold text-gray-900 mb-1 sm:mb-2 text-sm sm:text-base">Cherchez "Sur l'écran d'accueil"</h4>
<p class="text-sm text-gray-600 mb-3"> <p class="text-xs sm:text-sm text-gray-600 mb-2 sm:mb-3">
Faites défiler le menu de partage vers le haut Faites défiler le menu vers le bas jusqu'à trouver cette option
</p> </p>
<div class="bg-gradient-to-br from-primary-50 to-purple-50 rounded-lg p-4 border-2 border-primary-200"> <div class="bg-gradient-to-br from-primary-50 to-purple-50 rounded-lg p-3 sm:p-4 border-2 border-primary-200">
<div class="flex items-center space-x-3"> <div class="flex items-center space-x-3">
<div class="w-10 h-10 bg-primary-100 rounded-lg flex items-center justify-center"> <div class="w-10 h-10 bg-primary-100 rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-primary-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5 sm:w-6 sm:h-6 text-primary-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg> </svg>
</div> </div>
<div class="flex-1"> <div class="flex-1">
<p class="font-semibold text-primary-700 text-sm">Sur l'écran d'accueil</p> <p class="font-semibold text-primary-700 text-xs sm:text-sm">Sur l'écran d'accueil</p>
<p class="text-xs text-primary-600">Ajouter à l'écran d'accueil</p> <p class="text-xs text-primary-600">L'icône ressemble à un + dans un carré</p>
</div> </div>
</div> </div>
</div> </div>
@@ -85,97 +87,138 @@
</div> </div>
<!-- Step 3 --> <!-- Step 3 -->
<div class="flex items-start space-x-4"> <div class="flex items-start space-x-3 sm:space-x-4">
<div class="flex-shrink-0"> <div class="flex-shrink-0">
<div class="w-10 h-10 rounded-full bg-gradient-to-br from-primary-500 to-purple-600 flex items-center justify-center text-white font-bold text-lg shadow-lg"> <div class="w-8 h-8 sm:w-10 sm:h-10 rounded-full bg-gradient-to-br from-primary-500 to-purple-600 flex items-center justify-center text-white font-bold text-sm sm:text-lg shadow-lg">
3 3
</div> </div>
</div> </div>
<div class="flex-1 pt-1"> <div class="flex-1 pt-0.5 sm:pt-1">
<h4 class="font-semibold text-gray-900 mb-2">Confirmez l'installation</h4> <h4 class="font-semibold text-gray-900 mb-1 sm:mb-2 text-sm sm:text-base">Appuyez sur "Ajouter"</h4>
<p class="text-sm text-gray-600 mb-3"> <p class="text-xs sm:text-sm text-gray-600 mb-2 sm:mb-3">
Appuyez sur "Ajouter" dans la popup de confirmation En haut à droite de l'écran
</p> </p>
<div class="bg-green-50 rounded-lg p-4 border-2 border-green-200"> <div class="bg-green-50 rounded-lg p-3 sm:p-4 border-2 border-green-200">
<div class="flex items-center space-x-3"> <div class="flex items-center space-x-3">
<CheckCircle class="w-6 h-6 text-green-600" /> <CheckCircle class="w-5 h-5 sm:w-6 sm:h-6 text-green-600 flex-shrink-0" />
<p class="text-sm text-green-700 font-medium">L'application apparaîtra sur votre écran d'accueil !</p> <p class="text-xs sm:text-sm text-green-700 font-medium">L'application apparaîtra sur votre écran d'accueil !</p>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Note importante iOS -->
<div class="bg-amber-50 border border-amber-200 rounded-lg p-3 sm:p-4 mt-4">
<div class="flex items-start space-x-2">
<span class="text-lg"></span>
<div class="text-xs sm:text-sm text-amber-800">
<p class="font-medium mb-1">Important pour iOS :</p>
<p>Les notifications push ne fonctionnent que si l'app est installée sur l'écran d'accueil (iOS 16.4+)</p>
</div>
</div>
</div>
</div> </div>
<!-- Android --> <!-- Android Instructions -->
<div v-else class="space-y-6"> <div v-else-if="instructionType === 'android'" class="space-y-4 sm:space-y-6">
<!-- Step 1 --> <!-- Step 1 -->
<div class="flex items-start space-x-4"> <div class="flex items-start space-x-3 sm:space-x-4">
<div class="flex-shrink-0"> <div class="flex-shrink-0">
<div class="w-10 h-10 rounded-full bg-gradient-to-br from-primary-500 to-purple-600 flex items-center justify-center text-white font-bold text-lg shadow-lg"> <div class="w-8 h-8 sm:w-10 sm:h-10 rounded-full bg-gradient-to-br from-primary-500 to-purple-600 flex items-center justify-center text-white font-bold text-sm sm:text-lg shadow-lg">
1 1
</div> </div>
</div> </div>
<div class="flex-1 pt-1"> <div class="flex-1 pt-0.5 sm:pt-1">
<h4 class="font-semibold text-gray-900 mb-2">Appuyez sur le menu</h4> <h4 class="font-semibold text-gray-900 mb-1 sm:mb-2 text-sm sm:text-base">Appuyez sur le menu ⋮</h4>
<p class="text-sm text-gray-600 mb-3"> <p class="text-xs sm:text-sm text-gray-600 mb-2 sm:mb-3">
En haut à droite de votre navigateur () Les 3 points verticaux en haut à droite de Chrome
</p> </p>
<div class="bg-gray-50 rounded-lg p-4 border-2 border-dashed border-gray-200"> </div>
<div class="flex items-center justify-center"> </div>
<div class="w-12 h-12 bg-gray-200 rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <!-- Step 2 -->
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z" /> <div class="flex items-start space-x-3 sm:space-x-4">
</svg> <div class="flex-shrink-0">
</div> <div class="w-8 h-8 sm:w-10 sm:h-10 rounded-full bg-gradient-to-br from-primary-500 to-purple-600 flex items-center justify-center text-white font-bold text-sm sm:text-lg shadow-lg">
2
</div>
</div>
<div class="flex-1 pt-0.5 sm:pt-1">
<h4 class="font-semibold text-gray-900 mb-1 sm:mb-2 text-sm sm:text-base">"Installer l'application"</h4>
<p class="text-xs sm:text-sm text-gray-600 mb-2 sm:mb-3">
Ou "Ajouter à l'écran d'accueil"
</p>
</div>
</div>
<!-- Step 3 -->
<div class="flex items-start space-x-3 sm:space-x-4">
<div class="flex-shrink-0">
<div class="w-8 h-8 sm:w-10 sm:h-10 rounded-full bg-gradient-to-br from-primary-500 to-purple-600 flex items-center justify-center text-white font-bold text-sm sm:text-lg shadow-lg">
3
</div>
</div>
<div class="flex-1 pt-0.5 sm:pt-1">
<h4 class="font-semibold text-gray-900 mb-1 sm:mb-2 text-sm sm:text-base">Confirmez avec "Installer"</h4>
<div class="bg-green-50 rounded-lg p-3 sm:p-4 border-2 border-green-200">
<div class="flex items-center space-x-3">
<CheckCircle class="w-5 h-5 sm:w-6 sm:h-6 text-green-600 flex-shrink-0" />
<p class="text-xs sm:text-sm text-green-700 font-medium">L'app s'installera sur votre téléphone !</p>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div>
<!-- Desktop Instructions (Windows/Mac) -->
<div v-else class="space-y-4 sm:space-y-6">
<!-- Step 1 -->
<div class="flex items-start space-x-3 sm:space-x-4">
<div class="flex-shrink-0">
<div class="w-8 h-8 sm:w-10 sm:h-10 rounded-full bg-gradient-to-br from-primary-500 to-purple-600 flex items-center justify-center text-white font-bold text-sm sm:text-lg shadow-lg">
1
</div>
</div>
<div class="flex-1 pt-0.5 sm:pt-1">
<h4 class="font-semibold text-gray-900 mb-1 sm:mb-2 text-sm sm:text-base">Cherchez l'icône d'installation</h4>
<p class="text-xs sm:text-sm text-gray-600 mb-2 sm:mb-3">
Dans la barre d'adresse de Chrome/Edge, cherchez l'icône 📥 ou
</p>
<div class="bg-blue-50 border border-blue-200 rounded-lg p-3 sm:p-4">
<p class="text-xs sm:text-sm text-blue-700">
<strong>Chrome :</strong> Icône avec un écran et une flèche à droite de la barre d'adresse
</p>
<p class="text-xs sm:text-sm text-blue-700 mt-1">
<strong>Edge :</strong> "Installer LeDiscord" dans le menu ⋯
</p>
</div>
</div>
</div>
<!-- Step 2 --> <!-- Step 2 -->
<div class="flex items-start space-x-4"> <div class="flex items-start space-x-3 sm:space-x-4">
<div class="flex-shrink-0"> <div class="flex-shrink-0">
<div class="w-10 h-10 rounded-full bg-gradient-to-br from-primary-500 to-purple-600 flex items-center justify-center text-white font-bold text-lg shadow-lg"> <div class="w-8 h-8 sm:w-10 sm:h-10 rounded-full bg-gradient-to-br from-primary-500 to-purple-600 flex items-center justify-center text-white font-bold text-sm sm:text-lg shadow-lg">
2 2
</div> </div>
</div> </div>
<div class="flex-1 pt-1"> <div class="flex-1 pt-0.5 sm:pt-1">
<h4 class="font-semibold text-gray-900 mb-2">Sélectionnez "Ajouter à l'écran d'accueil"</h4> <h4 class="font-semibold text-gray-900 mb-1 sm:mb-2 text-sm sm:text-base">Cliquez sur "Installer"</h4>
<p class="text-sm text-gray-600 mb-3"> <div class="bg-green-50 rounded-lg p-3 sm:p-4 border-2 border-green-200">
Ou "Installer l'application" selon votre navigateur
</p>
<div class="bg-gradient-to-br from-primary-50 to-purple-50 rounded-lg p-4 border-2 border-primary-200">
<div class="flex items-center space-x-3"> <div class="flex items-center space-x-3">
<div class="w-10 h-10 bg-primary-100 rounded-lg flex items-center justify-center"> <CheckCircle class="w-5 h-5 sm:w-6 sm:h-6 text-green-600 flex-shrink-0" />
<svg class="w-6 h-6 text-primary-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <p class="text-xs sm:text-sm text-green-700 font-medium">L'app s'ouvrira dans sa propre fenêtre !</p>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
</div>
<div class="flex-1">
<p class="font-semibold text-primary-700 text-sm">Ajouter à l'écran d'accueil</p>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Step 3 --> <!-- Note si pas dispo -->
<div class="flex items-start space-x-4"> <div class="bg-amber-50 border border-amber-200 rounded-lg p-3 sm:p-4 mt-4">
<div class="flex-shrink-0"> <div class="flex items-start space-x-2">
<div class="w-10 h-10 rounded-full bg-gradient-to-br from-primary-500 to-purple-600 flex items-center justify-center text-white font-bold text-lg shadow-lg"> <span class="text-lg">💡</span>
3 <div class="text-xs sm:text-sm text-amber-800">
</div> <p>Si vous ne voyez pas l'option d'installation, rafraîchissez la page ou essayez avec Chrome/Edge.</p>
</div>
<div class="flex-1 pt-1">
<h4 class="font-semibold text-gray-900 mb-2">Confirmez l'installation</h4>
<p class="text-sm text-gray-600 mb-3">
Appuyez sur "Ajouter" ou "Installer" dans la popup
</p>
<div class="bg-green-50 rounded-lg p-4 border-2 border-green-200">
<div class="flex items-center space-x-3">
<CheckCircle class="w-6 h-6 text-green-600" />
<p class="text-sm text-green-700 font-medium">L'application sera installée !</p>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -231,11 +274,31 @@ const props = defineProps({
isIOS: { isIOS: {
type: Boolean, type: Boolean,
default: false default: false
},
isAndroid: {
type: Boolean,
default: false
},
isWindows: {
type: Boolean,
default: false
},
isMac: {
type: Boolean,
default: false
} }
}) })
const emit = defineEmits(['close']) const emit = defineEmits(['close'])
// Déterminer quel type d'instructions afficher
const instructionType = computed(() => {
if (props.isIOS) return 'ios'
if (props.isAndroid) return 'android'
if (props.isWindows || props.isMac) return 'desktop'
return 'android' // Par défaut
})
function close() { function close() {
emit('close') emit('close')
} }

View File

@@ -123,23 +123,28 @@ instance.interceptors.response.use(
} }
if (error.response?.status === 401) { if (error.response?.status === 401) {
// Ne pas déconnecter automatiquement sur les requêtes POST/PUT avec FormData const currentRoute = router.currentRoute.value
// car les erreurs peuvent être dues à des problèmes réseau temporaires sur mobile const errorDetail = error.response?.data?.detail || ''
const isFormDataUpload = error.config?.data instanceof FormData || const errorDetailLower = errorDetail.toLowerCase()
(error.config?.method === 'POST' || error.config?.method === 'PUT')
// Vérifier si c'est une vraie erreur d'authentification
const isRealAuthError = errorDetailLower.includes('credential') ||
errorDetailLower.includes('token') ||
errorDetailLower.includes('not authenticated') ||
errorDetailLower.includes('could not validate') ||
errorDetailLower.includes('expired')
console.warn(`🔒 401 reçu - Auth error: ${isRealAuthError}, Detail: ${errorDetail}`)
// Ne pas rediriger si on est déjà sur une page d'auth // Ne pas rediriger si on est déjà sur une page d'auth
const currentRoute = router.currentRoute.value
if (!currentRoute.path.includes('/login') && !currentRoute.path.includes('/register')) { if (!currentRoute.path.includes('/login') && !currentRoute.path.includes('/register')) {
// Pour les uploads, ne déconnecter que si c'est vraiment une erreur d'authentification if (isRealAuthError) {
// (pas juste une erreur réseau qui se manifeste comme 401)
if (!isFormDataUpload || error.response?.data?.detail?.includes('credentials') || error.response?.data?.detail?.includes('token')) {
localStorage.removeItem('token') localStorage.removeItem('token')
router.push('/login') router.push('/login')
toast.error('Session expirée, veuillez vous reconnecter') toast.error('Session expirée, veuillez vous reconnecter')
} else { } else {
// Pour les uploads, juste afficher une erreur sans déconnecter // Erreur 401 non liée à l'auth (rare mais possible)
toast.error('Erreur lors de l\'upload. Veuillez réessayer.') toast.error('Erreur d\'autorisation')
} }
} }
} else if (error.response?.status === 403) { } else if (error.response?.status === 403) {
@@ -210,27 +215,145 @@ export function getAppUrl() {
return import.meta.env.VITE_APP_URL || window.location.origin return import.meta.env.VITE_APP_URL || window.location.origin
} }
// Fonction utilitaire pour upload de FormData via fetch natif // Fonction utilitaire pour les requêtes POST JSON via fetch natif
// (contourne les problèmes d'axios avec FormData sur certains navigateurs/mobiles) // (contourne les problèmes d'axios sur certains navigateurs mobiles/PWA)
export async function uploadFormData(endpoint, formData) { export async function postJson(endpoint, data = {}) {
const token = localStorage.getItem('token') const token = localStorage.getItem('token')
const apiUrl = getApiUrl() const apiUrl = getApiUrl()
const response = await fetch(`${apiUrl}${endpoint}`, { console.log(`📤 POST JSON vers: ${apiUrl}${endpoint}`)
method: 'POST',
headers: { try {
'Authorization': `Bearer ${token}` const response = await fetch(`${apiUrl}${endpoint}`, {
// Ne PAS mettre Content-Type, fetch le gère automatiquement avec FormData method: 'POST',
}, headers: {
body: formData 'Authorization': `Bearer ${token}`,
}) 'Content-Type': 'application/json'
},
body: JSON.stringify(data)
})
console.log(`📥 POST response status: ${response.status}`)
if (!response.ok) { if (!response.ok) {
const errorData = await response.json().catch(() => ({})) const errorData = await response.json().catch(() => ({ detail: 'Erreur inconnue' }))
const error = new Error(errorData.detail || 'Erreur lors de l\'upload') console.error('❌ POST error:', errorData)
error.response = { status: response.status, data: errorData }
const toast = useToast()
if (response.status === 401) {
const errorDetail = (errorData.detail || '').toLowerCase()
const isRealAuthError = errorDetail.includes('credential') ||
errorDetail.includes('token') ||
errorDetail.includes('not authenticated') ||
errorDetail.includes('could not validate') ||
errorDetail.includes('expired')
if (isRealAuthError) {
localStorage.removeItem('token')
router.push('/login')
toast.error('Session expirée, veuillez vous reconnecter')
} else {
toast.error('Erreur d\'autorisation')
}
} else if (response.status === 403) {
toast.error('Accès non autorisé')
} else if (response.status === 422) {
toast.error('Données invalides')
} else {
toast.error(errorData.detail || 'Erreur serveur')
}
const error = new Error(errorData.detail || 'Erreur')
error.response = { status: response.status, data: errorData }
throw error
}
return await response.json()
} catch (error) {
if (!error.response) {
console.error('❌ Network error:', error)
const toast = useToast()
toast.error('Erreur de connexion')
}
throw error
}
}
// Fonction utilitaire pour upload de FormData via fetch natif
// (contourne les problèmes d'axios avec FormData sur certains navigateurs/mobiles)
export async function uploadFormData(endpoint, formData, options = {}) {
const token = localStorage.getItem('token')
const apiUrl = getApiUrl()
const toast = useToast()
// Timeout par défaut de 5 minutes pour les uploads
const timeout = options.timeout || 300000
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), timeout)
console.log(`📤 Upload FormData vers: ${apiUrl}${endpoint}`)
try {
const response = await fetch(`${apiUrl}${endpoint}`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`
// Ne PAS mettre Content-Type, fetch le gère automatiquement avec FormData
},
body: formData,
signal: controller.signal
})
clearTimeout(timeoutId)
console.log(`📥 Upload response status: ${response.status}`)
if (!response.ok) {
const errorData = await response.json().catch(() => ({ detail: 'Erreur inconnue' }))
console.error('❌ Upload error:', errorData)
// Gestion des erreurs d'authentification
if (response.status === 401) {
// Vérifier si c'est vraiment une erreur d'auth ou juste un problème réseau
const isAuthError = errorData.detail?.toLowerCase().includes('credential') ||
errorData.detail?.toLowerCase().includes('token') ||
errorData.detail?.toLowerCase().includes('not authenticated')
if (isAuthError) {
localStorage.removeItem('token')
router.push('/login')
toast.error('Session expirée, veuillez vous reconnecter')
} else {
toast.error('Erreur d\'authentification lors de l\'upload')
}
} else if (response.status === 413) {
toast.error('Fichier trop volumineux')
} else if (response.status === 422) {
toast.error('Données invalides: ' + (errorData.detail || 'Vérifiez le formulaire'))
} else {
toast.error(errorData.detail || 'Erreur lors de l\'upload')
}
const error = new Error(errorData.detail || 'Erreur lors de l\'upload')
error.response = { status: response.status, data: errorData }
throw error
}
const data = await response.json()
console.log('✅ Upload réussi')
return data
} catch (error) {
clearTimeout(timeoutId)
if (error.name === 'AbortError') {
console.error('❌ Upload timeout')
toast.error('Délai d\'attente dépassé. Le fichier est peut-être trop volumineux.')
throw new Error('Timeout lors de l\'upload')
}
// Erreur réseau
if (!error.response) {
console.error('❌ Network error during upload:', error)
toast.error('Erreur de connexion lors de l\'upload')
}
throw error throw error
} }
return await response.json()
} }

View File

@@ -950,8 +950,9 @@
<img <img
:src="getMediaUrl(selectedTicket.screenshot_path)" :src="getMediaUrl(selectedTicket.screenshot_path)"
:alt="'Screenshot du ticket ' + selectedTicket.title" :alt="'Screenshot du ticket ' + selectedTicket.title"
class="max-w-full h-auto rounded-lg shadow-sm ticket-screenshot cursor-pointer" class="w-full sm:max-w-2xl h-auto rounded-lg shadow-sm ticket-screenshot cursor-pointer"
@click="openImageModal(selectedTicket.screenshot_path)" @click="openImageModal(selectedTicket.screenshot_path)"
@error="(e) => console.error('Image load error:', e.target.src)"
> >
<p class="text-xs text-gray-500 mt-2">Cliquez sur l'image pour l'agrandir</p> <p class="text-xs text-gray-500 mt-2">Cliquez sur l'image pour l'agrandir</p>
</div> </div>
@@ -1009,7 +1010,7 @@
> >
<div <div
v-if="showImageModal" v-if="showImageModal"
class="fixed inset-0 bg-black bg-opacity-90 z-60 flex items-center justify-center p-4" class="fixed inset-0 bg-black bg-opacity-90 z-[70] flex items-center justify-center p-4"
@click="showImageModal = false" @click="showImageModal = false"
> >
<div class="relative max-w-4xl max-h-[90vh]"> <div class="relative max-w-4xl max-h-[90vh]">
@@ -1849,7 +1850,11 @@ function openImageModal(imageUrl) {
function getMediaUrl(path) { function getMediaUrl(path) {
if (!path) return '' if (!path) return ''
return path.startsWith('http') ? path : `${import.meta.env.VITE_API_URL || 'http://localhost:8002'}${path}` if (path.startsWith('http')) return path
const baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:8002'
// Ensure path starts with /
const normalizedPath = path.startsWith('/') ? path : `/${path}`
return `${baseUrl}${normalizedPath}`
} }
// Nouvelles fonctions pour les filtres et actions rapides // Nouvelles fonctions pour les filtres et actions rapides

View File

@@ -475,6 +475,17 @@
<X class="w-6 h-6" /> <X class="w-6 h-6" />
</button> </button>
<!-- Download Button -->
<a
:href="getMediaUrl(selectedMedia.file_path)"
:download="selectedMedia.caption || 'media'"
target="_blank"
class="absolute top-6 right-20 z-10 bg-black bg-opacity-70 text-white rounded-full w-12 h-12 flex items-center justify-center hover:bg-opacity-90 transition-colors shadow-lg"
title="Télécharger"
>
<Download class="w-6 h-6" />
</a>
<!-- Navigation Buttons --> <!-- Navigation Buttons -->
@@ -582,7 +593,8 @@ import {
Eye, Eye,
Heart, Heart,
ChevronLeft, ChevronLeft,
ChevronRight ChevronRight,
Download
} from 'lucide-vue-next' } from 'lucide-vue-next'
import LoadingLogo from '@/components/LoadingLogo.vue' import LoadingLogo from '@/components/LoadingLogo.vue'

View File

@@ -333,7 +333,7 @@
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { useToast } from 'vue-toastification' import { useToast } from 'vue-toastification'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import axios from '@/utils/axios' import axios, { postJson } from '@/utils/axios'
import { getMediaUrl, uploadFormData } from '@/utils/axios' import { getMediaUrl, uploadFormData } from '@/utils/axios'
import { formatRelativeDateInFrenchTimezone, formatShortDateInFrenchTimezone } from '@/utils/dateUtils' import { formatRelativeDateInFrenchTimezone, formatShortDateInFrenchTimezone } from '@/utils/dateUtils'
import { import {
@@ -630,8 +630,7 @@ async function createAlbum() {
} }
uploadStatus.value = 'Création de l\'album...' uploadStatus.value = 'Création de l\'album...'
const albumResponse = await axios.post('/api/albums', albumData) const album = await postJson('/api/albums', albumData)
const album = albumResponse.data
// Upload media files in batches for better performance // Upload media files in batches for better performance
const batchSize = 5 // Upload 5 files at a time const batchSize = 5 // Upload 5 files at a time

View File

@@ -221,23 +221,25 @@
> >
</div> </div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div> <div>
<label class="label">Date et heure</label> <label class="label text-sm">Date et heure</label>
<input <input
v-model="newEvent.date" v-model="newEvent.date"
type="datetime-local" type="datetime-local"
required required
class="input" class="input text-sm w-full"
style="min-width: 0;"
> >
</div> </div>
<div> <div>
<label class="label">Date de fin (optionnel)</label> <label class="label text-sm">Date de fin (optionnel)</label>
<input <input
v-model="newEvent.end_date" v-model="newEvent.end_date"
type="datetime-local" type="datetime-local"
class="input" class="input text-sm w-full"
style="min-width: 0;"
> >
</div> </div>
</div> </div>
@@ -331,7 +333,7 @@
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useToast } from 'vue-toastification' import { useToast } from 'vue-toastification'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import axios from '@/utils/axios' import axios, { postJson } from '@/utils/axios'
import { getMediaUrl } from '@/utils/axios' import { getMediaUrl } from '@/utils/axios'
import { formatRelativeDateInFrenchTimezone, formatShortDateInFrenchTimezone, convertFrenchTimezoneToUTC } from '@/utils/dateUtils' import { formatRelativeDateInFrenchTimezone, formatShortDateInFrenchTimezone, convertFrenchTimezoneToUTC } from '@/utils/dateUtils'
import { import {
@@ -496,11 +498,11 @@ async function createEvent() {
} }
console.log('🔵 eventData préparé:', eventData) console.log('🔵 eventData préparé:', eventData)
console.log('🔵 Envoi de la requête axios.post...') console.log('🔵 Envoi de la requête postJson...')
const response = await axios.post('/api/events', eventData) const data = await postJson('/api/events', eventData)
console.log('✅ Réponse reçue:', response) console.log('✅ Réponse reçue:', data)
// Refresh events list // Refresh events list
await fetchEvents() await fetchEvents()

View File

@@ -140,7 +140,7 @@
<img <img
:src="getMediaUrl(ticket.screenshot_path)" :src="getMediaUrl(ticket.screenshot_path)"
:alt="ticket.title" :alt="ticket.title"
class="max-w-md rounded-lg border border-gray-200 cursor-pointer hover:opacity-90 transition-opacity" class="w-full sm:max-w-md rounded-lg border border-gray-200 cursor-pointer hover:opacity-90 transition-opacity"
@click="viewScreenshot(ticket.screenshot_path)" @click="viewScreenshot(ticket.screenshot_path)"
> >
</div> </div>

View File

@@ -288,7 +288,7 @@ import { ref, computed, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useToast } from 'vue-toastification' import { useToast } from 'vue-toastification'
import axios from '@/utils/axios' import axios, { postJson } from '@/utils/axios'
import { getMediaUrl, uploadFormData } from '@/utils/axios' import { getMediaUrl, uploadFormData } from '@/utils/axios'
import { formatRelativeDateInFrenchTimezone } from '@/utils/dateUtils' import { formatRelativeDateInFrenchTimezone } from '@/utils/dateUtils'
import { import {
@@ -355,14 +355,14 @@ async function createPost() {
creating.value = true creating.value = true
try { try {
const response = await axios.post('/api/posts', { const data = await postJson('/api/posts', {
content: newPost.value.content, content: newPost.value.content,
image_url: newPost.value.image_url, image_url: newPost.value.image_url,
mentioned_user_ids: newPost.value.mentioned_user_ids mentioned_user_ids: newPost.value.mentioned_user_ids
}) })
// Add new post to the beginning of the list // Add new post to the beginning of the list
posts.value.unshift(response.data) posts.value.unshift(data)
// Forcer le rafraîchissement de la date immédiatement // Forcer le rafraîchissement de la date immédiatement
dateRefreshKey.value++ dateRefreshKey.value++

View File

@@ -68,7 +68,10 @@
> >
</div> </div>
<div> <div>
<label for="username" class="label text-sm sm:text-base">Nom d'utilisateur</label> <label for="username" class="label text-sm sm:text-base">
Identifiant unique
<span class="text-xs text-gray-500 font-normal ml-1">(non modifiable)</span>
</label>
<input <input
id="username" id="username"
v-model="form.username" v-model="form.username"
@@ -76,22 +79,26 @@
required required
minlength="3" minlength="3"
class="input text-sm sm:text-base" class="input text-sm sm:text-base"
placeholder="nom_utilisateur" placeholder="mon_pseudo"
@blur="touchedFields.username = true" @blur="touchedFields.username = true"
> >
</div> </div>
</div> </div>
<div> <div>
<label for="full_name" class="label text-sm sm:text-base">Nom complet</label> <label for="full_name" class="label text-sm sm:text-base">
Pseudo affiché
<span class="text-xs text-gray-500 font-normal ml-1">(modifiable plus tard)</span>
</label>
<input <input
id="full_name" id="full_name"
v-model="form.full_name" v-model="form.full_name"
type="text" type="text"
required required
class="input text-sm sm:text-base" class="input text-sm sm:text-base"
placeholder="Prénom Nom" placeholder="Ton surnom / prénom"
@blur="touchedFields.full_name = true" @blur="touchedFields.full_name = true"
> >
<p class="text-xs text-gray-500 mt-1">C'est ce qui sera affiché aux autres membres</p>
</div> </div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 sm:gap-6"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4 sm:gap-6">
<div> <div>
@@ -337,6 +344,9 @@
<PWAInstallTutorial <PWAInstallTutorial
:show="showPWAInstructions" :show="showPWAInstructions"
:is-ios="isIOS" :is-ios="isIOS"
:is-android="isAndroid"
:is-windows="isWindows"
:is-mac="isMac"
@close="showPWAInstructions = false" @close="showPWAInstructions = false"
/> />
</div> </div>
@@ -451,6 +461,9 @@ const isMobile = ref(false)
const showPWAInstructions = ref(false) const showPWAInstructions = ref(false)
const isIOS = computed(() => /iPhone|iPad|iPod/i.test(navigator.userAgent)) const isIOS = computed(() => /iPhone|iPad|iPod/i.test(navigator.userAgent))
const isAndroid = computed(() => /Android/i.test(navigator.userAgent))
const isWindows = computed(() => /Windows/i.test(navigator.userAgent))
const isMac = computed(() => /Macintosh|Mac OS/i.test(navigator.userAgent) && !isIOS.value)
function checkIfMobile() { function checkIfMobile() {
isMobile.value = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent) || isMobile.value = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent) ||
@@ -476,8 +489,8 @@ async function handleInstallApp() {
} catch (error) { } catch (error) {
console.error('Erreur lors de l\'installation:', error) console.error('Erreur lors de l\'installation:', error)
} }
} else if (isMobile.value) { } else {
// Sur mobile sans beforeinstallprompt, afficher les instructions // Si pas de beforeinstallprompt, afficher les instructions (mobile ou desktop)
showPWAInstructions.value = true showPWAInstructions.value = true
} }
} }