feat: add admin dashboard and core API routes with Docker configuration

This commit is contained in:
2026-10-29 19:46:37 +01:00
parent 2fdabe1dfd
commit 4f4856d9dc
89 changed files with 1984 additions and 1034 deletions

View File

@@ -5,6 +5,15 @@ import crypto from 'crypto';
import { verifyCsrfToken, verifyOrigin } from '@/lib/csrf';
function escapeHtml(value: string): string {
return value
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
export async function POST(req: NextRequest) {
// CSRF Protection
const isOriginValid = verifyOrigin(req);
@@ -52,13 +61,14 @@ export async function POST(req: NextRequest) {
where: { id: 'singleton' }
});
const siteName = settings?.siteName || 'Afrohub';
const siteName = escapeHtml(settings?.siteName || 'Afrohub');
const safeUserName = escapeHtml(user.name || '');
const subject = settings?.resetPasswordSubject || 'Réinitialisation de votre mot de passe - Afrohub';
let html = settings?.resetPasswordTemplate || `
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e5e7eb; border-radius: 8px;">
<h2 style="color: #0d9488; text-align: center;">${siteName}</h2>
<p>Bonjour ${user.name},</p>
<p>Bonjour ${safeUserName},</p>
<p>Vous avez demandé la réinitialisation de votre mot de passe. Cliquez sur le bouton ci-dessous pour choisir un nouveau mot de passe :</p>
<div style="text-align: center; margin: 30px 0;">
<a href="${resetUrl}" style="background-color: #0d9488; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: bold;">Réinitialiser mon mot de passe</a>
@@ -71,7 +81,7 @@ export async function POST(req: NextRequest) {
// Replace placeholders
html = html
.replace(/{name}/g, user.name)
.replace(/{name}/g, safeUserName)
.replace(/{resetUrl}/g, resetUrl)
.replace(/{siteName}/g, siteName);

View File

@@ -3,6 +3,7 @@ import prisma from '@/lib/prisma';
import bcrypt from 'bcryptjs';
import { verifyCsrfToken, verifyOrigin } from '@/lib/csrf';
import { signUserToken, setUserSessionCookie } from '@/lib/auth-utils';
export async function POST(request: NextRequest) {
// CSRF Protection
@@ -75,11 +76,34 @@ export async function POST(request: NextRequest) {
);
}
// Omit password from response
const { password: _, ...userWithoutPassword } = user;
// Sign and set HttpOnly session cookie
const token = await signUserToken({
userId: user.id,
email: user.email,
role: user.role,
});
await setUserSessionCookie(token);
// Return only safe user fields (never leak password, resetToken, verificationToken)
const safeUser = {
id: user.id,
name: user.name,
email: user.email,
role: user.role,
avatar: user.avatar,
bio: user.bio,
location: user.location,
phone: user.phone,
countryId: user.countryId,
isSuspended: user.isSuspended,
suspensionReason: user.suspensionReason,
emailVerified: user.emailVerified,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
};
return NextResponse.json(
{ message: 'Connexion réussie', user: userWithoutPassword },
{ message: 'Connexion réussie', user: safeUser },
{ status: 200 }
);

View File

@@ -0,0 +1,13 @@
import { NextResponse } from 'next/server';
import { clearUserSessionCookie } from '@/lib/auth-utils';
export async function POST() {
try {
await clearUserSessionCookie();
return NextResponse.json({ success: true });
} catch (error) {
console.error('Logout error:', error);
return NextResponse.json({ error: 'Erreur lors de la déconnexion' }, { status: 500 });
}
}

View File

@@ -5,6 +5,15 @@ import crypto from 'crypto';
import { verifyCsrfToken, verifyOrigin } from '@/lib/csrf';
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
export async function POST(request: NextRequest) {
// CSRF Protection
const isOriginValid = verifyOrigin(request);
@@ -19,7 +28,7 @@ export async function POST(request: NextRequest) {
try {
const rawBody = await request.json();
const name = rawBody.name;
const name = typeof rawBody.name === 'string' ? rawBody.name.trim() : '';
const email = rawBody.email?.toLowerCase().trim();
const password = rawBody.password;
@@ -31,9 +40,9 @@ export async function POST(request: NextRequest) {
);
}
if (password.length < 6) {
if (password.length < 8) {
return NextResponse.json(
{ error: 'Le mot de passe doit contenir au moins 6 caractères' },
{ error: 'Le mot de passe doit contenir au moins 8 caractères' },
{ status: 400 }
);
}
@@ -74,14 +83,16 @@ export async function POST(request: NextRequest) {
where: { id: 'singleton' }
});
const siteName = settings?.siteName || 'Afrohub';
const safeName = escapeHtml(name);
const safeEmail = escapeHtml(email);
const siteName = escapeHtml(settings?.siteName || 'Afrohub');
const subject = settings?.verifyEmailSubject || 'Vérification de votre compte - Afrohub';
const verifyUrl = `${process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'}/api/auth/verify?token=${verificationToken}`;
let html = settings?.verifyEmailTemplate || `
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e5e7eb; border-radius: 8px;">
<h2 style="color: #0d9488; text-align: center;">${siteName}</h2>
<p>Bonjour ${name},</p>
<p>Bonjour ${safeName},</p>
<p>Merci de vous être inscrit sur ${siteName}. Veuillez vérifier votre adresse email en cliquant sur le bouton ci-dessous :</p>
<div style="text-align: center; margin: 30px 0;">
<a href="${verifyUrl}" style="background-color: #0d9488; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: bold;">Vérifier mon compte</a>
@@ -89,9 +100,9 @@ export async function POST(request: NextRequest) {
</div>
`;
// Replace placeholders
// Replace placeholders with HTML-escaped values
html = html
.replace(/{name}/g, name)
.replace(/{name}/g, safeName)
.replace(/{verifyUrl}/g, verifyUrl)
.replace(/{siteName}/g, siteName);
@@ -107,7 +118,7 @@ export async function POST(request: NextRequest) {
// Send admin notification email
try {
const adminEmail = settings?.contactEmail || 'support@afrohub.com';
const adminSubject = `[Admin] Nouvelle inscription sur ${siteName} : ${name}`;
const adminSubject = `[Admin] Nouvelle inscription sur ${settings?.siteName || 'Afrohub'} : ${name}`;
const adminHtml = `
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e5e7eb; border-radius: 8px;">
<h2 style="color: #4f46e5; text-align: center;">Notification Admin - Inscription</h2>
@@ -116,11 +127,11 @@ export async function POST(request: NextRequest) {
<table style="width: 100%; border-collapse: collapse; margin-top: 15px;">
<tr>
<td style="padding: 8px; border-bottom: 1px solid #e5e7eb; font-weight: bold; width: 35%;">Nom complet :</td>
<td style="padding: 8px; border-bottom: 1px solid #e5e7eb;">${name}</td>
<td style="padding: 8px; border-bottom: 1px solid #e5e7eb;">${safeName}</td>
</tr>
<tr>
<td style="padding: 8px; border-bottom: 1px solid #e5e7eb; font-weight: bold;">Adresse email :</td>
<td style="padding: 8px; border-bottom: 1px solid #e5e7eb;"><a href="mailto:${email}">${email}</a></td>
<td style="padding: 8px; border-bottom: 1px solid #e5e7eb;"><a href="mailto:${safeEmail}">${safeEmail}</a></td>
</tr>
<tr>
<td style="padding: 8px; border-bottom: 1px solid #e5e7eb; font-weight: bold;">Date d'inscription :</td>
@@ -149,11 +160,25 @@ export async function POST(request: NextRequest) {
console.error('Failed to send registration email(s):', mailError);
}
// Omit password from response
const { password: _, ...userWithoutPassword } = user;
// Return only safe user fields (never leak password, verificationToken, resetToken)
const safeUser = {
id: user.id,
name: user.name,
email: user.email,
role: user.role,
avatar: user.avatar,
bio: user.bio,
location: user.location,
phone: user.phone,
countryId: user.countryId,
isSuspended: user.isSuspended,
emailVerified: user.emailVerified,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
};
return NextResponse.json(
{ message: 'Compte créé avec succès', user: userWithoutPassword },
{ message: 'Compte créé avec succès', user: safeUser },
{ status: 201 }
);

View File

@@ -4,6 +4,15 @@ import crypto from 'crypto';
import { verifyCsrfToken, verifyOrigin } from '@/lib/csrf';
function escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
export async function POST(request: NextRequest) {
// CSRF Protection
const isOriginValid = verifyOrigin(request);
@@ -61,7 +70,8 @@ export async function POST(request: NextRequest) {
where: { id: 'singleton' }
});
const siteName = settings?.siteName || 'Afrohub';
const siteName = escapeHtml(settings?.siteName || 'Afrohub');
const safeUserName = escapeHtml(user.name || '');
const subject = settings?.verifyEmailSubject || 'Vérification de votre compte - Afrohub';
const verifyUrl = `${process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'}/api/auth/verify?token=${verificationToken}`;
@@ -78,7 +88,7 @@ export async function POST(request: NextRequest) {
// Replace placeholders
html = html
.replace(/{name}/g, user.name)
.replace(/{name}/g, safeUserName)
.replace(/{verifyUrl}/g, verifyUrl)
.replace(/{siteName}/g, siteName);

View File

@@ -18,10 +18,17 @@ export async function POST(request: NextRequest) {
try {
const { token, password } = await request.json();
if (!token || !password) {
if (!token || !password || typeof password !== 'string') {
return NextResponse.json({ error: 'Token et mot de passe sont requis' }, { status: 400 });
}
if (password.length < 8) {
return NextResponse.json(
{ error: 'Le mot de passe doit contenir au moins 8 caractères' },
{ status: 400 }
);
}
const user = await prisma.user.findFirst({
where: {
resetToken: token,