feat: add contact page with form handling and validation

- Implemented a contact page with a form for user inquiries.
- Added validation for form fields using Zod schema.
- Integrated PostgreSQL database for storing contact messages.
- Created necessary API endpoints for form submission.
- Added admin authentication and session management.
- Developed CGU, cookies policy, privacy policy, and legal mentions pages.
- Set up Docker configuration for PostgreSQL and application services.
- Enhanced UI with responsive design and accessibility features.
This commit is contained in:
Puechberty Arthur
2026-04-01 19:21:21 +02:00
parent 50553d5e92
commit f63eeb2e84
36 changed files with 2267 additions and 155 deletions
+29
View File
@@ -0,0 +1,29 @@
import { NextResponse } from "next/server";
import { createAdminToken, getAdminCookieName, getAdminCredentials } from "@/lib/admin-auth";
export async function POST(request: Request) {
const body = (await request.json().catch(() => null)) as
| { username?: string; password?: string }
| null;
if (!body?.username || !body?.password) {
return NextResponse.json({ message: "Identifiants manquants." }, { status: 400 });
}
const credentials = getAdminCredentials();
if (body.username !== credentials.username || body.password !== credentials.password) {
return NextResponse.json({ message: "Identifiants invalides." }, { status: 401 });
}
const token = createAdminToken(body.username);
const response = NextResponse.json({ ok: true });
response.cookies.set(getAdminCookieName(), token, {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
path: "/",
maxAge: 60 * 60 * 12,
});
return response;
}
+15
View File
@@ -0,0 +1,15 @@
import { NextResponse } from "next/server";
import { getAdminCookieName } from "@/lib/admin-auth";
export async function POST() {
const response = NextResponse.json({ ok: true });
response.cookies.set(getAdminCookieName(), "", {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
path: "/",
maxAge: 0,
});
return response;
}
+11
View File
@@ -0,0 +1,11 @@
import { NextResponse } from "next/server";
import { isAdminAuthenticated } from "@/lib/admin-auth";
export async function GET() {
const authenticated = await isAdminAuthenticated();
if (!authenticated) {
return NextResponse.json({ message: "Unauthorized" }, { status: 401 });
}
return NextResponse.json({ ok: true });
}
+111
View File
@@ -0,0 +1,111 @@
import nodemailer from "nodemailer";
import { NextResponse } from "next/server";
import { isAdminAuthenticated } from "@/lib/admin-auth";
import { getMessageById, markMessageReply } from "@/lib/db";
async function sendReplyEmail(input: { to: string; reply: string; senderName: string; originalMessage: string }) {
const smtpHost = process.env.SMTP_HOST;
const smtpPort = Number(process.env.SMTP_PORT || "587");
const smtpUser = process.env.SMTP_USER;
const smtpPass = process.env.SMTP_PASS;
if (!smtpHost || !smtpUser || !smtpPass) {
throw new Error("SMTP non configure pour les reponses admin.");
}
const transporter = nodemailer.createTransport({
host: smtpHost,
port: Number.isNaN(smtpPort) ? 587 : smtpPort,
secure: smtpPort === 465,
auth: { user: smtpUser, pass: smtpPass },
});
const fromEmail = process.env.CONTACT_FROM_EMAIL || smtpUser;
const escapedOriginal = input.originalMessage
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
const escapedReply = input.reply
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
await transporter.sendMail({
from: fromEmail,
to: input.to,
subject: "Reponse a votre message",
text: [
`Bonjour ${input.senderName},`,
"",
"Merci pour votre message. Voici ma reponse:",
"",
input.reply,
"",
"---------------------------",
"Votre message initial:",
input.originalMessage,
"---------------------------",
"",
"ArthurP",
].join("\n"),
html: `
<div style="font-family: Inter, Segoe UI, Arial, sans-serif; color: #0f172a; line-height: 1.6;">
<h2 style="margin: 0 0 12px;">Reponse a votre message</h2>
<p style="margin: 0 0 16px;">Bonjour ${input.senderName},</p>
<p style="margin: 0 0 8px;">Merci pour votre message. Voici ma reponse:</p>
<div style="margin: 0 0 20px; padding: 12px 14px; background: #ecfeff; border: 1px solid #a5f3fc; border-radius: 10px; white-space: pre-wrap;">${escapedReply}</div>
<p style="margin: 0 0 8px; font-weight: 600;">Votre message initial:</p>
<div style="margin: 0; padding: 12px 14px; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 10px; white-space: pre-wrap;">${escapedOriginal}</div>
<p style="margin: 20px 0 0; font-size: 13px; color: #475569;">ArthurP</p>
</div>
`,
});
}
export async function POST(
request: Request,
context: { params: Promise<{ id: string }> },
) {
const authenticated = await isAdminAuthenticated();
if (!authenticated) {
return NextResponse.json({ message: "Unauthorized" }, { status: 401 });
}
const { id } = await context.params;
const messageId = Number(id);
if (!Number.isInteger(messageId) || messageId <= 0) {
return NextResponse.json({ message: "ID invalide." }, { status: 400 });
}
const body = (await request.json().catch(() => null)) as { reply?: string } | null;
const reply = body?.reply?.trim() || "";
if (reply.length < 5) {
return NextResponse.json({ message: "Reponse trop courte." }, { status: 400 });
}
const existing = await getMessageById(messageId);
if (!existing) {
return NextResponse.json({ message: "Message introuvable." }, { status: 404 });
}
try {
await sendReplyEmail({
to: existing.email,
reply,
senderName: existing.name,
originalMessage: existing.message,
});
await markMessageReply(messageId, reply);
return NextResponse.json({ ok: true });
} catch (error) {
return NextResponse.json(
{
message:
error instanceof Error
? error.message
: "Impossible d'envoyer la reponse.",
},
{ status: 500 },
);
}
}
+26
View File
@@ -0,0 +1,26 @@
import { NextResponse } from "next/server";
import { isAdminAuthenticated } from "@/lib/admin-auth";
import { deleteMessage } from "@/lib/db";
export async function DELETE(
_request: Request,
context: { params: Promise<{ id: string }> },
) {
const authenticated = await isAdminAuthenticated();
if (!authenticated) {
return NextResponse.json({ message: "Unauthorized" }, { status: 401 });
}
const { id } = await context.params;
const messageId = Number(id);
if (!Number.isInteger(messageId) || messageId <= 0) {
return NextResponse.json({ message: "ID invalide." }, { status: 400 });
}
const deleted = await deleteMessage(messageId);
if (!deleted) {
return NextResponse.json({ message: "Message introuvable." }, { status: 404 });
}
return NextResponse.json({ ok: true });
}
@@ -0,0 +1,33 @@
import { NextResponse } from "next/server";
import { isAdminAuthenticated } from "@/lib/admin-auth";
import { setMessageStatus } from "@/lib/db";
export async function POST(
request: Request,
context: { params: Promise<{ id: string }> },
) {
const authenticated = await isAdminAuthenticated();
if (!authenticated) {
return NextResponse.json({ message: "Unauthorized" }, { status: 401 });
}
const { id } = await context.params;
const messageId = Number(id);
if (!Number.isInteger(messageId) || messageId <= 0) {
return NextResponse.json({ message: "ID invalide." }, { status: 400 });
}
const body = (await request.json().catch(() => null)) as { status?: string } | null;
const status = body?.status;
if (status !== "pending" && status !== "replied") {
return NextResponse.json({ message: "Statut invalide." }, { status: 400 });
}
const updated = await setMessageStatus(messageId, status);
if (!updated) {
return NextResponse.json({ message: "Message introuvable." }, { status: 404 });
}
return NextResponse.json({ ok: true });
}
+27
View File
@@ -0,0 +1,27 @@
import { NextResponse } from "next/server";
import { isAdminAuthenticated } from "@/lib/admin-auth";
import { listMessages } from "@/lib/db";
export async function GET() {
const authenticated = await isAdminAuthenticated();
if (!authenticated) {
return NextResponse.json({ message: "Unauthorized" }, { status: 401 });
}
const messages = await listMessages();
return NextResponse.json({
messages: messages.map((row) => ({
id: row.id,
name: row.name,
email: row.email,
project: row.project,
requestType: row.request_type,
message: row.message,
createdAt: row.created_at,
repliedAt: row.replied_at,
adminReply: row.admin_reply,
status: row.status,
})),
});
}
+189
View File
@@ -0,0 +1,189 @@
import nodemailer from "nodemailer";
import { NextRequest, NextResponse } from "next/server";
import {
contactPayloadSchema,
PROJECT_LABELS,
REQUEST_TYPE_LABELS,
type ContactPayload,
} from "@/lib/contact";
import { createMessage } from "@/lib/db";
export const runtime = "nodejs";
const RATE_WINDOW_MS = 60_000;
const RATE_MAX_REQUESTS = 5;
const requestTracker = new Map<string, number[]>();
function getClientIdentifier(request: NextRequest): string {
const xff = request.headers.get("x-forwarded-for");
if (xff) {
return xff.split(",")[0]?.trim() || "unknown";
}
return request.headers.get("x-real-ip") || "unknown";
}
function isRateLimited(identifier: string): boolean {
const now = Date.now();
const recentRequests = (requestTracker.get(identifier) || []).filter(
(timestamp) => now - timestamp <= RATE_WINDOW_MS,
);
if (recentRequests.length >= RATE_MAX_REQUESTS) {
requestTracker.set(identifier, recentRequests);
return true;
}
recentRequests.push(now);
requestTracker.set(identifier, recentRequests);
return false;
}
function formatMessage(payload: ContactPayload): string {
return [
`Nom: ${payload.name}`,
`Email: ${payload.email}`,
`Projet: ${PROJECT_LABELS[payload.project]}`,
`Type: ${REQUEST_TYPE_LABELS[payload.requestType]}`,
`Source: ${payload.sourceUrl || "non renseignee"}`,
"Message:",
payload.message,
].join("\n");
}
async function sendEmail(payload: ContactPayload, message: string): Promise<boolean> {
const smtpHost = process.env.SMTP_HOST;
const smtpPortRaw = process.env.SMTP_PORT;
const smtpUser = process.env.SMTP_USER;
const smtpPass = process.env.SMTP_PASS;
const contactTo = process.env.CONTACT_TO_EMAIL;
if (!smtpHost || !smtpUser || !smtpPass || !contactTo) {
return false;
}
const smtpPort = Number(smtpPortRaw || "587");
const secure = smtpPort === 465;
const transporter = nodemailer.createTransport({
host: smtpHost,
port: Number.isNaN(smtpPort) ? 587 : smtpPort,
secure,
auth: {
user: smtpUser,
pass: smtpPass,
},
});
const fromEmail = process.env.CONTACT_FROM_EMAIL || smtpUser;
await transporter.sendMail({
from: fromEmail,
to: contactTo,
replyTo: payload.email,
subject: `[Contact] ${PROJECT_LABELS[payload.project]} - ${REQUEST_TYPE_LABELS[payload.requestType]}`,
text: message,
html: `<pre style="font-family: ui-monospace, SFMono-Regular, Menlo, monospace; white-space: pre-wrap;">${message}</pre>`,
});
return true;
}
async function sendDiscordWebhook(payload: ContactPayload, message: string): Promise<boolean> {
const webhookUrl = process.env.DISCORD_WEBHOOK_URL;
if (!webhookUrl) {
return false;
}
const response = await fetch(webhookUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
content: "📬 Nouveau message depuis le formulaire de contact",
embeds: [
{
title: `${PROJECT_LABELS[payload.project]} - ${REQUEST_TYPE_LABELS[payload.requestType]}`,
description: `\`\`\`\n${message}\n\`\`\``,
color: 65366,
timestamp: new Date().toISOString(),
},
],
}),
});
if (!response.ok) {
throw new Error("Discord webhook a retourne une erreur.");
}
return true;
}
export async function POST(request: NextRequest) {
const identifier = getClientIdentifier(request);
if (isRateLimited(identifier)) {
return NextResponse.json(
{ message: "Trop de tentatives. Merci de reessayer dans une minute." },
{ status: 429 },
);
}
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json({ message: "Payload invalide." }, { status: 400 });
}
const parsed = contactPayloadSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{
message: "Donnees invalides.",
issues: parsed.error.flatten().fieldErrors,
},
{ status: 400 },
);
}
const payload = parsed.data;
if (payload.honeypot.trim().length > 0) {
return NextResponse.json({ message: "Message envoye." }, { status: 200 });
}
const formattedMessage = formatMessage(payload);
try {
await createMessage({
name: payload.name,
email: payload.email,
project: payload.project,
requestType: payload.requestType,
message: payload.message,
sourceUrl: payload.sourceUrl || null,
});
} catch (error) {
console.error("Contact API db error:", error);
return NextResponse.json(
{ message: "Impossible d'enregistrer le message pour le moment." },
{ status: 500 },
);
}
const notificationResults = await Promise.allSettled([
sendEmail(payload, formattedMessage),
sendDiscordWebhook(payload, formattedMessage),
]);
notificationResults.forEach((result, index) => {
if (result.status === "rejected") {
const channel = index === 0 ? "email" : "discord";
console.warn(`Contact API ${channel} notification failed:`, result.reason);
}
});
return NextResponse.json({ message: "Message envoye avec succes." }, { status: 200 });
}