diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d101bb7 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +node_modules +.next +.git +.gitignore +Dockerfile* +README.md +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* +.env* +!.env.example diff --git a/.gitignore b/.gitignore index 5ef6a52..a259cf9 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,9 @@ yarn-error.log* # vercel .vercel +# editor local settings +.vscode/ + # typescript *.tsbuildinfo next-env.d.ts diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f813495 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,26 @@ +FROM node:22-alpine AS base +WORKDIR /app +ENV NEXT_TELEMETRY_DISABLED=1 + +FROM base AS deps +COPY package.json package-lock.json ./ +RUN npm ci + +FROM deps AS dev +COPY . . +EXPOSE 3000 +CMD ["npm", "run", "dev", "--", "--hostname", "0.0.0.0", "--port", "3000"] + +FROM deps AS builder +COPY . . +RUN npm run build + +FROM base AS runner +ENV NODE_ENV=production +ENV HOSTNAME=0.0.0.0 +ENV PORT=3000 +COPY --from=builder /app/.next/standalone ./ +COPY --from=builder /app/.next/static ./.next/static +COPY --from=builder /app/public ./public +EXPOSE 3000 +CMD ["node", "server.js"] diff --git a/README.md b/README.md index e215bc4..c3f8132 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,112 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). +# Contact ArthurP -## Getting Started +Page de contact centralisee pour les projets ArthurP avec stockage PostgreSQL et espace admin. -First, run the development server: +Site officiel: `https://contact.arthurp.fr` + +## Stack + +- Next.js 16 (App Router) +- React 19 +- Tailwind CSS 4 +- API route `app/api/contact/route.ts` +- Validation partagee avec Zod +- PostgreSQL (stockage des messages) +- Notifications via Nodemailer et Discord webhook (optionnel) +- Dashboard admin avec authentification par cookie de session + +## Sites supportes + +- arthurp.fr +- links.arthurp.fr +- qcu.arthurp.fr +- qrcode.arthurp.fr +- lazybot.arthurp.fr +- learn.arthurp.fr +- sudoku.arthurp.fr +- reducelink.arthurp.fr +- clock.arthurp.fr +- form.arthurp.fr +- pomodoro.arthurp.fr +- visio.arthurp.fr +- doudou.arthurp.fr +- portfolio.arthurp.fr +- moon.arthurp.fr +- calculatrice.arthurp.fr +- chrono.arthurp.fr +- blocnote.arthurp.fr +- imprimersudoku.arthurp.fr + +## Variables d'environnement + +Copier `.env.example` vers `.env` puis completer: ```bash -npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev +cp .env.example .env ``` -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. +Variables principales: -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. +- `CONTACT_TO_EMAIL` adresse recevant les messages +- `CONTACT_FROM_EMAIL` expediteur technique +- `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS` pour l'envoi email +- `DISCORD_WEBHOOK_URL` (optionnel) +- `DATABASE_URL` URL PostgreSQL +- `ADMIN_USERNAME`, `ADMIN_PASSWORD` credentials admin +- `ADMIN_SESSION_SECRET` secret de signature de session admin -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. +## Lancement en local -## Learn More +```bash +npm install +npm run dev +``` -To learn more about Next.js, take a look at the following resources: +Accessible sur `http://localhost:3000`. -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. +Dashboard admin: `http://localhost:3000/admin` -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! +Exemples de liens: -## Deploy on Vercel +- `http://localhost:3000?project=lazybot` +- `http://localhost:3000?project=qrcode` -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. +## Docker -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. +Le projet fournit un Dockerfile multi-stage + Compose avec profils `dev` et `prod`. + +### Developpement (hot reload + PostgreSQL) + +```bash +docker compose --profile dev up --build +``` + +### Production (app + PostgreSQL) + +```bash +docker compose --profile prod up --build -d +``` + +Le build production utilise `output: "standalone"` pour une image plus propre et legere. + +## Pages legales + +- `/mentions-legales` +- `/politique-confidentialite` +- `/cgu` +- `/cookies` + +## Verification qualite + +```bash +npm run lint +npm run build +``` + +## Securite deja en place + +- Validation client + serveur +- Honeypot anti-spam +- Limitation de requetes basique par IP +- Secrets conserves via variables d'environnement +- Session admin en cookie HttpOnly diff --git a/app/admin/page.tsx b/app/admin/page.tsx new file mode 100644 index 0000000..5ae8ad2 --- /dev/null +++ b/app/admin/page.tsx @@ -0,0 +1,5 @@ +import AdminPanelClient from "./panel-client"; + +export default function AdminPage() { + return ; +} diff --git a/app/admin/panel-client.tsx b/app/admin/panel-client.tsx new file mode 100644 index 0000000..6172331 --- /dev/null +++ b/app/admin/panel-client.tsx @@ -0,0 +1,392 @@ +"use client"; + +import { FormEvent, useEffect, useMemo, useState } from "react"; +import Link from "next/link"; + +type AdminMessage = { + id: number; + name: string; + email: string; + project: string; + requestType: string; + message: string; + createdAt: string; + repliedAt: string | null; + adminReply: string | null; + status: "pending" | "replied"; +}; + +type FilterState = "all" | "pending" | "replied"; + +export default function AdminPanelClient() { + const [isAuthenticated, setIsAuthenticated] = useState(false); + const [isChecking, setIsChecking] = useState(true); + const [authError, setAuthError] = useState(null); + const [login, setLogin] = useState({ username: "", password: "" }); + const [messages, setMessages] = useState([]); + const [loadingMessages, setLoadingMessages] = useState(false); + const [replyDrafts, setReplyDrafts] = useState>({}); + const [replyingId, setReplyingId] = useState(null); + const [deletingId, setDeletingId] = useState(null); + const [statusUpdatingId, setStatusUpdatingId] = useState(null); + const [activeFilter, setActiveFilter] = useState("all"); + + async function checkSession() { + setIsChecking(true); + try { + const response = await fetch("/api/admin/me"); + setIsAuthenticated(response.ok); + } finally { + setIsChecking(false); + } + } + + useEffect(() => { + checkSession(); + }, []); + + async function loadMessages() { + setLoadingMessages(true); + try { + const response = await fetch("/api/admin/messages"); + if (!response.ok) { + if (response.status === 401) { + setIsAuthenticated(false); + return; + } + throw new Error("Impossible de charger les messages."); + } + + const data = (await response.json()) as { messages: AdminMessage[] }; + setMessages(data.messages); + const initialDrafts = Object.fromEntries( + data.messages.map((item) => [item.id, item.adminReply ?? ""]), + ) as Record; + setReplyDrafts(initialDrafts); + } catch (error) { + setAuthError(error instanceof Error ? error.message : "Erreur de chargement."); + } finally { + setLoadingMessages(false); + } + } + + useEffect(() => { + if (isAuthenticated) { + loadMessages(); + } + }, [isAuthenticated]); + + async function submitLogin(event: FormEvent) { + event.preventDefault(); + setAuthError(null); + + const response = await fetch("/api/admin/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(login), + }); + + if (!response.ok) { + const payload = (await response.json().catch(() => null)) as { message?: string } | null; + setAuthError(payload?.message ?? "Connexion impossible."); + return; + } + + setIsAuthenticated(true); + setLogin({ username: "", password: "" }); + await loadMessages(); + } + + async function logout() { + await fetch("/api/admin/logout", { method: "POST" }); + setIsAuthenticated(false); + setMessages([]); + } + + async function sendReply(id: number) { + const reply = (replyDrafts[id] || "").trim(); + if (reply.length < 5) { + setAuthError("La reponse doit contenir au moins 5 caracteres."); + return; + } + + setReplyingId(id); + setAuthError(null); + + try { + const response = await fetch(`/api/admin/messages/${id}/reply`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ reply }), + }); + + if (!response.ok) { + const payload = (await response.json().catch(() => null)) as { message?: string } | null; + throw new Error(payload?.message ?? "Impossible d'envoyer la reponse."); + } + + await loadMessages(); + } catch (error) { + setAuthError(error instanceof Error ? error.message : "Erreur d'envoi."); + } finally { + setReplyingId(null); + } + } + + async function toggleStatus(item: AdminMessage) { + const nextStatus = item.status === "pending" ? "replied" : "pending"; + setStatusUpdatingId(item.id); + setAuthError(null); + + try { + const response = await fetch(`/api/admin/messages/${item.id}/status`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ status: nextStatus }), + }); + + if (!response.ok) { + const payload = (await response.json().catch(() => null)) as { message?: string } | null; + throw new Error(payload?.message ?? "Impossible de changer le statut."); + } + + await loadMessages(); + } catch (error) { + setAuthError(error instanceof Error ? error.message : "Erreur de statut."); + } finally { + setStatusUpdatingId(null); + } + } + + async function removeMessage(id: number) { + const confirmed = window.confirm("Supprimer ce message ? Cette action est irreversible."); + if (!confirmed) { + return; + } + + setDeletingId(id); + setAuthError(null); + + try { + const response = await fetch(`/api/admin/messages/${id}`, { + method: "DELETE", + }); + + if (!response.ok) { + const payload = (await response.json().catch(() => null)) as { message?: string } | null; + throw new Error(payload?.message ?? "Suppression impossible."); + } + + await loadMessages(); + } catch (error) { + setAuthError(error instanceof Error ? error.message : "Erreur de suppression."); + } finally { + setDeletingId(null); + } + } + + const stats = useMemo(() => { + const total = messages.length; + const replied = messages.filter((item) => item.status === "replied").length; + return { total, replied, pending: total - replied }; + }, [messages]); + + const filteredMessages = useMemo(() => { + if (activeFilter === "all") { + return messages; + } + + return messages.filter((item) => item.status === activeFilter); + }, [messages, activeFilter]); + + if (isChecking) { + return
Verification de session...
; + } + + if (!isAuthenticated) { + return ( +
+
+
+ + Retour a l'accueil + +
+

Connexion admin

+

+ Connecte-toi pour voir les messages et repondre depuis le tableau de bord. +

+
+ + + +
+ {authError ?

{authError}

: null} +
+
+ ); + } + + return ( +
+
+
+
+

Espace admin

+

Gestion des messages de contact

+
+
+ + Retour a l'accueil + + +
+
+
+ + + +
+
+ + {loadingMessages ? ( +
Chargement...
+ ) : null} + + {filteredMessages.map((item) => ( +
+
+
+

#{item.id} - {new Date(item.createdAt).toLocaleString("fr-FR")}

+

{item.name} ({item.email})

+

{item.project} - {item.requestType}

+
+ {item.status === "replied" ? ( + + Repondu + + ) : ( + + En attente + + )} +
+ +

+ {item.message} +

+ +
+