1120 lines
29 KiB
JavaScript
1120 lines
29 KiB
JavaScript
/**
|
|
* Serveur web pour héberger les transcripts avec authentification Discord
|
|
* Interface professionnelle avec dashboard et statistiques
|
|
*/
|
|
|
|
require('dotenv').config();
|
|
const express = require('express');
|
|
const session = require('express-session');
|
|
const passport = require('passport');
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
const db = require('../functions/database/db.js');
|
|
|
|
// Note: passport-discord peut nécessiter une installation séparée
|
|
let DiscordStrategy;
|
|
try {
|
|
DiscordStrategy = require('passport-discord').Strategy;
|
|
} catch (err) {
|
|
console.warn('⚠️ passport-discord non installé. Installer avec: npm install passport-discord');
|
|
DiscordStrategy = null;
|
|
}
|
|
|
|
const app = express();
|
|
const PORT = process.env.TRANSCRIPT_PORT || 3000;
|
|
|
|
// Middleware pour parser les données
|
|
app.use(express.json());
|
|
app.use(express.urlencoded({ extended: true }));
|
|
|
|
// Configuration de la session
|
|
app.use(session({
|
|
secret: process.env.SESSION_SECRET || 'change-me-in-production',
|
|
resave: false,
|
|
saveUninitialized: false,
|
|
cookie: {
|
|
secure: process.env.NODE_ENV === 'production',
|
|
httpOnly: true,
|
|
maxAge: 24 * 60 * 60 * 1000 // 24 heures
|
|
}
|
|
}));
|
|
|
|
// Configuration Passport
|
|
app.use(passport.initialize());
|
|
app.use(passport.session());
|
|
|
|
// Variable pour savoir si la stratégie Discord est configurée
|
|
let discordStrategyConfigured = false;
|
|
|
|
// Récupérer le Client ID et Secret (avec fallback pour compatibilité)
|
|
const discordClientId = process.env.DISCORD_CLIENT_ID || process.env.CLIENT_ID;
|
|
const discordClientSecret = process.env.DISCORD_CLIENT_SECRET || process.env.CLIENT_SECRET;
|
|
|
|
// Configuration Discord OAuth
|
|
if (DiscordStrategy && discordClientId && discordClientSecret) {
|
|
try {
|
|
passport.use('discord', new DiscordStrategy({
|
|
clientID: discordClientId,
|
|
clientSecret: discordClientSecret,
|
|
callbackURL: process.env.DISCORD_CALLBACK_URL || `http://localhost:${PORT}/auth/discord/callback`,
|
|
scope: ['identify', 'guilds']
|
|
}, (accessToken, refreshToken, profile, done) => {
|
|
return done(null, profile);
|
|
}));
|
|
discordStrategyConfigured = true;
|
|
console.log('✅ Stratégie Discord OAuth configurée avec succès');
|
|
} catch (err) {
|
|
console.error('❌ Erreur lors de la configuration de la stratégie Discord:', err);
|
|
discordStrategyConfigured = false;
|
|
}
|
|
} else {
|
|
if (!DiscordStrategy) {
|
|
console.warn('⚠️ passport-discord non installé. Installer avec: npm install passport-discord');
|
|
} else {
|
|
if (!discordClientId) {
|
|
console.warn('⚠️ DISCORD_CLIENT_ID ou CLIENT_ID manquant dans .env');
|
|
}
|
|
if (!discordClientSecret) {
|
|
console.warn('⚠️ DISCORD_CLIENT_SECRET ou CLIENT_SECRET manquant dans .env');
|
|
}
|
|
console.warn('⚠️ Discord OAuth non configuré. Configurer DISCORD_CLIENT_SECRET (ou CLIENT_SECRET) et DISCORD_CLIENT_ID (ou CLIENT_ID) dans .env');
|
|
}
|
|
discordStrategyConfigured = false;
|
|
}
|
|
|
|
passport.serializeUser((user, done) => {
|
|
done(null, user);
|
|
});
|
|
|
|
passport.deserializeUser((obj, done) => {
|
|
done(null, obj);
|
|
});
|
|
|
|
// Middleware pour vérifier l'authentification
|
|
function isAuthenticated(req, res, next) {
|
|
if (req.isAuthenticated()) {
|
|
return next();
|
|
}
|
|
res.redirect('/login');
|
|
}
|
|
|
|
// Middleware pour vérifier les permissions
|
|
async function hasPermission(req, res, next) {
|
|
if (!req.isAuthenticated()) {
|
|
return res.redirect('/login');
|
|
}
|
|
|
|
const filePath = req.params[0]; // Ex: plainte/2025-11-09/file.html (sans le préfixe /tickets/ ou /transcripts/)
|
|
// Le chemin dans la DB est transcripts/type/date/file.html ou tickets/type/date/file.html (ancien format)
|
|
// On cherche avec les deux formats pour compatibilité
|
|
const transcriptsPath = `transcripts/${filePath}`;
|
|
const ticketsPath = `tickets/${filePath}`;
|
|
|
|
try {
|
|
// Chercher avec les deux formats (nouveau: transcripts/, ancien: tickets/)
|
|
const [tickets] = await db.query(
|
|
'SELECT * FROM tickets WHERE transcriptPath = ? OR transcriptPath = ? OR transcriptPath LIKE ?',
|
|
[transcriptsPath, ticketsPath, `%${filePath}`]
|
|
);
|
|
|
|
if (tickets.length === 0) {
|
|
return res.status(404).send('Transcript introuvable.');
|
|
}
|
|
|
|
const ticket = tickets[0];
|
|
const userId = req.user.id;
|
|
|
|
if (ticket.userId === userId) {
|
|
return next();
|
|
}
|
|
|
|
// Vérifier si l'utilisateur est modérateur
|
|
// Pour l'instant, on vérifie si l'utilisateur est dans la liste des modérateurs configurés
|
|
// TODO: Implémenter une vérification Discord réelle (rôles, permissions)
|
|
const moderatorIds = process.env.MODERATOR_IDS ? process.env.MODERATOR_IDS.split(',') : [];
|
|
if (moderatorIds.includes(userId)) {
|
|
return next();
|
|
}
|
|
|
|
// Sinon, accès refusé
|
|
return res.status(403).send('Accès refusé. Vous n\'avez pas la permission d\'accéder à ce transcript.');
|
|
|
|
} catch (err) {
|
|
console.error('Erreur lors de la vérification des permissions:', err);
|
|
return res.status(500).send('Erreur serveur.');
|
|
}
|
|
}
|
|
|
|
// Fonction pour générer le HTML de la page
|
|
function generateDashboardHTML(user, stats, userTickets, allTickets) {
|
|
// Gérer les nouveaux et anciens formats de Discord (discriminator peut être null ou '0')
|
|
let userAvatar;
|
|
if (user.avatar) {
|
|
userAvatar = `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}.png?size=128`;
|
|
} else if (user.discriminator && user.discriminator !== '0') {
|
|
userAvatar = `https://cdn.discordapp.com/embed/avatars/${user.discriminator % 5}.png`;
|
|
} else {
|
|
// Pour les nouveaux comptes sans discriminator, utiliser l'ID mod 5
|
|
userAvatar = `https://cdn.discordapp.com/embed/avatars/${parseInt(user.id) % 5}.png`;
|
|
}
|
|
|
|
return `<!DOCTYPE html>
|
|
<html lang="fr">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Dashboard - Transcripts France Femboy</title>
|
|
<style>
|
|
* {
|
|
margin: 0;
|
|
padding: 0;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
min-height: 100vh;
|
|
color: #333;
|
|
}
|
|
|
|
.navbar {
|
|
background: rgba(255, 255, 255, 0.95);
|
|
backdrop-filter: blur(10px);
|
|
padding: 15px 30px;
|
|
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
|
|
.navbar-brand {
|
|
font-size: 1.5em;
|
|
font-weight: 700;
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
-webkit-background-clip: text;
|
|
-webkit-text-fill-color: transparent;
|
|
background-clip: text;
|
|
text-decoration: none;
|
|
}
|
|
|
|
.navbar-links {
|
|
display: flex;
|
|
gap: 20px;
|
|
align-items: center;
|
|
}
|
|
|
|
.navbar-links a {
|
|
color: #667eea;
|
|
text-decoration: none;
|
|
font-weight: 500;
|
|
transition: color 0.2s;
|
|
}
|
|
|
|
.navbar-links a:hover {
|
|
color: #5568d3;
|
|
}
|
|
|
|
.user-menu {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 15px;
|
|
}
|
|
|
|
.user-info {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
}
|
|
|
|
.user-avatar {
|
|
width: 40px;
|
|
height: 40px;
|
|
border-radius: 50%;
|
|
border: 2px solid #667eea;
|
|
}
|
|
|
|
.user-name {
|
|
font-weight: 600;
|
|
color: #333;
|
|
}
|
|
|
|
.logout-btn {
|
|
padding: 8px 16px;
|
|
background: #ef4444;
|
|
color: white;
|
|
text-decoration: none;
|
|
border-radius: 6px;
|
|
font-weight: 500;
|
|
transition: background 0.2s;
|
|
}
|
|
|
|
.logout-btn:hover {
|
|
background: #dc2626;
|
|
}
|
|
|
|
.container {
|
|
max-width: 1400px;
|
|
margin: 30px auto;
|
|
padding: 0 30px;
|
|
}
|
|
|
|
.stats-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
|
gap: 20px;
|
|
margin-bottom: 30px;
|
|
}
|
|
|
|
.stat-card {
|
|
background: white;
|
|
padding: 25px;
|
|
border-radius: 12px;
|
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
|
transition: transform 0.2s, box-shadow 0.2s;
|
|
}
|
|
|
|
.stat-card:hover {
|
|
transform: translateY(-2px);
|
|
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.15);
|
|
}
|
|
|
|
.stat-icon {
|
|
font-size: 2.5em;
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
.stat-label {
|
|
font-size: 0.9em;
|
|
color: #666;
|
|
margin-bottom: 5px;
|
|
}
|
|
|
|
.stat-value {
|
|
font-size: 2em;
|
|
font-weight: 700;
|
|
color: #333;
|
|
}
|
|
|
|
.section {
|
|
background: white;
|
|
border-radius: 12px;
|
|
padding: 30px;
|
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
|
margin-bottom: 30px;
|
|
}
|
|
|
|
.section-title {
|
|
font-size: 1.5em;
|
|
font-weight: 700;
|
|
margin-bottom: 20px;
|
|
color: #333;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
}
|
|
|
|
.tickets-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
|
gap: 20px;
|
|
}
|
|
|
|
.ticket-card {
|
|
background: #f8f9fa;
|
|
border-left: 4px solid #667eea;
|
|
padding: 20px;
|
|
border-radius: 8px;
|
|
transition: transform 0.2s, box-shadow 0.2s;
|
|
}
|
|
|
|
.ticket-card:hover {
|
|
transform: translateX(5px);
|
|
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
|
}
|
|
|
|
.ticket-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: start;
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
.ticket-id {
|
|
font-weight: 600;
|
|
color: #667eea;
|
|
font-size: 1.1em;
|
|
}
|
|
|
|
.ticket-type {
|
|
display: inline-block;
|
|
padding: 4px 12px;
|
|
background: #667eea;
|
|
color: white;
|
|
border-radius: 12px;
|
|
font-size: 0.8em;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.ticket-info {
|
|
font-size: 0.9em;
|
|
color: #666;
|
|
margin-bottom: 15px;
|
|
}
|
|
|
|
.ticket-status {
|
|
display: inline-block;
|
|
padding: 4px 12px;
|
|
border-radius: 12px;
|
|
font-size: 0.8em;
|
|
font-weight: 600;
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
.status-open {
|
|
background: #10b981;
|
|
color: white;
|
|
}
|
|
|
|
.status-closed {
|
|
background: #ef4444;
|
|
color: white;
|
|
}
|
|
|
|
.status-pending {
|
|
background: #f59e0b;
|
|
color: white;
|
|
}
|
|
|
|
.view-btn {
|
|
display: inline-block;
|
|
padding: 8px 16px;
|
|
background: #667eea;
|
|
color: white;
|
|
text-decoration: none;
|
|
border-radius: 6px;
|
|
font-weight: 500;
|
|
transition: background 0.2s;
|
|
}
|
|
|
|
.view-btn:hover {
|
|
background: #5568d3;
|
|
}
|
|
|
|
.no-tickets {
|
|
text-align: center;
|
|
padding: 40px;
|
|
color: #666;
|
|
font-style: italic;
|
|
}
|
|
|
|
.type-badge {
|
|
display: inline-block;
|
|
padding: 4px 12px;
|
|
border-radius: 12px;
|
|
font-size: 0.8em;
|
|
font-weight: 600;
|
|
margin-right: 8px;
|
|
}
|
|
|
|
.type-support { background: #3b82f6; color: white; }
|
|
.type-plainte { background: #8b5cf6; color: white; }
|
|
.type-plainte-staff { background: #ef4444; color: white; }
|
|
.type-candidature { background: #10b981; color: white; }
|
|
.type-probleme-technique { background: #f59e0b; color: white; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<nav class="navbar">
|
|
<a href="/" class="navbar-brand">🎫 Transcripts Dashboard</a>
|
|
<div style="display: flex; align-items: center; gap: 20px;">
|
|
<div class="navbar-links">
|
|
<a href="/transcripts">Tous les Transcripts</a>
|
|
</div>
|
|
<div class="user-menu">
|
|
<div class="user-info">
|
|
<img src="${userAvatar}" alt="${escapeHtml(user.username)}" class="user-avatar">
|
|
<span class="user-name">${escapeHtml(user.username)}</span>
|
|
</div>
|
|
<a href="/auth/logout" class="logout-btn">Déconnexion</a>
|
|
</div>
|
|
</div>
|
|
</nav>
|
|
|
|
<div class="container">
|
|
<div class="stats-grid">
|
|
<div class="stat-card">
|
|
<div class="stat-icon">📊</div>
|
|
<div class="stat-label">Tickets Totaux</div>
|
|
<div class="stat-value">${stats.totalTickets}</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-icon">📝</div>
|
|
<div class="stat-label">Mes Tickets</div>
|
|
<div class="stat-value">${stats.myTickets}</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-icon">🔓</div>
|
|
<div class="stat-label">Tickets Ouverts</div>
|
|
<div class="stat-value">${stats.openTickets}</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-icon">🔒</div>
|
|
<div class="stat-label">Tickets Fermés</div>
|
|
<div class="stat-value">${stats.closedTickets}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="section">
|
|
<h2 class="section-title">📄 Mes Tickets</h2>
|
|
${userTickets.length > 0 ? `
|
|
<div class="tickets-grid">
|
|
${userTickets.map(ticket => generateTicketCard(ticket)).join('')}
|
|
</div>
|
|
` : '<div class="no-tickets">Aucun ticket réclamé.</div>'}
|
|
</div>
|
|
|
|
<div class="section">
|
|
<h2 class="section-title">🌐 Tous les Transcripts</h2>
|
|
<p style="margin-bottom: 15px; color: #666;">Voir tous les transcripts sur la <a href="/transcripts" style="color: #667eea; font-weight: 600;">page dédiée</a>.</p>
|
|
${allTickets.length > 0 ? `
|
|
<div class="tickets-grid">
|
|
${allTickets.slice(0, 6).map(ticket => generateTicketCard(ticket)).join('')}
|
|
</div>
|
|
${allTickets.length > 6 ? `<p style="text-align: center; margin-top: 20px;"><a href="/transcripts" class="view-btn">Voir tous les transcripts (${allTickets.length})</a></p>` : ''}
|
|
` : '<div class="no-tickets">Aucun transcript disponible.</div>'}
|
|
</div>
|
|
</div>
|
|
</body>
|
|
</html>`;
|
|
}
|
|
|
|
// Fonction pour formater la date selon le fuseau horaire (France par défaut)
|
|
// Format: "9/11/2025, 18h43" (format français court avec heure)
|
|
function formatDate(timestamp, timezone = 'Europe/Paris') {
|
|
try {
|
|
const date = new Date(timestamp);
|
|
|
|
// Utiliser Intl.DateTimeFormat pour gérer correctement les fuseaux horaires
|
|
const formatter = new Intl.DateTimeFormat('fr-FR', {
|
|
timeZone: timezone,
|
|
year: 'numeric',
|
|
month: 'numeric',
|
|
day: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
hour12: false
|
|
});
|
|
|
|
// Formater la date
|
|
const parts = formatter.formatToParts(date);
|
|
const day = parts.find(p => p.type === 'day').value;
|
|
const month = parts.find(p => p.type === 'month').value;
|
|
const year = parts.find(p => p.type === 'year').value;
|
|
const hour = parts.find(p => p.type === 'hour').value;
|
|
const minute = parts.find(p => p.type === 'minute').value;
|
|
|
|
return `${day}/${month}/${year}, ${hour}h${minute}`;
|
|
} catch (err) {
|
|
// Fallback sur le format français standard
|
|
const date = new Date(timestamp);
|
|
const day = date.getDate();
|
|
const month = date.getMonth() + 1;
|
|
const year = date.getFullYear();
|
|
const hours = date.getHours().toString().padStart(2, '0');
|
|
const minutes = date.getMinutes().toString().padStart(2, '0');
|
|
return `${day}/${month}/${year}, ${hours}h${minutes}`;
|
|
}
|
|
}
|
|
|
|
function generateTicketCard(ticket) {
|
|
const date = formatDate(ticket.createdAt);
|
|
|
|
const url = ticket.transcriptPath ? `/${ticket.transcriptPath}` : '#';
|
|
const typeClass = `type-${ticket.type.toLowerCase().replace(/\s+/g, '-')}`;
|
|
const statusClass = `status-${ticket.status.toLowerCase()}`;
|
|
|
|
const typeEmojis = {
|
|
'Support': '💬',
|
|
'Plainte': '📢',
|
|
'Plainte Staff': '⚠️',
|
|
'Candidature': '📝',
|
|
'Problème Technique': '🔧'
|
|
};
|
|
|
|
return `
|
|
<div class="ticket-card">
|
|
<div class="ticket-header">
|
|
<div class="ticket-id">${typeEmojis[ticket.type] || '🎫'} ${ticket.ticketId}</div>
|
|
<span class="ticket-type ${typeClass}">${ticket.type}</span>
|
|
</div>
|
|
<div class="ticket-info">
|
|
<div><strong>Créé par:</strong> ${escapeHtml(ticket.userTag)}</div>
|
|
${ticket.claimedBy ? `<div><strong>Géré par:</strong> ${ticket.claimedByTag || 'Modérateur'}</div>` : ''}
|
|
<div><strong>Date:</strong> ${date}</div>
|
|
</div>
|
|
<div>
|
|
<span class="ticket-status ${statusClass}">${ticket.status}</span>
|
|
</div>
|
|
${ticket.transcriptPath ? `<a href="${url}" class="view-btn">Voir le transcript</a>` : '<span style="color: #999;">Transcript non disponible</span>'}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function escapeHtml(text) {
|
|
if (!text) return '';
|
|
const map = {
|
|
'&': '&',
|
|
'<': '<',
|
|
'>': '>',
|
|
'"': '"',
|
|
"'": '''
|
|
};
|
|
return text.replace(/[&<>"']/g, m => map[m]);
|
|
}
|
|
|
|
// Routes
|
|
app.get('/login', (req, res) => {
|
|
res.send(`
|
|
<!DOCTYPE html>
|
|
<html lang="fr">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Connexion - Transcripts</title>
|
|
<style>
|
|
* {
|
|
margin: 0;
|
|
padding: 0;
|
|
box-sizing: border-box;
|
|
}
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
min-height: 100vh;
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
margin: 0;
|
|
}
|
|
.login-box {
|
|
background: white;
|
|
padding: 50px;
|
|
border-radius: 16px;
|
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
|
text-align: center;
|
|
max-width: 400px;
|
|
width: 90%;
|
|
}
|
|
.login-box h1 {
|
|
margin-bottom: 10px;
|
|
color: #333;
|
|
}
|
|
.login-box p {
|
|
color: #666;
|
|
margin-bottom: 30px;
|
|
}
|
|
.login-button {
|
|
display: inline-block;
|
|
padding: 15px 30px;
|
|
background: #5865F2;
|
|
color: white;
|
|
text-decoration: none;
|
|
border-radius: 8px;
|
|
font-weight: 600;
|
|
transition: background 0.2s;
|
|
}
|
|
.login-button:hover {
|
|
background: #4752C4;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="login-box">
|
|
<h1>🔐 Connexion requise</h1>
|
|
<p>Pour accéder aux transcripts, vous devez vous connecter avec Discord.</p>
|
|
<a href="/auth/discord" class="login-button">Se connecter avec Discord</a>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
`);
|
|
});
|
|
|
|
app.get('/auth/discord', (req, res, next) => {
|
|
if (!discordStrategyConfigured) {
|
|
return res.send(`
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>Erreur de Configuration</title>
|
|
<style>
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
min-height: 100vh;
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
margin: 0;
|
|
padding: 20px;
|
|
}
|
|
.error-box {
|
|
background: white;
|
|
padding: 40px;
|
|
border-radius: 16px;
|
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
|
max-width: 600px;
|
|
}
|
|
.error-box h1 {
|
|
color: #ef4444;
|
|
margin-bottom: 20px;
|
|
}
|
|
.error-box code {
|
|
background: #f3f4f6;
|
|
padding: 2px 6px;
|
|
border-radius: 4px;
|
|
font-family: 'Courier New', monospace;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="error-box">
|
|
<h1>⚠️ Discord OAuth non configuré</h1>
|
|
<p>Pour utiliser l'authentification Discord, vous devez :</p>
|
|
<ol>
|
|
<li>Installer <code>passport-discord</code> : <code>npm install passport-discord</code></li>
|
|
<li>Configurer <code>DISCORD_CLIENT_ID</code> et <code>DISCORD_CLIENT_SECRET</code> dans <code>.env</code></li>
|
|
<li>Configurer <code>DISCORD_CALLBACK_URL</code> dans <code>.env</code></li>
|
|
</ol>
|
|
<p><strong>Voir <code>server/README.md</code> pour plus d'informations.</strong></p>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
`);
|
|
}
|
|
passport.authenticate('discord')(req, res, next);
|
|
});
|
|
|
|
app.get('/auth/discord/callback',
|
|
(req, res, next) => {
|
|
if (!discordStrategyConfigured) {
|
|
return res.redirect('/login?error=not_configured');
|
|
}
|
|
passport.authenticate('discord', { failureRedirect: '/login?error=auth_failed' })(req, res, next);
|
|
},
|
|
(req, res) => {
|
|
res.redirect('/');
|
|
}
|
|
);
|
|
|
|
app.get('/auth/logout', (req, res) => {
|
|
req.logout(() => {
|
|
res.redirect('/login');
|
|
});
|
|
});
|
|
|
|
// Servir les fichiers statiques (CSS, JS, images, etc.)
|
|
// IMPORTANT: Cette route doit être AVANT les routes avec wildcards
|
|
app.use('/static', express.static(path.join(__dirname, 'public')));
|
|
|
|
// Servir les fichiers de transcripts
|
|
// Support à la fois /tickets/* et /transcripts/* pour compatibilité
|
|
app.get('/tickets/*', isAuthenticated, hasPermission, (req, res) => {
|
|
const filePath = req.params[0]; // Ex: plainte/2025-11-09/file.html
|
|
// Les fichiers sont toujours dans transcripts/, même si l'URL est /tickets/
|
|
const fullPath = path.join(process.cwd(), 'transcripts', filePath);
|
|
|
|
if (!fs.existsSync(fullPath)) {
|
|
return res.status(404).send('Transcript introuvable.');
|
|
}
|
|
|
|
res.sendFile(fullPath);
|
|
});
|
|
|
|
app.get('/transcripts/*', isAuthenticated, hasPermission, (req, res) => {
|
|
const filePath = req.params[0]; // Ex: plainte/2025-11-09/file.html
|
|
// Les fichiers sont dans transcripts/
|
|
const fullPath = path.join(process.cwd(), 'transcripts', filePath);
|
|
|
|
if (!fs.existsSync(fullPath)) {
|
|
return res.status(404).send('Transcript introuvable.');
|
|
}
|
|
|
|
res.sendFile(fullPath);
|
|
});
|
|
|
|
// Page d'accueil (Dashboard)
|
|
app.get('/', isAuthenticated, async (req, res) => {
|
|
try {
|
|
const userId = req.user.id;
|
|
|
|
// Récupérer les statistiques
|
|
const [totalStats] = await db.query('SELECT COUNT(*) as total FROM tickets');
|
|
const [myTicketsStats] = await db.query('SELECT COUNT(*) as total FROM tickets WHERE claimedBy = ?', [userId]);
|
|
const [openStats] = await db.query('SELECT COUNT(*) as total FROM tickets WHERE status = ?', ['Ouvert']);
|
|
const [closedStats] = await db.query('SELECT COUNT(*) as total FROM tickets WHERE status = ?', ['Fermé']);
|
|
|
|
const stats = {
|
|
totalTickets: totalStats[0].total,
|
|
myTickets: myTicketsStats[0].total,
|
|
openTickets: openStats[0].total,
|
|
closedTickets: closedStats[0].total
|
|
};
|
|
|
|
// Récupérer les tickets claimés par l'utilisateur (pour "Mes Tickets")
|
|
const [userTickets] = await db.query(
|
|
'SELECT * FROM tickets WHERE claimedBy = ? AND transcriptPath IS NOT NULL ORDER BY createdAt DESC LIMIT 20',
|
|
[userId]
|
|
);
|
|
|
|
// Récupérer tous les transcripts accessibles (avec transcriptPath) pour la page dédiée
|
|
const [allTickets] = await db.query(
|
|
'SELECT * FROM tickets WHERE transcriptPath IS NOT NULL ORDER BY createdAt DESC',
|
|
[]
|
|
);
|
|
|
|
const html = generateDashboardHTML(req.user, stats, userTickets, allTickets);
|
|
res.send(html);
|
|
|
|
} catch (err) {
|
|
console.error('Erreur lors de la génération du dashboard:', err);
|
|
res.status(500).send('Erreur serveur.');
|
|
}
|
|
});
|
|
|
|
// Page dédiée pour tous les transcripts (tableau)
|
|
app.get('/transcripts', isAuthenticated, async (req, res) => {
|
|
try {
|
|
const userId = req.user.id;
|
|
|
|
// Récupérer tous les transcripts avec les informations des modérateurs
|
|
const [allTickets] = await db.query(
|
|
`SELECT * FROM tickets
|
|
WHERE transcriptPath IS NOT NULL
|
|
ORDER BY createdAt DESC`,
|
|
[]
|
|
);
|
|
|
|
// Récupérer les informations des modérateurs qui ont claim les tickets
|
|
// Note: On pourrait utiliser l'API Discord pour récupérer les vrais noms, mais pour l'instant on utilise les tags stockés
|
|
// Pour améliorer, on pourrait créer une table de cache des utilisateurs Discord
|
|
|
|
const html = generateTranscriptsPageHTML(req.user, allTickets);
|
|
res.send(html);
|
|
|
|
} catch (err) {
|
|
console.error('Erreur lors de la génération de la page transcripts:', err);
|
|
res.status(500).send('Erreur serveur.');
|
|
}
|
|
});
|
|
|
|
// Fonction pour générer la page HTML des transcripts avec tableau
|
|
function generateTranscriptsPageHTML(user, tickets) {
|
|
// Gérer les nouveaux et anciens formats de Discord (discriminator peut être null ou '0')
|
|
let userAvatar;
|
|
if (user.avatar) {
|
|
userAvatar = `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}.png?size=128`;
|
|
} else if (user.discriminator && user.discriminator !== '0') {
|
|
userAvatar = `https://cdn.discordapp.com/embed/avatars/${user.discriminator % 5}.png`;
|
|
} else {
|
|
userAvatar = `https://cdn.discordapp.com/embed/avatars/${parseInt(user.id) % 5}.png`;
|
|
}
|
|
|
|
const typeEmojis = {
|
|
'Support': '💬',
|
|
'Plainte': '📢',
|
|
'Plainte Staff': '⚠️',
|
|
'Candidature': '📝',
|
|
'Problème Technique': '🔧'
|
|
};
|
|
|
|
const statusColors = {
|
|
'Ouvert': '#10b981',
|
|
'Fermé': '#ef4444',
|
|
'En attente': '#f59e0b',
|
|
'Supprimé': '#6b7280'
|
|
};
|
|
|
|
return `<!DOCTYPE html>
|
|
<html lang="fr">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Tous les Transcripts - France Femboy</title>
|
|
<style>
|
|
* {
|
|
margin: 0;
|
|
padding: 0;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
min-height: 100vh;
|
|
color: #333;
|
|
}
|
|
|
|
.navbar {
|
|
background: rgba(255, 255, 255, 0.95);
|
|
backdrop-filter: blur(10px);
|
|
padding: 15px 30px;
|
|
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
|
|
.navbar-brand {
|
|
font-size: 1.5em;
|
|
font-weight: 700;
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
-webkit-background-clip: text;
|
|
-webkit-text-fill-color: transparent;
|
|
background-clip: text;
|
|
}
|
|
|
|
.navbar-links {
|
|
display: flex;
|
|
gap: 20px;
|
|
align-items: center;
|
|
}
|
|
|
|
.navbar-links a {
|
|
color: #667eea;
|
|
text-decoration: none;
|
|
font-weight: 500;
|
|
transition: color 0.2s;
|
|
}
|
|
|
|
.navbar-links a:hover {
|
|
color: #5568d3;
|
|
}
|
|
|
|
.user-menu {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 15px;
|
|
}
|
|
|
|
.user-info {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
}
|
|
|
|
.user-avatar {
|
|
width: 40px;
|
|
height: 40px;
|
|
border-radius: 50%;
|
|
border: 2px solid #667eea;
|
|
}
|
|
|
|
.user-name {
|
|
font-weight: 600;
|
|
color: #333;
|
|
}
|
|
|
|
.logout-btn {
|
|
padding: 8px 16px;
|
|
background: #ef4444;
|
|
color: white;
|
|
text-decoration: none;
|
|
border-radius: 6px;
|
|
font-weight: 500;
|
|
transition: background 0.2s;
|
|
}
|
|
|
|
.logout-btn:hover {
|
|
background: #dc2626;
|
|
}
|
|
|
|
.container {
|
|
max-width: 1400px;
|
|
margin: 30px auto;
|
|
padding: 0 30px;
|
|
}
|
|
|
|
.section {
|
|
background: white;
|
|
border-radius: 12px;
|
|
padding: 30px;
|
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
|
margin-bottom: 30px;
|
|
}
|
|
|
|
.section-title {
|
|
font-size: 1.5em;
|
|
font-weight: 700;
|
|
margin-bottom: 20px;
|
|
color: #333;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
}
|
|
|
|
.table-container {
|
|
overflow-x: auto;
|
|
}
|
|
|
|
table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
margin-top: 20px;
|
|
}
|
|
|
|
thead {
|
|
background: #f8f9fa;
|
|
}
|
|
|
|
th {
|
|
padding: 12px;
|
|
text-align: left;
|
|
font-weight: 600;
|
|
color: #333;
|
|
border-bottom: 2px solid #e5e7eb;
|
|
}
|
|
|
|
td {
|
|
padding: 12px;
|
|
border-bottom: 1px solid #e5e7eb;
|
|
}
|
|
|
|
tr:hover {
|
|
background: #f8f9fa;
|
|
}
|
|
|
|
.ticket-id {
|
|
font-weight: 600;
|
|
color: #667eea;
|
|
font-family: 'Courier New', monospace;
|
|
}
|
|
|
|
.type-badge {
|
|
display: inline-block;
|
|
padding: 4px 12px;
|
|
border-radius: 12px;
|
|
font-size: 0.85em;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.type-support { background: #3b82f6; color: white; }
|
|
.type-plainte { background: #8b5cf6; color: white; }
|
|
.type-plainte-staff { background: #ef4444; color: white; }
|
|
.type-candidature { background: #10b981; color: white; }
|
|
.type-probleme-technique { background: #f59e0b; color: white; }
|
|
|
|
.status-badge {
|
|
display: inline-block;
|
|
padding: 4px 12px;
|
|
border-radius: 12px;
|
|
font-size: 0.85em;
|
|
font-weight: 600;
|
|
color: white;
|
|
}
|
|
|
|
.view-link {
|
|
color: #667eea;
|
|
text-decoration: none;
|
|
font-weight: 500;
|
|
transition: color 0.2s;
|
|
}
|
|
|
|
.view-link:hover {
|
|
color: #5568d3;
|
|
text-decoration: underline;
|
|
}
|
|
|
|
.no-tickets {
|
|
text-align: center;
|
|
padding: 40px;
|
|
color: #666;
|
|
font-style: italic;
|
|
}
|
|
|
|
.moderator-name {
|
|
color: #667eea;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.date-cell {
|
|
font-family: 'Courier New', monospace;
|
|
color: #666;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<nav class="navbar">
|
|
<div class="navbar-brand">🎫 Transcripts Dashboard</div>
|
|
<div class="navbar-links">
|
|
<a href="/">Dashboard</a>
|
|
<a href="/transcripts">Tous les Transcripts</a>
|
|
</div>
|
|
<div class="user-menu">
|
|
<div class="user-info">
|
|
<img src="${userAvatar}" alt="${escapeHtml(user.username)}" class="user-avatar">
|
|
<span class="user-name">${escapeHtml(user.username)}</span>
|
|
</div>
|
|
<a href="/auth/logout" class="logout-btn">Déconnexion</a>
|
|
</div>
|
|
</nav>
|
|
|
|
<div class="container">
|
|
<div class="section">
|
|
<h2 class="section-title">📋 Tous les Transcripts</h2>
|
|
${tickets.length > 0 ? `
|
|
<div class="table-container">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Ticket</th>
|
|
<th>Type</th>
|
|
<th>Modérateur</th>
|
|
<th>Date</th>
|
|
<th>Status</th>
|
|
<th>Action</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
${tickets.map(ticket => {
|
|
const typeClass = `type-${ticket.type.toLowerCase().replace(/\s+/g, '-')}`;
|
|
const statusColor = statusColors[ticket.status] || '#6b7280';
|
|
const date = formatDate(ticket.createdAt);
|
|
const url = ticket.transcriptPath ? `/${ticket.transcriptPath}` : '#';
|
|
|
|
// Récupérer le nom du modérateur qui a claim le ticket
|
|
let moderatorDisplay = 'Non assigné';
|
|
if (ticket.claimedBy && ticket.claimedByTag) {
|
|
moderatorDisplay = `Géré par: ${escapeHtml(ticket.claimedByTag)}`;
|
|
} else if (ticket.claimedBy) {
|
|
moderatorDisplay = 'Géré par: Modérateur';
|
|
}
|
|
|
|
return `
|
|
<tr>
|
|
<td class="ticket-id">${typeEmojis[ticket.type] || '🎫'} ${ticket.ticketId}</td>
|
|
<td><span class="type-badge ${typeClass}">${ticket.type}</span></td>
|
|
<td class="moderator-name">${moderatorDisplay}</td>
|
|
<td class="date-cell">${date}</td>
|
|
<td><span class="status-badge" style="background: ${statusColor};">${ticket.status}</span></td>
|
|
<td>${ticket.transcriptPath ? `<a href="${url}" class="view-link">Voir</a>` : '<span style="color: #999;">N/A</span>'}</td>
|
|
</tr>
|
|
`;
|
|
}).join('')}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
` : '<div class="no-tickets">Aucun transcript disponible.</div>'}
|
|
</div>
|
|
</div>
|
|
</body>
|
|
</html>`;
|
|
}
|
|
|
|
// Démarrer le serveur
|
|
app.listen(PORT, () => {
|
|
console.log(`🌐 Serveur de transcripts démarré sur le port ${PORT}`);
|
|
console.log(`🔗 URL: http://localhost:${PORT}`);
|
|
console.log(`📝 Login: http://localhost:${PORT}/login`);
|
|
});
|