mirror of
https://github.com/arthur-pbty/contact.git
synced 2026-06-06 14:10:44 +02:00
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:
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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, "<")
|
||||
.replace(/>/g, ">");
|
||||
const escapedReply = input.reply
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
|
||||
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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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,
|
||||
})),
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
Reference in New Issue
Block a user