feat(communication hub): centre de communication interne — fini les courriels publics
Public : /contact (hub 10 catégories : partenariats, données/API, investisseurs, carrières, fournisseurs, médias, questions, légal/Loi 25, sécurité anonyme, autre) + formulaires spécialisés pilotés par le registre (src/lib/comms/categories.ts), préremplissage KA ID, honeypot + rate-limit + uploads contrôlés, référence publique KA-XXX-2026-NNNN, accusé de réception Resend. contact@/info@/admin@groupe-ka.com retirés de toute l interface publique (footer, accueil, loi-25, retrait, conditions, confidentialité, bots, compte, marque, investisseurs, carte Wallet). Admin : /admin protégé serveur (comptes admin_users distincts des KA ID, scrypt + JWT 12 h invalidé au changement de mot de passe), RBAC 8 rôles (super_admin, admin, support, legal, investor_relations, hr, media, security) appliqué DANS les requêtes SQL + par demande (anti-IDOR, 404), dashboard KPIs, inbox filtres/recherche/pagination, vue demande complète (workflow, notes internes, conversation email via Resend, historique, pièces jointes servies hors public), pipeline candidatures, gestion équipe + mots de passe. DB data/ka-comms.db (WAL) : contact_submissions, contact_messages, contact_notes, contact_events, contact_attachments, contact_counters, admin_users ; priorité intelligente backend ; champs ai_* prêts pour la classification future. Testé : 18 scénarios curl (validation, uploads, spam, RBAC, IDOR, rate-limit, comptes) + parcours Playwright https en prod.
47 changed files +5,047 −88
added
src/app/admin/(panel)/AdminNav.tsx
+134 −0
@@ -0,0 +1,134 @@ | ||
| 1 | +"use client"; | |
| 2 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 3 | +// Navigation du panel — latérale (desktop) / barre repliable (mobile). | |
| 4 | +// Le badge de non-lus se rafraîchit par sondage léger (45 s) sur | |
| 5 | +// /api/admin/badge — l'architecture notifications pourra brancher | |
| 6 | +// courriel/push/Slack sur le même compteur. | |
| 7 | +import { useEffect, useState } from "react"; | |
| 8 | +import { usePathname, useRouter } from "next/navigation"; | |
| 9 | +import { ADMIN_ROLES } from "@/lib/comms/categories"; | |
| 10 | + | |
| 11 | +const LINKS = [ | |
| 12 | + { href: "/admin", label: "Dashboard", exact: true }, | |
| 13 | + { href: "/admin/inbox", label: "Inbox", badge: true }, | |
| 14 | + { href: "/admin/candidatures", label: "Candidatures" }, | |
| 15 | + { href: "/admin/equipe", label: "Équipe & accès" }, | |
| 16 | +]; | |
| 17 | + | |
| 18 | +export default function AdminNav({ | |
| 19 | + displayName, | |
| 20 | + username, | |
| 21 | + roles, | |
| 22 | + initialUnread, | |
| 23 | +}: { | |
| 24 | + displayName: string; | |
| 25 | + username: string; | |
| 26 | + roles: string[]; | |
| 27 | + initialUnread: number; | |
| 28 | +}) { | |
| 29 | + const pathname = usePathname(); | |
| 30 | + const router = useRouter(); | |
| 31 | + const [unread, setUnread] = useState(initialUnread); | |
| 32 | + const [open, setOpen] = useState(false); | |
| 33 | + | |
| 34 | + useEffect(() => { | |
| 35 | + setUnread(initialUnread); | |
| 36 | + }, [initialUnread]); | |
| 37 | + | |
| 38 | + useEffect(() => { | |
| 39 | + const t = setInterval(async () => { | |
| 40 | + try { | |
| 41 | + const res = await fetch("/api/admin/badge"); | |
| 42 | + if (res.ok) { | |
| 43 | + const data = (await res.json()) as { unread: number }; | |
| 44 | + setUnread(data.unread); | |
| 45 | + } | |
| 46 | + } catch { | |
| 47 | + /* réseau : on garde la dernière valeur */ | |
| 48 | + } | |
| 49 | + }, 45000); | |
| 50 | + return () => clearInterval(t); | |
| 51 | + }, []); | |
| 52 | + | |
| 53 | + async function logout() { | |
| 54 | + await fetch("/api/admin/logout", { method: "POST" }).catch(() => {}); | |
| 55 | + router.replace("/admin/connexion"); | |
| 56 | + router.refresh(); | |
| 57 | + } | |
| 58 | + | |
| 59 | + const nav = ( | |
| 60 | + <> | |
| 61 | + <nav className="mt-6 flex flex-col gap-1" aria-label="Navigation admin"> | |
| 62 | + {LINKS.map((l) => { | |
| 63 | + const active = l.exact ? pathname === l.href : pathname.startsWith(l.href); | |
| 64 | + return ( | |
| 65 | + <a | |
| 66 | + key={l.href} | |
| 67 | + href={l.href} | |
| 68 | + className={`adm-nav-link ${active ? "adm-nav-link--active" : ""}`} | |
| 69 | + > | |
| 70 | + {l.label} | |
| 71 | + {l.badge && unread > 0 ? ( | |
| 72 | + <span | |
| 73 | + className={`gk-mono rounded-full px-2 py-[1px] text-[10.5px] font-bold ${ | |
| 74 | + active ? "bg-ink text-lime" : "bg-lime text-ink" | |
| 75 | + }`} | |
| 76 | + > | |
| 77 | + {unread} | |
| 78 | + </span> | |
| 79 | + ) : null} | |
| 80 | + </a> | |
| 81 | + ); | |
| 82 | + })} | |
| 83 | + </nav> | |
| 84 | + <div className="mt-auto border-t border-[rgba(245,243,238,0.14)] pt-4"> | |
| 85 | + <p className="gk-display text-[14px] font-bold text-paper">{displayName}</p> | |
| 86 | + <p className="gk-mono mt-[2px] text-[10px] tracking-[0.08em] text-[rgba(245,243,238,0.5)] uppercase"> | |
| 87 | + @{username} · {roles.map((r) => ADMIN_ROLES[r] ?? r).join(" · ")} | |
| 88 | + </p> | |
| 89 | + <div className="mt-3 flex items-center gap-4"> | |
| 90 | + <a | |
| 91 | + href="/contact" | |
| 92 | + target="_blank" | |
| 93 | + className="gk-mono text-[10px] font-bold tracking-[0.08em] text-[rgba(245,243,238,0.55)] uppercase underline-offset-4 hover:text-lime hover:underline" | |
| 94 | + > | |
| 95 | + Voir /contact ↗ | |
| 96 | + </a> | |
| 97 | + <button | |
| 98 | + onClick={logout} | |
| 99 | + className="gk-mono cursor-pointer text-[10px] font-bold tracking-[0.08em] text-[rgba(245,243,238,0.55)] uppercase underline-offset-4 hover:text-lime hover:underline" | |
| 100 | + > | |
| 101 | + Déconnexion | |
| 102 | + </button> | |
| 103 | + </div> | |
| 104 | + </div> | |
| 105 | + </> | |
| 106 | + ); | |
| 107 | + | |
| 108 | + return ( | |
| 109 | + <aside className="flex-none bg-ink lg:sticky lg:top-0 lg:flex lg:h-[100dvh] lg:w-[248px] lg:flex-col lg:px-5 lg:py-6"> | |
| 110 | + {/* barre mobile */} | |
| 111 | + <div className="flex items-center justify-between px-4 py-4 lg:px-0 lg:py-0"> | |
| 112 | + <a href="/admin" className="gk-display text-[19px] font-bold tracking-[-0.03em] text-paper no-underline"> | |
| 113 | + Groupe{" "} | |
| 114 | + <span className="inline-block -rotate-2 rounded-md bg-lime px-[6px] pb-[2px] text-ink"> | |
| 115 | + KA | |
| 116 | + </span>{" "} | |
| 117 | + <span className="gk-mono ml-1 align-middle text-[9px] font-bold tracking-[0.24em] text-[rgba(245,243,238,0.5)] uppercase"> | |
| 118 | + Admin | |
| 119 | + </span> | |
| 120 | + </a> | |
| 121 | + <button | |
| 122 | + className="gk-mono cursor-pointer rounded-lg border border-[rgba(245,243,238,0.3)] px-3 py-2 text-[10px] font-bold tracking-[0.1em] text-paper uppercase lg:hidden" | |
| 123 | + onClick={() => setOpen((o) => !o)} | |
| 124 | + aria-expanded={open} | |
| 125 | + > | |
| 126 | + Menu{unread > 0 ? ` · ${unread}` : ""} | |
| 127 | + </button> | |
| 128 | + </div> | |
| 129 | + <div className={`${open ? "flex" : "hidden"} flex-col px-4 pb-6 lg:flex lg:flex-1 lg:px-0 lg:pb-0`}> | |
| 130 | + {nav} | |
| 131 | + </div> | |
| 132 | + </aside> | |
| 133 | + ); | |
| 134 | +} | |
added
src/app/admin/(panel)/candidatures/page.tsx
+99 −0
@@ -0,0 +1,99 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// /admin/candidatures — le pipeline RH : les demandes « carrieres » vues | |
| 3 | +// comme un processus d'embauche (nouvelle → à analyser → entrevue → offre → | |
| 4 | +// embauchée / refusée), avec compteur par étape et liste filtrable. | |
| 5 | +import { redirect } from "next/navigation"; | |
| 6 | +import { getAdminSession, canViewCategory } from "@/lib/comms/admin-auth"; | |
| 7 | +import { listSubmissions } from "@/lib/comms/queries"; | |
| 8 | +import { JOB_STATUSES, PRIORITIES } from "@/lib/comms/categories"; | |
| 9 | + | |
| 10 | +export const dynamic = "force-dynamic"; | |
| 11 | + | |
| 12 | +function fmtDate(iso: string): string { | |
| 13 | + return iso.slice(0, 10); | |
| 14 | +} | |
| 15 | + | |
| 16 | +export default async function CandidaturesPage({ | |
| 17 | + searchParams, | |
| 18 | +}: { | |
| 19 | + searchParams: Promise<Record<string, string | string[] | undefined>>; | |
| 20 | +}) { | |
| 21 | + const session = await getAdminSession(); | |
| 22 | + if (!session) redirect("/admin/connexion"); | |
| 23 | + if (!canViewCategory(session, "carrieres")) | |
| 24 | + return ( | |
| 25 | + <p className="gk-mono mt-10 text-[12px] text-ink-3"> | |
| 26 | + Votre rôle ne donne pas accès aux candidatures (rôle « Ressources humaines » requis). | |
| 27 | + </p> | |
| 28 | + ); | |
| 29 | + const sp = await searchParams; | |
| 30 | + const stage = typeof sp.stage === "string" && JOB_STATUSES[sp.stage] ? sp.stage : ""; | |
| 31 | + | |
| 32 | + const { rows: all } = listSubmissions(session, { | |
| 33 | + category: "carrieres", | |
| 34 | + view: "actives", | |
| 35 | + page: 1, | |
| 36 | + }); | |
| 37 | + const byStage = new Map<string, number>(); | |
| 38 | + for (const r of all) byStage.set(r.status, (byStage.get(r.status) ?? 0) + 1); | |
| 39 | + const rows = stage ? all.filter((r) => r.status === stage) : all; | |
| 40 | + | |
| 41 | + return ( | |
| 42 | + <> | |
| 43 | + <p className="kicker">Candidatures</p> | |
| 44 | + <h1 className="gk-display mt-2 text-[clamp(22px,3vw,30px)] font-bold tracking-[-0.03em]"> | |
| 45 | + Pipeline d'embauche · {all.length} candidature{all.length > 1 ? "s" : ""} | |
| 46 | + </h1> | |
| 47 | + | |
| 48 | + <div className="mt-6 flex flex-wrap gap-2"> | |
| 49 | + <a href="/admin/candidatures" className={`adm-chip no-underline ${!stage ? "adm-chip--lime" : ""}`}> | |
| 50 | + Toutes · {all.length} | |
| 51 | + </a> | |
| 52 | + {Object.entries(JOB_STATUSES).map(([k, v]) => ( | |
| 53 | + <a | |
| 54 | + key={k} | |
| 55 | + href={`/admin/candidatures?stage=${k}`} | |
| 56 | + className={`adm-chip no-underline ${stage === k ? "adm-chip--lime" : ""}`} | |
| 57 | + > | |
| 58 | + {v} · {byStage.get(k) ?? 0} | |
| 59 | + </a> | |
| 60 | + ))} | |
| 61 | + </div> | |
| 62 | + | |
| 63 | + <div className="gk-card mt-6 overflow-hidden !shadow-none"> | |
| 64 | + {rows.length === 0 ? ( | |
| 65 | + <p className="gk-mono p-6 text-[12px] text-ink-3">Aucune candidature à cette étape.</p> | |
| 66 | + ) : ( | |
| 67 | + rows.map((r) => ( | |
| 68 | + <a | |
| 69 | + key={r.id} | |
| 70 | + href={`/admin/demande/${r.reference}`} | |
| 71 | + className={`adm-row ${r.is_read ? "" : "adm-row--unread"}`} | |
| 72 | + > | |
| 73 | + <div className="flex flex-wrap items-center gap-x-3 gap-y-1"> | |
| 74 | + <span className="gk-mono text-[11px] font-bold text-green">{r.reference}</span> | |
| 75 | + <span className="adm-chip adm-chip--ghost !text-[9px]"> | |
| 76 | + {JOB_STATUSES[r.status] ?? r.status} | |
| 77 | + </span> | |
| 78 | + <span className={`adm-chip adm-chip--${r.priority} !text-[9px]`}> | |
| 79 | + {PRIORITIES[r.priority as keyof typeof PRIORITIES] ?? r.priority} | |
| 80 | + </span> | |
| 81 | + <span className="gk-mono ml-auto text-[10.5px] text-ink-3">{fmtDate(r.created_at)}</span> | |
| 82 | + </div> | |
| 83 | + <p className="mt-1 truncate text-[13.5px]"> | |
| 84 | + <strong>{[r.first_name, r.last_name].filter(Boolean).join(" ") || r.email}</strong> | |
| 85 | + {r.job_title ? ` — ${r.job_title}` : ""} | |
| 86 | + {r.sub_category?.startsWith("Candidature spontanée") ? " · spontanée" : ""} | |
| 87 | + {r.assignee ? <span className="gk-mono text-[11px] text-ink-3"> · suivi : {r.assignee}</span> : null} | |
| 88 | + </p> | |
| 89 | + </a> | |
| 90 | + )) | |
| 91 | + )} | |
| 92 | + </div> | |
| 93 | + <p className="gk-mono mt-4 text-[10.5px] leading-relaxed text-ink-3"> | |
| 94 | + Les CV et documents personnels sont accessibles depuis chaque fiche, | |
| 95 | + réservés aux rôles autorisés, et servis hors du dossier public. | |
| 96 | + </p> | |
| 97 | + </> | |
| 98 | + ); | |
| 99 | +} | |
added
src/app/admin/(panel)/demande/[ref]/SubmissionActions.tsx
+232 −0
@@ -0,0 +1,232 @@ | ||
| 1 | +"use client"; | |
| 2 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 3 | +// Workflow d'une demande (client) : statut, priorité, assignation, | |
| 4 | +// confidentialité, tags, note interne, réponse au demandeur, archive/spam. | |
| 5 | +// Chaque action passe par l'API admin (revalidation RBAC serveur) puis | |
| 6 | +// rafraîchit la page serveur — pas d'état dupliqué. | |
| 7 | +import { useState } from "react"; | |
| 8 | +import { useRouter } from "next/navigation"; | |
| 9 | +import { PRIORITIES, CONFIDENTIALITY_LEVELS } from "@/lib/comms/categories"; | |
| 10 | + | |
| 11 | +type Props = { | |
| 12 | + id: number; | |
| 13 | + statuses: Record<string, string>; | |
| 14 | + status: string; | |
| 15 | + priority: string; | |
| 16 | + confidentiality: string; | |
| 17 | + assignedTo: number | null; | |
| 18 | + tags: string[]; | |
| 19 | + admins: { id: number; name: string }[]; | |
| 20 | + isArchived: boolean; | |
| 21 | + isSpam: boolean; | |
| 22 | + hasEmail: boolean; | |
| 23 | +}; | |
| 24 | + | |
| 25 | +export default function SubmissionActions(p: Props) { | |
| 26 | + const router = useRouter(); | |
| 27 | + const [busy, setBusy] = useState(false); | |
| 28 | + const [error, setError] = useState(""); | |
| 29 | + const [note, setNote] = useState(""); | |
| 30 | + const [reply, setReply] = useState(""); | |
| 31 | + const [tagsText, setTagsText] = useState(p.tags.join(", ")); | |
| 32 | + const [sentInfo, setSentInfo] = useState(""); | |
| 33 | + | |
| 34 | + async function call(url: string, body: unknown): Promise<boolean> { | |
| 35 | + setBusy(true); | |
| 36 | + setError(""); | |
| 37 | + try { | |
| 38 | + const res = await fetch(url, { | |
| 39 | + method: url.includes("/notes") || url.includes("/reply") ? "POST" : "PATCH", | |
| 40 | + headers: { "Content-Type": "application/json" }, | |
| 41 | + body: JSON.stringify(body), | |
| 42 | + }); | |
| 43 | + const data = (await res.json().catch(() => ({}))) as { error?: string; sent?: boolean }; | |
| 44 | + if (!res.ok) { | |
| 45 | + setError(data.error ?? "Action refusée."); | |
| 46 | + setBusy(false); | |
| 47 | + return false; | |
| 48 | + } | |
| 49 | + if (data.sent === false) | |
| 50 | + setSentInfo("Message enregistré, mais l'envoi courriel a échoué — il reste dans la conversation."); | |
| 51 | + router.refresh(); | |
| 52 | + setBusy(false); | |
| 53 | + return true; | |
| 54 | + } catch { | |
| 55 | + setError("Réseau indisponible."); | |
| 56 | + setBusy(false); | |
| 57 | + return false; | |
| 58 | + } | |
| 59 | + } | |
| 60 | + | |
| 61 | + const base = `/api/admin/submissions/${p.id}`; | |
| 62 | + const sel = "field cursor-pointer !py-[8px] !text-[13px]"; | |
| 63 | + | |
| 64 | + return ( | |
| 65 | + <div className="flex flex-col gap-5"> | |
| 66 | + <div className="grid grid-cols-2 gap-3"> | |
| 67 | + <div> | |
| 68 | + <label className="klabel mb-[4px] block">Statut</label> | |
| 69 | + <select | |
| 70 | + className={sel} | |
| 71 | + value={p.status} | |
| 72 | + disabled={busy} | |
| 73 | + onChange={(e) => call(base, { status: e.target.value })} | |
| 74 | + > | |
| 75 | + {Object.entries(p.statuses).map(([k, v]) => ( | |
| 76 | + <option key={k} value={k}>{v}</option> | |
| 77 | + ))} | |
| 78 | + </select> | |
| 79 | + </div> | |
| 80 | + <div> | |
| 81 | + <label className="klabel mb-[4px] block">Priorité</label> | |
| 82 | + <select | |
| 83 | + className={sel} | |
| 84 | + value={p.priority} | |
| 85 | + disabled={busy} | |
| 86 | + onChange={(e) => call(base, { priority: e.target.value })} | |
| 87 | + > | |
| 88 | + {Object.entries(PRIORITIES).map(([k, v]) => ( | |
| 89 | + <option key={k} value={k}>{v}</option> | |
| 90 | + ))} | |
| 91 | + </select> | |
| 92 | + </div> | |
| 93 | + <div> | |
| 94 | + <label className="klabel mb-[4px] block">Assignée à</label> | |
| 95 | + <select | |
| 96 | + className={sel} | |
| 97 | + value={p.assignedTo ?? ""} | |
| 98 | + disabled={busy} | |
| 99 | + onChange={(e) => | |
| 100 | + call(base, { assigned_to: e.target.value ? Number(e.target.value) : null }) | |
| 101 | + } | |
| 102 | + > | |
| 103 | + <option value="">Personne</option> | |
| 104 | + {p.admins.map((a) => ( | |
| 105 | + <option key={a.id} value={a.id}>{a.name}</option> | |
| 106 | + ))} | |
| 107 | + </select> | |
| 108 | + </div> | |
| 109 | + <div> | |
| 110 | + <label className="klabel mb-[4px] block">Confidentialité</label> | |
| 111 | + <select | |
| 112 | + className={sel} | |
| 113 | + value={p.confidentiality} | |
| 114 | + disabled={busy} | |
| 115 | + onChange={(e) => call(base, { confidentiality: e.target.value })} | |
| 116 | + > | |
| 117 | + {Object.entries(CONFIDENTIALITY_LEVELS).map(([k, v]) => ( | |
| 118 | + <option key={k} value={k}>{v}</option> | |
| 119 | + ))} | |
| 120 | + </select> | |
| 121 | + </div> | |
| 122 | + </div> | |
| 123 | + | |
| 124 | + <div> | |
| 125 | + <label className="klabel mb-[4px] block" htmlFor="w-tags">Tags (séparés par des virgules)</label> | |
| 126 | + <div className="flex gap-2"> | |
| 127 | + <input | |
| 128 | + id="w-tags" | |
| 129 | + className="field !py-[8px] !text-[13px]" | |
| 130 | + value={tagsText} | |
| 131 | + onChange={(e) => setTagsText(e.target.value)} | |
| 132 | + placeholder="ex. à-suivre, q1-2027" | |
| 133 | + /> | |
| 134 | + <button | |
| 135 | + className="btn btn-ghost !min-h-[38px] !px-4 !text-[12.5px]" | |
| 136 | + disabled={busy} | |
| 137 | + onClick={() => | |
| 138 | + call(base, { tags: tagsText.split(",").map((t) => t.trim()).filter(Boolean) }) | |
| 139 | + } | |
| 140 | + > | |
| 141 | + OK | |
| 142 | + </button> | |
| 143 | + </div> | |
| 144 | + </div> | |
| 145 | + | |
| 146 | + <div className="flex flex-wrap gap-2"> | |
| 147 | + <button | |
| 148 | + className="btn btn-ghost !min-h-[36px] !px-4 !text-[12.5px]" | |
| 149 | + disabled={busy} | |
| 150 | + onClick={() => call(base, { is_archived: !p.isArchived })} | |
| 151 | + > | |
| 152 | + {p.isArchived ? "Désarchiver" : "Archiver"} | |
| 153 | + </button> | |
| 154 | + <button | |
| 155 | + className="btn btn-ghost !min-h-[36px] !px-4 !text-[12.5px]" | |
| 156 | + disabled={busy} | |
| 157 | + onClick={() => call(base, { is_spam: !p.isSpam })} | |
| 158 | + > | |
| 159 | + {p.isSpam ? "Non-spam" : "Marquer spam"} | |
| 160 | + </button> | |
| 161 | + <button | |
| 162 | + className="btn btn-ghost !min-h-[36px] !px-4 !text-[12.5px]" | |
| 163 | + disabled={busy} | |
| 164 | + onClick={() => call(base, { is_read: false })} | |
| 165 | + > | |
| 166 | + Marquer non lue | |
| 167 | + </button> | |
| 168 | + </div> | |
| 169 | + | |
| 170 | + <div className="rule-ink pt-4"> | |
| 171 | + <label className="klabel mb-[4px] block" htmlFor="w-note"> | |
| 172 | + Note interne (jamais visible du demandeur) | |
| 173 | + </label> | |
| 174 | + <textarea | |
| 175 | + id="w-note" | |
| 176 | + rows={3} | |
| 177 | + className="field resize-y !text-[13px]" | |
| 178 | + value={note} | |
| 179 | + onChange={(e) => setNote(e.target.value)} | |
| 180 | + /> | |
| 181 | + <button | |
| 182 | + className="btn btn-ghost mt-2 !min-h-[36px] !px-4 !text-[12.5px]" | |
| 183 | + disabled={busy || !note.trim()} | |
| 184 | + onClick={async () => { | |
| 185 | + if (await call(`${base}/notes`, { body: note })) setNote(""); | |
| 186 | + }} | |
| 187 | + > | |
| 188 | + Ajouter la note | |
| 189 | + </button> | |
| 190 | + </div> | |
| 191 | + | |
| 192 | + <div className="rule-ink pt-4"> | |
| 193 | + <label className="klabel mb-[4px] block" htmlFor="w-reply"> | |
| 194 | + Répondre au demandeur {p.hasEmail ? "(courriel via la conversation)" : ""} | |
| 195 | + </label> | |
| 196 | + {p.hasEmail ? ( | |
| 197 | + <> | |
| 198 | + <textarea | |
| 199 | + id="w-reply" | |
| 200 | + rows={5} | |
| 201 | + className="field resize-y !text-[13px]" | |
| 202 | + value={reply} | |
| 203 | + onChange={(e) => setReply(e.target.value)} | |
| 204 | + placeholder="Votre réponse — elle est enregistrée dans la conversation et envoyée par courriel avec la référence." | |
| 205 | + /> | |
| 206 | + <button | |
| 207 | + className="btn btn-primary mt-2 !min-h-[38px] !px-5 !text-[13px]" | |
| 208 | + disabled={busy || !reply.trim()} | |
| 209 | + onClick={async () => { | |
| 210 | + if (await call(`${base}/reply`, { body: reply })) setReply(""); | |
| 211 | + }} | |
| 212 | + > | |
| 213 | + Envoyer la réponse | |
| 214 | + </button> | |
| 215 | + </> | |
| 216 | + ) : ( | |
| 217 | + <p className="gk-mono text-[11px] leading-relaxed text-ink-3"> | |
| 218 | + Soumission anonyme ou sans courriel — aucune réponse directe | |
| 219 | + possible. Utilisez une note interne. | |
| 220 | + </p> | |
| 221 | + )} | |
| 222 | + </div> | |
| 223 | + | |
| 224 | + {sentInfo ? ( | |
| 225 | + <p className="gk-mono rounded-lg border-[1.5px] border-amber bg-amber-soft px-3 py-2 text-[11px] font-bold text-[#7a4d0d]">{sentInfo}</p> | |
| 226 | + ) : null} | |
| 227 | + {error ? ( | |
| 228 | + <p className="gk-mono rounded-lg border-[1.5px] border-danger bg-[var(--danger-soft)] px-3 py-2 text-[11px] font-bold text-danger">{error}</p> | |
| 229 | + ) : null} | |
| 230 | + </div> | |
| 231 | + ); | |
| 232 | +} | |
added
src/app/admin/(panel)/demande/[ref]/page.tsx
+287 −0
@@ -0,0 +1,287 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// /admin/demande/[ref] — la vue complète d'une demande : identité, contenu, | |
| 3 | +// métadonnées du formulaire, pièces jointes, workflow, notes internes, | |
| 4 | +// conversation et historique d'activité. L'ouverture marque la demande lue. | |
| 5 | +// Contrôle d'accès PAR demande : un admin sans droit reçoit un 404. | |
| 6 | +import { notFound, redirect } from "next/navigation"; | |
| 7 | +import { getAdminSession, canViewSubmission } from "@/lib/comms/admin-auth"; | |
| 8 | +import { | |
| 9 | + findSubmissionByRef, | |
| 10 | + attachmentsOf, | |
| 11 | + notesOf, | |
| 12 | + messagesOf, | |
| 13 | + eventsOf, | |
| 14 | + listAdmins, | |
| 15 | + updateSubmission, | |
| 16 | +} from "@/lib/comms/db"; | |
| 17 | +import { | |
| 18 | + categoryBySlug, | |
| 19 | + statusesFor, | |
| 20 | + PRIORITIES, | |
| 21 | + CONFIDENTIALITY_LEVELS, | |
| 22 | +} from "@/lib/comms/categories"; | |
| 23 | +import SubmissionActions from "./SubmissionActions"; | |
| 24 | + | |
| 25 | +export const dynamic = "force-dynamic"; | |
| 26 | + | |
| 27 | +function fmtDate(iso: string): string { | |
| 28 | + return iso.replace("T", " ").slice(0, 16); | |
| 29 | +} | |
| 30 | + | |
| 31 | +function fmtBytes(n: number): string { | |
| 32 | + if (n < 1024) return `${n} o`; | |
| 33 | + if (n < 1024 * 1024) return `${Math.round(n / 1024)} Ko`; | |
| 34 | + return `${(n / 1024 / 1024).toFixed(1)} Mo`; | |
| 35 | +} | |
| 36 | + | |
| 37 | +function Meta({ label, value }: { label: string; value: React.ReactNode }) { | |
| 38 | + if (value == null || value === "") return null; | |
| 39 | + return ( | |
| 40 | + <div className="min-w-0"> | |
| 41 | + <p className="klabel">{label}</p> | |
| 42 | + <p className="mt-[2px] text-[13.5px] break-words">{value}</p> | |
| 43 | + </div> | |
| 44 | + ); | |
| 45 | +} | |
| 46 | + | |
| 47 | +export default async function SubmissionPage({ | |
| 48 | + params, | |
| 49 | +}: { | |
| 50 | + params: Promise<{ ref: string }>; | |
| 51 | +}) { | |
| 52 | + const session = await getAdminSession(); | |
| 53 | + if (!session) redirect("/admin/connexion"); | |
| 54 | + const ref = decodeURIComponent((await params).ref).slice(0, 40); | |
| 55 | + const sub = findSubmissionByRef(ref); | |
| 56 | + if (!sub || !canViewSubmission(session, sub)) notFound(); | |
| 57 | + | |
| 58 | + if (!sub.is_read) updateSubmission(sub.id, { is_read: 1 }); | |
| 59 | + | |
| 60 | + const cat = categoryBySlug(sub.category); | |
| 61 | + const atts = attachmentsOf(sub.id); | |
| 62 | + const notes = notesOf(sub.id); | |
| 63 | + const messages = messagesOf(sub.id); | |
| 64 | + const events = eventsOf(sub.id); | |
| 65 | + const admins = listAdmins().filter((a) => a.active); | |
| 66 | + const metadata = (() => { | |
| 67 | + try { | |
| 68 | + return JSON.parse(sub.metadata ?? "{}") as Record<string, unknown>; | |
| 69 | + } catch { | |
| 70 | + return {}; | |
| 71 | + } | |
| 72 | + })(); | |
| 73 | + const tags = (() => { | |
| 74 | + try { | |
| 75 | + const t = JSON.parse(sub.tags ?? "[]") as unknown; | |
| 76 | + return Array.isArray(t) ? t.filter((x): x is string => typeof x === "string") : []; | |
| 77 | + } catch { | |
| 78 | + return []; | |
| 79 | + } | |
| 80 | + })(); | |
| 81 | + // libellés humains des clés metadata, depuis le registre | |
| 82 | + const fieldLabel = (key: string): string => | |
| 83 | + cat?.fields.find((f) => f.name === key)?.label ?? key; | |
| 84 | + const displayName = sub.is_anonymous | |
| 85 | + ? "Anonyme" | |
| 86 | + : [sub.first_name, sub.last_name].filter(Boolean).join(" ") || sub.email || "—"; | |
| 87 | + | |
| 88 | + return ( | |
| 89 | + <> | |
| 90 | + <nav aria-label="Fil d'Ariane"> | |
| 91 | + <a href="/admin/inbox" className="gk-mono text-[11px] font-bold tracking-[0.1em] text-ink-3 uppercase no-underline hover:text-green"> | |
| 92 | + ← Inbox | |
| 93 | + </a> | |
| 94 | + </nav> | |
| 95 | + | |
| 96 | + <div className="mt-4 flex flex-wrap items-center gap-3"> | |
| 97 | + <h1 className="gk-mono text-[clamp(17px,2.5vw,22px)] font-bold tracking-[0.06em] text-green"> | |
| 98 | + {sub.reference} | |
| 99 | + </h1> | |
| 100 | + <span className="adm-chip">{cat?.title ?? sub.category}</span> | |
| 101 | + <span className={`adm-chip adm-chip--${sub.priority}`}> | |
| 102 | + {PRIORITIES[sub.priority as keyof typeof PRIORITIES] ?? sub.priority} | |
| 103 | + </span> | |
| 104 | + <span className="adm-chip adm-chip--ghost"> | |
| 105 | + {statusesFor(sub.category)[sub.status] ?? sub.status} | |
| 106 | + </span> | |
| 107 | + <span className="adm-chip adm-chip--ghost"> | |
| 108 | + Confidentialité : {CONFIDENTIALITY_LEVELS[sub.confidentiality] ?? sub.confidentiality} | |
| 109 | + </span> | |
| 110 | + {sub.is_spam ? <span className="adm-chip adm-chip--urgente">Spam</span> : null} | |
| 111 | + {sub.is_archived ? <span className="adm-chip adm-chip--ghost">Archivée</span> : null} | |
| 112 | + {tags.map((t) => ( | |
| 113 | + <span key={t} className="adm-chip adm-chip--lime">#{t}</span> | |
| 114 | + ))} | |
| 115 | + <span className="gk-mono ml-auto text-[11px] text-ink-3"> | |
| 116 | + Reçue le {fmtDate(sub.created_at)} | |
| 117 | + {sub.resolved_at ? ` · résolue le ${fmtDate(sub.resolved_at)}` : ""} | |
| 118 | + </span> | |
| 119 | + </div> | |
| 120 | + | |
| 121 | + <div className="mt-6 grid gap-6 xl:grid-cols-[1fr_360px]"> | |
| 122 | + {/* colonne principale : identité + contenu + conversation + historique */} | |
| 123 | + <div className="flex min-w-0 flex-col gap-6"> | |
| 124 | + <section className="gk-card p-5 !shadow-none"> | |
| 125 | + <p className="kicker">Identité</p> | |
| 126 | + <div className="mt-4 grid grid-cols-2 gap-x-6 gap-y-4 sm:grid-cols-3"> | |
| 127 | + <Meta label="Nom" value={displayName} /> | |
| 128 | + <Meta | |
| 129 | + label="Courriel" | |
| 130 | + value={sub.email ? <a className="text-green underline underline-offset-2" href={`mailto:${sub.email}`}>{sub.email}</a> : sub.is_anonymous ? "— (anonyme)" : null} | |
| 131 | + /> | |
| 132 | + <Meta label="Téléphone" value={sub.phone} /> | |
| 133 | + <Meta label="Organisation" value={sub.organization} /> | |
| 134 | + <Meta label="Rôle / poste" value={sub.job_title} /> | |
| 135 | + <Meta | |
| 136 | + label="KA ID" | |
| 137 | + value={sub.ka_id ? <span className="gk-mono font-bold">{sub.ka_id}</span> : null} | |
| 138 | + /> | |
| 139 | + <Meta | |
| 140 | + label="Site web" | |
| 141 | + value={sub.website ? <a className="text-green underline underline-offset-2" href={sub.website} target="_blank" rel="noopener noreferrer">{sub.website}</a> : null} | |
| 142 | + /> | |
| 143 | + <Meta | |
| 144 | + label="LinkedIn" | |
| 145 | + value={sub.linkedin_url ? <a className="text-green underline underline-offset-2" href={sub.linkedin_url} target="_blank" rel="noopener noreferrer">{sub.linkedin_url}</a> : null} | |
| 146 | + /> | |
| 147 | + <Meta | |
| 148 | + label="GitHub / portfolio" | |
| 149 | + value={sub.github_url ? <a className="text-green underline underline-offset-2" href={sub.github_url} target="_blank" rel="noopener noreferrer">{sub.github_url}</a> : null} | |
| 150 | + /> | |
| 151 | + </div> | |
| 152 | + </section> | |
| 153 | + | |
| 154 | + <section className="gk-card p-5 !shadow-none"> | |
| 155 | + <p className="kicker">Contenu de la demande</p> | |
| 156 | + <div className="mt-4 grid grid-cols-2 gap-x-6 gap-y-4 sm:grid-cols-3"> | |
| 157 | + <Meta label="Sous-catégorie" value={sub.sub_category} /> | |
| 158 | + <Meta label="Site concerné" value={sub.site_concerned} /> | |
| 159 | + <Meta label="Sujet" value={sub.subject} /> | |
| 160 | + <Meta label="Source" value={sub.source} /> | |
| 161 | + </div> | |
| 162 | + {sub.message ? ( | |
| 163 | + <div className="mt-5 border-l-2 border-green bg-surface-2 px-4 py-3"> | |
| 164 | + <p className="text-[14px] leading-relaxed whitespace-pre-wrap">{sub.message}</p> | |
| 165 | + </div> | |
| 166 | + ) : null} | |
| 167 | + {Object.keys(metadata).length ? ( | |
| 168 | + <div className="mt-5"> | |
| 169 | + <p className="klabel">Métadonnées du formulaire</p> | |
| 170 | + <div className="mt-2 grid grid-cols-2 gap-x-6 gap-y-3 sm:grid-cols-3"> | |
| 171 | + {Object.entries(metadata).map(([k, v]) => ( | |
| 172 | + <Meta | |
| 173 | + key={k} | |
| 174 | + label={fieldLabel(k)} | |
| 175 | + value={v === true ? "Oui" : String(v)} | |
| 176 | + /> | |
| 177 | + ))} | |
| 178 | + </div> | |
| 179 | + </div> | |
| 180 | + ) : null} | |
| 181 | + {atts.length ? ( | |
| 182 | + <div className="mt-5"> | |
| 183 | + <p className="klabel">Pièces jointes</p> | |
| 184 | + <ul className="mt-2 flex flex-wrap gap-2"> | |
| 185 | + {atts.map((a) => ( | |
| 186 | + <li key={a.id}> | |
| 187 | + <a | |
| 188 | + href={`/api/admin/attachments/${a.id}`} | |
| 189 | + className="gk-mono inline-flex items-center gap-2 rounded-lg border-[1.5px] border-ink bg-surface px-3 py-2 text-[11.5px] font-bold no-underline hover:bg-lime-soft" | |
| 190 | + > | |
| 191 | + ⭳ {a.original_name} | |
| 192 | + <span className="font-normal text-ink-3">{fmtBytes(a.size)}</span> | |
| 193 | + </a> | |
| 194 | + </li> | |
| 195 | + ))} | |
| 196 | + </ul> | |
| 197 | + </div> | |
| 198 | + ) : null} | |
| 199 | + </section> | |
| 200 | + | |
| 201 | + <section className="gk-card p-5 !shadow-none"> | |
| 202 | + <p className="kicker">Conversation ({messages.length})</p> | |
| 203 | + {messages.length === 0 ? ( | |
| 204 | + <p className="gk-mono mt-3 text-[11.5px] text-ink-3"> | |
| 205 | + Aucun échange pour l'instant — répondez depuis le panneau de droite. | |
| 206 | + </p> | |
| 207 | + ) : ( | |
| 208 | + <ul className="mt-4 flex flex-col gap-3"> | |
| 209 | + {messages.map((m) => ( | |
| 210 | + <li | |
| 211 | + key={m.id} | |
| 212 | + className={`max-w-[85%] rounded-xl border-[1.5px] px-4 py-3 ${ | |
| 213 | + m.sender_type === "admin" | |
| 214 | + ? "self-end border-ink bg-ink text-paper" | |
| 215 | + : "self-start border-line bg-surface-2" | |
| 216 | + }`} | |
| 217 | + > | |
| 218 | + <p className={`gk-mono text-[9.5px] font-bold tracking-[0.1em] uppercase ${m.sender_type === "admin" ? "text-lime" : "text-ink-3"}`}> | |
| 219 | + {m.sender_type === "admin" ? (m.author ?? "Équipe KA") : displayName} · {fmtDate(m.created_at)} · {m.channel} | |
| 220 | + </p> | |
| 221 | + <p className="mt-1 text-[13.5px] leading-relaxed whitespace-pre-wrap">{m.body}</p> | |
| 222 | + </li> | |
| 223 | + ))} | |
| 224 | + </ul> | |
| 225 | + )} | |
| 226 | + </section> | |
| 227 | + | |
| 228 | + <section className="gk-card p-5 !shadow-none"> | |
| 229 | + <p className="kicker">Notes internes ({notes.length})</p> | |
| 230 | + {notes.length === 0 ? ( | |
| 231 | + <p className="gk-mono mt-3 text-[11.5px] text-ink-3">Aucune note.</p> | |
| 232 | + ) : ( | |
| 233 | + <ul className="mt-4 flex flex-col gap-3"> | |
| 234 | + {notes.map((n) => ( | |
| 235 | + <li key={n.id} className="border-l-2 border-amber bg-amber-soft px-4 py-3"> | |
| 236 | + <p className="gk-mono text-[9.5px] font-bold tracking-[0.1em] text-[#7a4d0d] uppercase"> | |
| 237 | + {n.author} · {fmtDate(n.created_at)} · interne | |
| 238 | + </p> | |
| 239 | + <p className="mt-1 text-[13.5px] leading-relaxed whitespace-pre-wrap">{n.body}</p> | |
| 240 | + </li> | |
| 241 | + ))} | |
| 242 | + </ul> | |
| 243 | + )} | |
| 244 | + </section> | |
| 245 | + | |
| 246 | + <section className="gk-card p-5 !shadow-none"> | |
| 247 | + <p className="kicker">Historique d'activité</p> | |
| 248 | + <ul className="mt-4 flex flex-col"> | |
| 249 | + {events.map((e) => ( | |
| 250 | + <li key={e.id} className="flex items-baseline gap-3 border-t border-line py-2 first:border-t-0"> | |
| 251 | + <span className="gk-mono flex-none text-[10px] text-ink-3">{fmtDate(e.created_at)}</span> | |
| 252 | + <span className="adm-chip adm-chip--ghost flex-none !text-[9px]">{e.kind}</span> | |
| 253 | + <span className="min-w-0 text-[12.5px] text-ink-2"> | |
| 254 | + {e.detail} | |
| 255 | + {e.author ? <span className="text-ink-3"> — {e.author}</span> : null} | |
| 256 | + </span> | |
| 257 | + </li> | |
| 258 | + ))} | |
| 259 | + </ul> | |
| 260 | + </section> | |
| 261 | + </div> | |
| 262 | + | |
| 263 | + {/* colonne workflow */} | |
| 264 | + <aside className="h-fit xl:sticky xl:top-8"> | |
| 265 | + <div className="gk-card p-5"> | |
| 266 | + <p className="kicker">Workflow</p> | |
| 267 | + <div className="mt-4"> | |
| 268 | + <SubmissionActions | |
| 269 | + id={sub.id} | |
| 270 | + statuses={statusesFor(sub.category)} | |
| 271 | + status={sub.status} | |
| 272 | + priority={sub.priority} | |
| 273 | + confidentiality={sub.confidentiality} | |
| 274 | + assignedTo={sub.assigned_to} | |
| 275 | + tags={tags} | |
| 276 | + admins={admins.map((a) => ({ id: a.id, name: a.display_name }))} | |
| 277 | + isArchived={!!sub.is_archived} | |
| 278 | + isSpam={!!sub.is_spam} | |
| 279 | + hasEmail={!!sub.email} | |
| 280 | + /> | |
| 281 | + </div> | |
| 282 | + </div> | |
| 283 | + </aside> | |
| 284 | + </div> | |
| 285 | + </> | |
| 286 | + ); | |
| 287 | +} | |
added
src/app/admin/(panel)/equipe/TeamClient.tsx
+293 −0
@@ -0,0 +1,293 @@ | ||
| 1 | +"use client"; | |
| 2 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 3 | +// /admin/equipe — composants interactifs : changement de SON mot de passe, | |
| 4 | +// fiche d'un membre (rôles, activation, réinitialisation) et création de | |
| 5 | +// compte. Toutes les règles sont revérifiées par l'API côté serveur. | |
| 6 | +import { useState } from "react"; | |
| 7 | +import { useRouter } from "next/navigation"; | |
| 8 | +import { ADMIN_ROLES } from "@/lib/comms/categories"; | |
| 9 | + | |
| 10 | +function useApi() { | |
| 11 | + const router = useRouter(); | |
| 12 | + const [busy, setBusy] = useState(false); | |
| 13 | + const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null); | |
| 14 | + async function call(url: string, method: string, body: unknown): Promise<boolean> { | |
| 15 | + setBusy(true); | |
| 16 | + setMsg(null); | |
| 17 | + try { | |
| 18 | + const res = await fetch(url, { | |
| 19 | + method, | |
| 20 | + headers: { "Content-Type": "application/json" }, | |
| 21 | + body: JSON.stringify(body), | |
| 22 | + }); | |
| 23 | + const data = (await res.json().catch(() => ({}))) as { error?: string }; | |
| 24 | + if (!res.ok) { | |
| 25 | + setMsg({ ok: false, text: data.error ?? "Action refusée." }); | |
| 26 | + setBusy(false); | |
| 27 | + return false; | |
| 28 | + } | |
| 29 | + router.refresh(); | |
| 30 | + setBusy(false); | |
| 31 | + return true; | |
| 32 | + } catch { | |
| 33 | + setMsg({ ok: false, text: "Réseau indisponible." }); | |
| 34 | + setBusy(false); | |
| 35 | + return false; | |
| 36 | + } | |
| 37 | + } | |
| 38 | + return { busy, msg, setMsg, call }; | |
| 39 | +} | |
| 40 | + | |
| 41 | +function Feedback({ msg }: { msg: { ok: boolean; text: string } | null }) { | |
| 42 | + if (!msg) return null; | |
| 43 | + return ( | |
| 44 | + <p | |
| 45 | + className={`gk-mono mt-3 rounded-lg border-[1.5px] px-3 py-2 text-[11px] font-bold ${ | |
| 46 | + msg.ok | |
| 47 | + ? "border-green bg-lime-soft text-green" | |
| 48 | + : "border-danger bg-[var(--danger-soft)] text-danger" | |
| 49 | + }`} | |
| 50 | + > | |
| 51 | + {msg.text} | |
| 52 | + </p> | |
| 53 | + ); | |
| 54 | +} | |
| 55 | + | |
| 56 | +/* ---------- mon mot de passe ---------- */ | |
| 57 | + | |
| 58 | +export function ChangeMyPassword() { | |
| 59 | + const { busy, msg, setMsg, call } = useApi(); | |
| 60 | + const [current, setCurrent] = useState(""); | |
| 61 | + const [next, setNext] = useState(""); | |
| 62 | + const [confirm, setConfirm] = useState(""); | |
| 63 | + return ( | |
| 64 | + <form | |
| 65 | + className="mt-4" | |
| 66 | + onSubmit={async (e) => { | |
| 67 | + e.preventDefault(); | |
| 68 | + if (next !== confirm) { | |
| 69 | + setMsg({ ok: false, text: "La confirmation ne correspond pas." }); | |
| 70 | + return; | |
| 71 | + } | |
| 72 | + if (await call("/api/admin/password", "POST", { current, next })) { | |
| 73 | + setMsg({ ok: true, text: "Mot de passe changé — vos autres sessions sont déconnectées." }); | |
| 74 | + setCurrent(""); | |
| 75 | + setNext(""); | |
| 76 | + setConfirm(""); | |
| 77 | + } | |
| 78 | + }} | |
| 79 | + > | |
| 80 | + <label className="klabel mb-[4px] block" htmlFor="pw-cur">Mot de passe actuel</label> | |
| 81 | + <input id="pw-cur" type="password" className="field" autoComplete="current-password" value={current} onChange={(e) => setCurrent(e.target.value)} required /> | |
| 82 | + <div className="mt-3 grid gap-3 sm:grid-cols-2"> | |
| 83 | + <div> | |
| 84 | + <label className="klabel mb-[4px] block" htmlFor="pw-next">Nouveau (8 car. min.)</label> | |
| 85 | + <input id="pw-next" type="password" className="field" autoComplete="new-password" value={next} onChange={(e) => setNext(e.target.value)} required minLength={8} /> | |
| 86 | + </div> | |
| 87 | + <div> | |
| 88 | + <label className="klabel mb-[4px] block" htmlFor="pw-conf">Confirmation</label> | |
| 89 | + <input id="pw-conf" type="password" className="field" autoComplete="new-password" value={confirm} onChange={(e) => setConfirm(e.target.value)} required minLength={8} /> | |
| 90 | + </div> | |
| 91 | + </div> | |
| 92 | + <button type="submit" className="btn btn-primary mt-4 !min-h-[40px] !px-5 !text-[13px]" disabled={busy}> | |
| 93 | + Changer mon mot de passe | |
| 94 | + </button> | |
| 95 | + <Feedback msg={msg} /> | |
| 96 | + </form> | |
| 97 | + ); | |
| 98 | +} | |
| 99 | + | |
| 100 | +/* ---------- fiche membre (super admin) ---------- */ | |
| 101 | + | |
| 102 | +export function TeamMemberRow({ | |
| 103 | + admin, | |
| 104 | + self, | |
| 105 | +}: { | |
| 106 | + admin: { | |
| 107 | + id: number; | |
| 108 | + username: string; | |
| 109 | + displayName: string; | |
| 110 | + email: string | null; | |
| 111 | + roles: string[]; | |
| 112 | + active: boolean; | |
| 113 | + lastLogin: string | null; | |
| 114 | + }; | |
| 115 | + self: boolean; | |
| 116 | +}) { | |
| 117 | + const { busy, msg, setMsg, call } = useApi(); | |
| 118 | + const [roles, setRoles] = useState<string[]>(admin.roles); | |
| 119 | + const [reset, setReset] = useState(""); | |
| 120 | + const [open, setOpen] = useState(false); | |
| 121 | + | |
| 122 | + function toggleRole(r: string) { | |
| 123 | + setRoles((cur) => (cur.includes(r) ? cur.filter((x) => x !== r) : [...cur, r])); | |
| 124 | + } | |
| 125 | + | |
| 126 | + return ( | |
| 127 | + <div className={`gk-card p-5 !shadow-none ${admin.active ? "" : "opacity-60"}`}> | |
| 128 | + <div className="flex flex-wrap items-center gap-3"> | |
| 129 | + <div className="min-w-0"> | |
| 130 | + <p className="gk-display text-[16px] font-bold"> | |
| 131 | + {admin.displayName} | |
| 132 | + {self ? <span className="gk-mono ml-2 text-[10px] font-bold text-green uppercase">(vous)</span> : null} | |
| 133 | + </p> | |
| 134 | + <p className="gk-mono text-[10.5px] text-ink-3"> | |
| 135 | + @{admin.username} | |
| 136 | + {admin.email ? ` · ${admin.email}` : ""} | |
| 137 | + {admin.lastLogin ? ` · vu le ${admin.lastLogin.slice(0, 16).replace("T", " ")}` : " · jamais connecté"} | |
| 138 | + </p> | |
| 139 | + </div> | |
| 140 | + <div className="ml-auto flex flex-wrap gap-1"> | |
| 141 | + {admin.roles.map((r) => ( | |
| 142 | + <span key={r} className="adm-chip !text-[9px]">{ADMIN_ROLES[r] ?? r}</span> | |
| 143 | + ))} | |
| 144 | + {!admin.active ? <span className="adm-chip adm-chip--urgente !text-[9px]">Désactivé</span> : null} | |
| 145 | + </div> | |
| 146 | + <button | |
| 147 | + className="gk-mono cursor-pointer text-[10.5px] font-bold tracking-[0.08em] text-green uppercase underline-offset-4 hover:underline" | |
| 148 | + onClick={() => setOpen((o) => !o)} | |
| 149 | + > | |
| 150 | + {open ? "Fermer" : "Gérer"} | |
| 151 | + </button> | |
| 152 | + </div> | |
| 153 | + | |
| 154 | + {open ? ( | |
| 155 | + <div className="rule-hair mt-4 pt-4"> | |
| 156 | + <p className="klabel">Rôles</p> | |
| 157 | + <div className="mt-2 flex flex-wrap gap-x-5 gap-y-2"> | |
| 158 | + {Object.entries(ADMIN_ROLES).map(([k, v]) => ( | |
| 159 | + <label key={k} className="flex cursor-pointer items-center gap-2 text-[12.5px]"> | |
| 160 | + <input | |
| 161 | + type="checkbox" | |
| 162 | + checked={roles.includes(k)} | |
| 163 | + onChange={() => toggleRole(k)} | |
| 164 | + className="h-4 w-4 accent-[var(--green)]" | |
| 165 | + /> | |
| 166 | + {v} | |
| 167 | + </label> | |
| 168 | + ))} | |
| 169 | + </div> | |
| 170 | + <div className="mt-4 flex flex-wrap items-end gap-3"> | |
| 171 | + <button | |
| 172 | + className="btn btn-ghost !min-h-[36px] !px-4 !text-[12.5px]" | |
| 173 | + disabled={busy} | |
| 174 | + onClick={() => call(`/api/admin/team/${admin.id}`, "PATCH", { roles })} | |
| 175 | + > | |
| 176 | + Enregistrer les rôles | |
| 177 | + </button> | |
| 178 | + <button | |
| 179 | + className="btn btn-ghost !min-h-[36px] !px-4 !text-[12.5px]" | |
| 180 | + disabled={busy || self} | |
| 181 | + onClick={() => call(`/api/admin/team/${admin.id}`, "PATCH", { active: !admin.active })} | |
| 182 | + > | |
| 183 | + {admin.active ? "Désactiver" : "Réactiver"} | |
| 184 | + </button> | |
| 185 | + <div className="flex items-end gap-2"> | |
| 186 | + <div> | |
| 187 | + <label className="klabel mb-[4px] block" htmlFor={`rs-${admin.id}`}> | |
| 188 | + Nouveau mot de passe | |
| 189 | + </label> | |
| 190 | + <input | |
| 191 | + id={`rs-${admin.id}`} | |
| 192 | + type="password" | |
| 193 | + className="field !py-[7px] !text-[13px]" | |
| 194 | + value={reset} | |
| 195 | + onChange={(e) => setReset(e.target.value)} | |
| 196 | + minLength={8} | |
| 197 | + autoComplete="new-password" | |
| 198 | + /> | |
| 199 | + </div> | |
| 200 | + <button | |
| 201 | + className="btn btn-ghost !min-h-[36px] !px-4 !text-[12.5px]" | |
| 202 | + disabled={busy || reset.length < 8} | |
| 203 | + onClick={async () => { | |
| 204 | + if (await call(`/api/admin/team/${admin.id}`, "PATCH", { password: reset })) { | |
| 205 | + setReset(""); | |
| 206 | + setMsg({ ok: true, text: "Mot de passe réinitialisé (sessions du compte invalidées)." }); | |
| 207 | + } | |
| 208 | + }} | |
| 209 | + > | |
| 210 | + Réinitialiser | |
| 211 | + </button> | |
| 212 | + </div> | |
| 213 | + </div> | |
| 214 | + <Feedback msg={msg} /> | |
| 215 | + </div> | |
| 216 | + ) : null} | |
| 217 | + </div> | |
| 218 | + ); | |
| 219 | +} | |
| 220 | + | |
| 221 | +/* ---------- création de compte (super admin) ---------- */ | |
| 222 | + | |
| 223 | +export function CreateAdminForm() { | |
| 224 | + const { busy, msg, setMsg, call } = useApi(); | |
| 225 | + const [username, setUsername] = useState(""); | |
| 226 | + const [displayName, setDisplayName] = useState(""); | |
| 227 | + const [email, setEmail] = useState(""); | |
| 228 | + const [password, setPassword] = useState(""); | |
| 229 | + const [roles, setRoles] = useState<string[]>(["support"]); | |
| 230 | + | |
| 231 | + return ( | |
| 232 | + <form | |
| 233 | + onSubmit={async (e) => { | |
| 234 | + e.preventDefault(); | |
| 235 | + if ( | |
| 236 | + await call("/api/admin/team", "POST", { | |
| 237 | + username, | |
| 238 | + display_name: displayName, | |
| 239 | + email: email || undefined, | |
| 240 | + password, | |
| 241 | + roles, | |
| 242 | + }) | |
| 243 | + ) { | |
| 244 | + setMsg({ ok: true, text: `Compte « ${username} » créé.` }); | |
| 245 | + setUsername(""); | |
| 246 | + setDisplayName(""); | |
| 247 | + setEmail(""); | |
| 248 | + setPassword(""); | |
| 249 | + setRoles(["support"]); | |
| 250 | + } | |
| 251 | + }} | |
| 252 | + > | |
| 253 | + <div className="grid gap-3 sm:grid-cols-2"> | |
| 254 | + <div> | |
| 255 | + <label className="klabel mb-[4px] block" htmlFor="na-user">Identifiant</label> | |
| 256 | + <input id="na-user" className="field" value={username} onChange={(e) => setUsername(e.target.value)} required pattern="[a-z0-9._-]{3,40}" autoCapitalize="none" /> | |
| 257 | + </div> | |
| 258 | + <div> | |
| 259 | + <label className="klabel mb-[4px] block" htmlFor="na-name">Nom d'affichage</label> | |
| 260 | + <input id="na-name" className="field" value={displayName} onChange={(e) => setDisplayName(e.target.value)} required /> | |
| 261 | + </div> | |
| 262 | + <div> | |
| 263 | + <label className="klabel mb-[4px] block" htmlFor="na-email">Courriel (facultatif)</label> | |
| 264 | + <input id="na-email" type="email" className="field" value={email} onChange={(e) => setEmail(e.target.value)} /> | |
| 265 | + </div> | |
| 266 | + <div> | |
| 267 | + <label className="klabel mb-[4px] block" htmlFor="na-pass">Mot de passe initial (8 car. min.)</label> | |
| 268 | + <input id="na-pass" type="password" className="field" value={password} onChange={(e) => setPassword(e.target.value)} required minLength={8} autoComplete="new-password" /> | |
| 269 | + </div> | |
| 270 | + </div> | |
| 271 | + <p className="klabel mt-4">Rôles</p> | |
| 272 | + <div className="mt-2 flex flex-wrap gap-x-5 gap-y-2"> | |
| 273 | + {Object.entries(ADMIN_ROLES).map(([k, v]) => ( | |
| 274 | + <label key={k} className="flex cursor-pointer items-center gap-2 text-[12.5px]"> | |
| 275 | + <input | |
| 276 | + type="checkbox" | |
| 277 | + checked={roles.includes(k)} | |
| 278 | + onChange={() => | |
| 279 | + setRoles((cur) => (cur.includes(k) ? cur.filter((x) => x !== k) : [...cur, k])) | |
| 280 | + } | |
| 281 | + className="h-4 w-4 accent-[var(--green)]" | |
| 282 | + /> | |
| 283 | + {v} | |
| 284 | + </label> | |
| 285 | + ))} | |
| 286 | + </div> | |
| 287 | + <button type="submit" className="btn btn-primary mt-5 !min-h-[40px] !px-5 !text-[13px]" disabled={busy}> | |
| 288 | + Créer le compte | |
| 289 | + </button> | |
| 290 | + <Feedback msg={msg} /> | |
| 291 | + </form> | |
| 292 | + ); | |
| 293 | +} | |
added
src/app/admin/(panel)/equipe/page.tsx
+77 −0
@@ -0,0 +1,77 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// /admin/equipe — gestion des accès du panel. Tout admin peut changer SON | |
| 3 | +// mot de passe ; la gestion des comptes (création, rôles, activation, | |
| 4 | +// réinitialisation) est réservée au super administrateur — et l'API le | |
| 5 | +// revérifie côté serveur. | |
| 6 | +import { redirect } from "next/navigation"; | |
| 7 | +import { getAdminSession, isSuperAdmin, parseRoles } from "@/lib/comms/admin-auth"; | |
| 8 | +import { listAdmins } from "@/lib/comms/db"; | |
| 9 | +import { ADMIN_ROLES } from "@/lib/comms/categories"; | |
| 10 | +import { ChangeMyPassword, CreateAdminForm, TeamMemberRow } from "./TeamClient"; | |
| 11 | + | |
| 12 | +export const dynamic = "force-dynamic"; | |
| 13 | + | |
| 14 | +export default async function EquipePage() { | |
| 15 | + const session = await getAdminSession(); | |
| 16 | + if (!session) redirect("/admin/connexion"); | |
| 17 | + const superAdmin = isSuperAdmin(session); | |
| 18 | + const admins = listAdmins(); | |
| 19 | + | |
| 20 | + return ( | |
| 21 | + <> | |
| 22 | + <p className="kicker">Équipe & accès</p> | |
| 23 | + <h1 className="gk-display mt-2 text-[clamp(22px,3vw,30px)] font-bold tracking-[-0.03em]"> | |
| 24 | + Comptes du panel | |
| 25 | + </h1> | |
| 26 | + <p className="mt-3 max-w-2xl text-[13.5px] text-ink-2"> | |
| 27 | + Les rôles limitent ce que chaque personne voit : investisseurs, légal | |
| 28 | + et sécurité ne sont visibles que des rôles dédiés (et du super | |
| 29 | + administrateur). Le niveau « restreinte » d'une demande resserre | |
| 30 | + encore l'accès. | |
| 31 | + </p> | |
| 32 | + | |
| 33 | + <section className="gk-card mt-8 max-w-xl p-6 !shadow-none"> | |
| 34 | + <p className="kicker">Mon mot de passe</p> | |
| 35 | + <ChangeMyPassword /> | |
| 36 | + </section> | |
| 37 | + | |
| 38 | + {superAdmin ? ( | |
| 39 | + <> | |
| 40 | + <div className="sec-head mt-12"> | |
| 41 | + <p className="kicker">Membres ({admins.length})</p> | |
| 42 | + <span className="sec-index">super admin requis</span> | |
| 43 | + </div> | |
| 44 | + <div className="mt-5 flex flex-col gap-4"> | |
| 45 | + {admins.map((a) => ( | |
| 46 | + <TeamMemberRow | |
| 47 | + key={a.id} | |
| 48 | + admin={{ | |
| 49 | + id: a.id, | |
| 50 | + username: a.username, | |
| 51 | + displayName: a.display_name, | |
| 52 | + email: a.email, | |
| 53 | + roles: parseRoles(a.roles), | |
| 54 | + active: !!a.active, | |
| 55 | + lastLogin: a.last_login, | |
| 56 | + }} | |
| 57 | + self={a.id === session.id} | |
| 58 | + /> | |
| 59 | + ))} | |
| 60 | + </div> | |
| 61 | + | |
| 62 | + <div className="sec-head mt-12"> | |
| 63 | + <p className="kicker">Ajouter un compte</p> | |
| 64 | + <span className="sec-index">{Object.keys(ADMIN_ROLES).length} rôles</span> | |
| 65 | + </div> | |
| 66 | + <div className="gk-card mt-5 max-w-2xl p-6 !shadow-none"> | |
| 67 | + <CreateAdminForm /> | |
| 68 | + </div> | |
| 69 | + </> | |
| 70 | + ) : ( | |
| 71 | + <p className="gk-mono mt-10 text-[11.5px] text-ink-3"> | |
| 72 | + La gestion des comptes est réservée au super administrateur. | |
| 73 | + </p> | |
| 74 | + )} | |
| 75 | + </> | |
| 76 | + ); | |
| 77 | +} | |
added
src/app/admin/(panel)/inbox/page.tsx
+255 −0
@@ -0,0 +1,255 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// /admin/inbox — toutes les demandes, centralisées. Filtres (catégorie, | |
| 3 | +// sous-catégorie, statut, priorité, site, assignation, KA ID, vue) + | |
| 4 | +// recherche globale + pagination. Le filtrage RBAC est fait dans la requête | |
| 5 | +// SQL : un admin ne voit jamais une ligne interdite. | |
| 6 | +import { redirect } from "next/navigation"; | |
| 7 | +import { getAdminSession } from "@/lib/comms/admin-auth"; | |
| 8 | +import { listSubmissions, type InboxFilters } from "@/lib/comms/queries"; | |
| 9 | +import { listAdmins } from "@/lib/comms/db"; | |
| 10 | +import { | |
| 11 | + CATEGORIES, | |
| 12 | + categoryBySlug, | |
| 13 | + PRIORITIES, | |
| 14 | + SITES_KA, | |
| 15 | + STATUSES, | |
| 16 | + JOB_STATUSES, | |
| 17 | + statusesFor, | |
| 18 | +} from "@/lib/comms/categories"; | |
| 19 | + | |
| 20 | +export const dynamic = "force-dynamic"; | |
| 21 | + | |
| 22 | +const QUICK = [ | |
| 23 | + { label: "Investisseurs", category: "investisseurs" }, | |
| 24 | + { label: "Candidatures", category: "carrieres" }, | |
| 25 | + { label: "Légal", category: "legal" }, | |
| 26 | + { label: "Sécurité", category: "securite" }, | |
| 27 | + { label: "Médias", category: "medias" }, | |
| 28 | +] as const; | |
| 29 | + | |
| 30 | +function fmtDate(iso: string): string { | |
| 31 | + return iso.replace("T", " ").slice(0, 16); | |
| 32 | +} | |
| 33 | + | |
| 34 | +export default async function InboxPage({ | |
| 35 | + searchParams, | |
| 36 | +}: { | |
| 37 | + searchParams: Promise<Record<string, string | string[] | undefined>>; | |
| 38 | +}) { | |
| 39 | + const session = await getAdminSession(); | |
| 40 | + if (!session) redirect("/admin/connexion"); | |
| 41 | + const sp = await searchParams; | |
| 42 | + const one = (k: string): string => | |
| 43 | + typeof sp[k] === "string" ? (sp[k] as string).slice(0, 200) : ""; | |
| 44 | + | |
| 45 | + const filters: InboxFilters = { | |
| 46 | + category: one("category") || undefined, | |
| 47 | + subCategory: one("sub") || undefined, | |
| 48 | + status: one("status") || undefined, | |
| 49 | + priority: one("priority") || undefined, | |
| 50 | + site: one("site") || undefined, | |
| 51 | + assigned: one("assigned") || undefined, | |
| 52 | + kaId: one("ka_id") || undefined, | |
| 53 | + q: one("q") || undefined, | |
| 54 | + view: (["actives", "archivees", "spam", "toutes"].includes(one("view")) | |
| 55 | + ? one("view") | |
| 56 | + : "actives") as InboxFilters["view"], | |
| 57 | + page: Math.max(1, Number(one("page")) || 1), | |
| 58 | + }; | |
| 59 | + const { rows, total } = listSubmissions(session, filters); | |
| 60 | + const admins = listAdmins().filter((a) => a.active); | |
| 61 | + const pages = Math.max(1, Math.ceil(total / 40)); | |
| 62 | + const currentCat = filters.category ? categoryBySlug(filters.category) : undefined; | |
| 63 | + const statusOptions = filters.category | |
| 64 | + ? statusesFor(filters.category) | |
| 65 | + : { ...STATUSES, ...JOB_STATUSES }; | |
| 66 | + | |
| 67 | + const qs = (patch: Record<string, string>) => { | |
| 68 | + const params = new URLSearchParams(); | |
| 69 | + for (const [k, v] of Object.entries({ | |
| 70 | + category: filters.category ?? "", | |
| 71 | + sub: filters.subCategory ?? "", | |
| 72 | + status: filters.status ?? "", | |
| 73 | + priority: filters.priority ?? "", | |
| 74 | + site: filters.site ?? "", | |
| 75 | + assigned: filters.assigned ?? "", | |
| 76 | + ka_id: filters.kaId ?? "", | |
| 77 | + q: filters.q ?? "", | |
| 78 | + view: filters.view ?? "actives", | |
| 79 | + ...patch, | |
| 80 | + })) | |
| 81 | + if (v) params.set(k, v); | |
| 82 | + const s = params.toString(); | |
| 83 | + return s ? `/admin/inbox?${s}` : "/admin/inbox"; | |
| 84 | + }; | |
| 85 | + | |
| 86 | + return ( | |
| 87 | + <> | |
| 88 | + <div className="flex flex-wrap items-end justify-between gap-4"> | |
| 89 | + <div> | |
| 90 | + <p className="kicker">Inbox</p> | |
| 91 | + <h1 className="gk-display mt-2 text-[clamp(22px,3vw,30px)] font-bold tracking-[-0.03em]"> | |
| 92 | + {total} demande{total > 1 ? "s" : ""} | |
| 93 | + {currentCat ? ` · ${currentCat.title}` : ""} | |
| 94 | + </h1> | |
| 95 | + </div> | |
| 96 | + <div className="flex flex-wrap gap-2"> | |
| 97 | + {QUICK.map((qf) => ( | |
| 98 | + <a | |
| 99 | + key={qf.category} | |
| 100 | + href={qs({ category: filters.category === qf.category ? "" : qf.category, page: "" })} | |
| 101 | + className={`adm-chip no-underline ${filters.category === qf.category ? "adm-chip--lime" : ""}`} | |
| 102 | + > | |
| 103 | + {qf.label} | |
| 104 | + </a> | |
| 105 | + ))} | |
| 106 | + </div> | |
| 107 | + </div> | |
| 108 | + | |
| 109 | + {/* filtres — formulaire GET simple, robuste, sans JS */} | |
| 110 | + <form method="get" action="/admin/inbox" className="gk-card mt-6 grid grid-cols-2 gap-3 p-4 !shadow-none sm:grid-cols-3 xl:grid-cols-6"> | |
| 111 | + <div className="col-span-2 sm:col-span-3 xl:col-span-2"> | |
| 112 | + <label className="klabel mb-[4px] block" htmlFor="f-q">Recherche globale</label> | |
| 113 | + <input id="f-q" name="q" defaultValue={filters.q ?? ""} placeholder="référence, nom, organisation, sujet…" className="field !py-[8px]" /> | |
| 114 | + </div> | |
| 115 | + <div> | |
| 116 | + <label className="klabel mb-[4px] block" htmlFor="f-cat">Catégorie</label> | |
| 117 | + <select id="f-cat" name="category" defaultValue={filters.category ?? ""} className="field cursor-pointer !py-[8px]"> | |
| 118 | + <option value="">Toutes</option> | |
| 119 | + {CATEGORIES.map((c) => ( | |
| 120 | + <option key={c.slug} value={c.slug}>{c.short}</option> | |
| 121 | + ))} | |
| 122 | + </select> | |
| 123 | + </div> | |
| 124 | + <div> | |
| 125 | + <label className="klabel mb-[4px] block" htmlFor="f-status">Statut</label> | |
| 126 | + <select id="f-status" name="status" defaultValue={filters.status ?? ""} className="field cursor-pointer !py-[8px]"> | |
| 127 | + <option value="">Tous</option> | |
| 128 | + {Object.entries(statusOptions).map(([k, v]) => ( | |
| 129 | + <option key={k} value={k}>{v}</option> | |
| 130 | + ))} | |
| 131 | + </select> | |
| 132 | + </div> | |
| 133 | + <div> | |
| 134 | + <label className="klabel mb-[4px] block" htmlFor="f-prio">Priorité</label> | |
| 135 | + <select id="f-prio" name="priority" defaultValue={filters.priority ?? ""} className="field cursor-pointer !py-[8px]"> | |
| 136 | + <option value="">Toutes</option> | |
| 137 | + {Object.entries(PRIORITIES).map(([k, v]) => ( | |
| 138 | + <option key={k} value={k}>{v}</option> | |
| 139 | + ))} | |
| 140 | + </select> | |
| 141 | + </div> | |
| 142 | + <div> | |
| 143 | + <label className="klabel mb-[4px] block" htmlFor="f-site">Site concerné</label> | |
| 144 | + <select id="f-site" name="site" defaultValue={filters.site ?? ""} className="field cursor-pointer !py-[8px]"> | |
| 145 | + <option value="">Tous</option> | |
| 146 | + {SITES_KA.map((s) => ( | |
| 147 | + <option key={s} value={s}>{s}</option> | |
| 148 | + ))} | |
| 149 | + </select> | |
| 150 | + </div> | |
| 151 | + <div> | |
| 152 | + <label className="klabel mb-[4px] block" htmlFor="f-ass">Assignée à</label> | |
| 153 | + <select id="f-ass" name="assigned" defaultValue={filters.assigned ?? ""} className="field cursor-pointer !py-[8px]"> | |
| 154 | + <option value="">Peu importe</option> | |
| 155 | + <option value="moi">Moi</option> | |
| 156 | + <option value="personne">Personne</option> | |
| 157 | + {admins.map((a) => ( | |
| 158 | + <option key={a.id} value={String(a.id)}>{a.display_name}</option> | |
| 159 | + ))} | |
| 160 | + </select> | |
| 161 | + </div> | |
| 162 | + <div> | |
| 163 | + <label className="klabel mb-[4px] block" htmlFor="f-view">Vue</label> | |
| 164 | + <select id="f-view" name="view" defaultValue={filters.view} className="field cursor-pointer !py-[8px]"> | |
| 165 | + <option value="actives">Actives</option> | |
| 166 | + <option value="toutes">Toutes</option> | |
| 167 | + <option value="archivees">Archivées</option> | |
| 168 | + <option value="spam">Spam</option> | |
| 169 | + </select> | |
| 170 | + </div> | |
| 171 | + <div className="flex items-end gap-2"> | |
| 172 | + <button type="submit" className="btn btn-primary !min-h-[38px] flex-1 !px-4 !text-[13px]">Filtrer</button> | |
| 173 | + <a href="/admin/inbox" className="btn btn-ghost !min-h-[38px] !px-4 !text-[13px]">Effacer</a> | |
| 174 | + </div> | |
| 175 | + </form> | |
| 176 | + | |
| 177 | + {/* liste */} | |
| 178 | + <div className="gk-card mt-6 overflow-hidden !shadow-none"> | |
| 179 | + <div className="gk-mono hidden grid-cols-[130px_1fr_150px_130px_120px_110px] gap-3 border-b-[1.5px] border-ink bg-surface-2 px-4 py-2 text-[9.5px] font-bold tracking-[0.1em] text-ink-3 uppercase lg:grid"> | |
| 180 | + <span>Référence</span> | |
| 181 | + <span>Demandeur · sujet</span> | |
| 182 | + <span>Site / sous-cat.</span> | |
| 183 | + <span>Priorité · statut</span> | |
| 184 | + <span>Assignée à</span> | |
| 185 | + <span>Date</span> | |
| 186 | + </div> | |
| 187 | + {rows.length === 0 ? ( | |
| 188 | + <p className="gk-mono p-6 text-[12px] text-ink-3">Aucune demande ne correspond à ces filtres.</p> | |
| 189 | + ) : ( | |
| 190 | + rows.map((r) => { | |
| 191 | + const cat = categoryBySlug(r.category); | |
| 192 | + return ( | |
| 193 | + <a | |
| 194 | + key={r.id} | |
| 195 | + href={`/admin/demande/${r.reference}`} | |
| 196 | + className={`adm-row lg:grid lg:grid-cols-[130px_1fr_150px_130px_120px_110px] lg:items-center lg:gap-3 ${r.is_read ? "" : "adm-row--unread"}`} | |
| 197 | + > | |
| 198 | + <span className="gk-mono block text-[11px] font-bold text-green"> | |
| 199 | + {r.reference} | |
| 200 | + {r.ka_id ? <span className="block font-normal text-ink-3">{r.ka_id}</span> : null} | |
| 201 | + </span> | |
| 202 | + <span className="mt-1 block min-w-0 lg:mt-0"> | |
| 203 | + <span className="block truncate text-[13.5px] font-semibold"> | |
| 204 | + {r.is_anonymous | |
| 205 | + ? "Anonyme" | |
| 206 | + : [r.first_name, r.last_name].filter(Boolean).join(" ") || r.email || "—"} | |
| 207 | + {r.organization ? <span className="font-normal text-ink-2"> · {r.organization}</span> : null} | |
| 208 | + </span> | |
| 209 | + <span className="block truncate text-[12px] text-ink-2"> | |
| 210 | + <span className="adm-chip mr-2 align-middle !text-[9px]">{cat?.short ?? r.category}</span> | |
| 211 | + {r.subject ?? r.sub_category ?? (r.message ?? "").slice(0, 80)} | |
| 212 | + </span> | |
| 213 | + </span> | |
| 214 | + <span className="gk-mono mt-1 block truncate text-[10.5px] text-ink-3 lg:mt-0"> | |
| 215 | + {r.site_concerned ?? "—"} | |
| 216 | + {r.sub_category ? <span className="block truncate">{r.sub_category}</span> : null} | |
| 217 | + </span> | |
| 218 | + <span className="mt-1 flex flex-wrap gap-1 lg:mt-0"> | |
| 219 | + <span className={`adm-chip adm-chip--${r.priority} !text-[9px]`}> | |
| 220 | + {PRIORITIES[r.priority as keyof typeof PRIORITIES] ?? r.priority} | |
| 221 | + </span> | |
| 222 | + <span className="adm-chip adm-chip--ghost !text-[9px]"> | |
| 223 | + {statusesFor(r.category)[r.status] ?? r.status} | |
| 224 | + </span> | |
| 225 | + </span> | |
| 226 | + <span className="gk-mono mt-1 block truncate text-[10.5px] text-ink-2 lg:mt-0"> | |
| 227 | + {r.assignee ?? "—"} | |
| 228 | + </span> | |
| 229 | + <span className="gk-mono mt-1 block text-[10.5px] text-ink-3 lg:mt-0"> | |
| 230 | + {fmtDate(r.created_at)} | |
| 231 | + {r.is_read ? null : <span className="ml-2 font-bold text-green">●</span>} | |
| 232 | + </span> | |
| 233 | + </a> | |
| 234 | + ); | |
| 235 | + }) | |
| 236 | + )} | |
| 237 | + </div> | |
| 238 | + | |
| 239 | + {/* pagination */} | |
| 240 | + {pages > 1 ? ( | |
| 241 | + <div className="gk-mono mt-5 flex items-center gap-4 text-[12px] font-bold"> | |
| 242 | + {filters.page! > 1 ? ( | |
| 243 | + <a href={qs({ page: String(filters.page! - 1) })} className="underline underline-offset-4">← Précédente</a> | |
| 244 | + ) : null} | |
| 245 | + <span className="text-ink-3"> | |
| 246 | + Page {filters.page} / {pages} | |
| 247 | + </span> | |
| 248 | + {filters.page! < pages ? ( | |
| 249 | + <a href={qs({ page: String(filters.page! + 1) })} className="underline underline-offset-4">Suivante →</a> | |
| 250 | + ) : null} | |
| 251 | + </div> | |
| 252 | + ) : null} | |
| 253 | + </> | |
| 254 | + ); | |
| 255 | +} | |
added
src/app/admin/(panel)/layout.tsx
+31 −0
@@ -0,0 +1,31 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// Coquille du panel /admin — garde serveur (session ka_admin obligatoire), | |
| 3 | +// navigation latérale encre, badge de non-lus. Tout ce qui est ici est | |
| 4 | +// invisible sans session : la garde redirige AVANT tout rendu. | |
| 5 | +import { redirect } from "next/navigation"; | |
| 6 | +import { getAdminSession } from "@/lib/comms/admin-auth"; | |
| 7 | +import { unreadCount } from "@/lib/comms/queries"; | |
| 8 | +import AdminNav from "./AdminNav"; | |
| 9 | + | |
| 10 | +export const metadata = { | |
| 11 | + robots: { index: false, follow: false }, | |
| 12 | +}; | |
| 13 | + | |
| 14 | +export default async function AdminLayout({ | |
| 15 | + children, | |
| 16 | +}: Readonly<{ children: React.ReactNode }>) { | |
| 17 | + const session = await getAdminSession(); | |
| 18 | + if (!session) redirect("/admin/connexion"); | |
| 19 | + const unread = unreadCount(session); | |
| 20 | + return ( | |
| 21 | + <div data-admin-root className="flex min-h-[100dvh] flex-col lg:flex-row"> | |
| 22 | + <AdminNav | |
| 23 | + displayName={session.displayName} | |
| 24 | + username={session.username} | |
| 25 | + roles={session.roles} | |
| 26 | + initialUnread={unread} | |
| 27 | + /> | |
| 28 | + <main className="min-w-0 flex-1 px-4 py-6 sm:px-8 lg:py-8">{children}</main> | |
| 29 | + </div> | |
| 30 | + ); | |
| 31 | +} | |
added
src/app/admin/(panel)/page.tsx
+116 −0
@@ -0,0 +1,116 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// /admin — dashboard du centre de communication : les chiffres qui comptent | |
| 3 | +// (nouvelles demandes, non-lues, urgentes, investisseurs, candidatures, | |
| 4 | +// légal, sécurité, résolues, délai de première réponse) + demandes récentes. | |
| 5 | +// Chaque chiffre respecte le RBAC de l'admin connecté. | |
| 6 | +import { redirect } from "next/navigation"; | |
| 7 | +import { getAdminSession } from "@/lib/comms/admin-auth"; | |
| 8 | +import { dashboardStats, recentSubmissions } from "@/lib/comms/queries"; | |
| 9 | +import { categoryBySlug, PRIORITIES, statusesFor } from "@/lib/comms/categories"; | |
| 10 | + | |
| 11 | +export const dynamic = "force-dynamic"; | |
| 12 | + | |
| 13 | +function fmtDate(iso: string): string { | |
| 14 | + return iso.replace("T", " ").slice(0, 16); | |
| 15 | +} | |
| 16 | + | |
| 17 | +export default async function AdminDashboard() { | |
| 18 | + const session = await getAdminSession(); | |
| 19 | + if (!session) redirect("/admin/connexion"); | |
| 20 | + const s = dashboardStats(session); | |
| 21 | + const recent = recentSubmissions(session, 8); | |
| 22 | + | |
| 23 | + const TILES: { label: string; value: string | number; href: string; tone?: "lime" | "danger" }[] = [ | |
| 24 | + { label: "Nouvelles aujourd'hui", value: s.today, href: "/admin/inbox", tone: "lime" }, | |
| 25 | + { label: "Non lues", value: s.unread, href: "/admin/inbox" }, | |
| 26 | + { label: "Ouvertes", value: s.open, href: "/admin/inbox" }, | |
| 27 | + { label: "Urgentes / hautes", value: s.urgent, href: "/admin/inbox?priority=urgente", tone: s.urgent > 0 ? "danger" : undefined }, | |
| 28 | + { label: "Investisseurs ouvertes", value: s.investors, href: "/admin/inbox?category=investisseurs" }, | |
| 29 | + { label: "Candidatures (30 j)", value: s.jobs, href: "/admin/candidatures" }, | |
| 30 | + { label: "Légal & Loi 25 ouvertes", value: s.legal, href: "/admin/inbox?category=legal" }, | |
| 31 | + { label: "Signalements sécurité", value: s.security, href: "/admin/inbox?category=securite" }, | |
| 32 | + { label: "Résolues cette semaine", value: s.resolvedWeek, href: "/admin/inbox?view=toutes" }, | |
| 33 | + { | |
| 34 | + label: "1re réponse (moy. 30 j)", | |
| 35 | + value: s.avgFirstResponseH != null ? `${s.avgFirstResponseH} h` : "—", | |
| 36 | + href: "/admin/inbox", | |
| 37 | + }, | |
| 38 | + ]; | |
| 39 | + | |
| 40 | + return ( | |
| 41 | + <> | |
| 42 | + <div className="flex flex-wrap items-end justify-between gap-4"> | |
| 43 | + <div> | |
| 44 | + <p className="kicker">Communication Hub</p> | |
| 45 | + <h1 className="gk-display mt-2 text-[clamp(24px,3.5vw,34px)] leading-tight font-bold tracking-[-0.03em]"> | |
| 46 | + Bonjour, {session.displayName.split(" ")[0]}. | |
| 47 | + </h1> | |
| 48 | + </div> | |
| 49 | + <a href="/admin/inbox" className="btn btn-primary"> | |
| 50 | + Ouvrir l'inbox {s.unread > 0 ? `(${s.unread} non lues)` : ""} | |
| 51 | + </a> | |
| 52 | + </div> | |
| 53 | + | |
| 54 | + <div className="mt-8 grid grid-cols-2 gap-3 sm:grid-cols-3 xl:grid-cols-5"> | |
| 55 | + {TILES.map((t) => ( | |
| 56 | + <a | |
| 57 | + key={t.label} | |
| 58 | + href={t.href} | |
| 59 | + className={`gk-card gk-card-hover p-4 no-underline ${ | |
| 60 | + t.tone === "lime" ? "!bg-lime-soft" : t.tone === "danger" ? "!bg-[var(--danger-soft)]" : "" | |
| 61 | + }`} | |
| 62 | + > | |
| 63 | + <p className="stat-huge !text-[clamp(26px,3vw,38px)]">{t.value}</p> | |
| 64 | + <p className="klabel mt-2 leading-snug normal-case">{t.label}</p> | |
| 65 | + </a> | |
| 66 | + ))} | |
| 67 | + </div> | |
| 68 | + | |
| 69 | + <div className="sec-head mt-12"> | |
| 70 | + <p className="kicker">Demandes récentes</p> | |
| 71 | + <a href="/admin/inbox" className="sec-index no-underline hover:text-green"> | |
| 72 | + Tout voir → | |
| 73 | + </a> | |
| 74 | + </div> | |
| 75 | + <div className="gk-card mt-5 overflow-hidden !shadow-none"> | |
| 76 | + {recent.length === 0 ? ( | |
| 77 | + <p className="gk-mono p-6 text-[12px] text-ink-3"> | |
| 78 | + Aucune demande pour l'instant — le centre de communication est prêt. | |
| 79 | + </p> | |
| 80 | + ) : ( | |
| 81 | + recent.map((r) => { | |
| 82 | + const cat = categoryBySlug(r.category); | |
| 83 | + return ( | |
| 84 | + <a | |
| 85 | + key={r.id} | |
| 86 | + href={`/admin/demande/${r.reference}`} | |
| 87 | + className={`adm-row ${r.is_read ? "" : "adm-row--unread"}`} | |
| 88 | + > | |
| 89 | + <div className="flex flex-wrap items-center gap-x-3 gap-y-1"> | |
| 90 | + <span className="gk-mono text-[11px] font-bold text-green">{r.reference}</span> | |
| 91 | + <span className="adm-chip">{cat?.short ?? r.category}</span> | |
| 92 | + <span className={`adm-chip adm-chip--${r.priority}`}> | |
| 93 | + {PRIORITIES[r.priority as keyof typeof PRIORITIES] ?? r.priority} | |
| 94 | + </span> | |
| 95 | + <span className="adm-chip adm-chip--ghost"> | |
| 96 | + {statusesFor(r.category)[r.status] ?? r.status} | |
| 97 | + </span> | |
| 98 | + <span className="gk-mono ml-auto text-[10.5px] text-ink-3">{fmtDate(r.created_at)}</span> | |
| 99 | + </div> | |
| 100 | + <p className="mt-1 truncate text-[13.5px]"> | |
| 101 | + <strong> | |
| 102 | + {r.is_anonymous | |
| 103 | + ? "Anonyme" | |
| 104 | + : [r.first_name, r.last_name].filter(Boolean).join(" ") || r.email || "—"} | |
| 105 | + </strong> | |
| 106 | + {r.organization ? ` · ${r.organization}` : ""} | |
| 107 | + {r.subject ? ` — ${r.subject}` : r.sub_category ? ` — ${r.sub_category}` : ""} | |
| 108 | + </p> | |
| 109 | + </a> | |
| 110 | + ); | |
| 111 | + }) | |
| 112 | + )} | |
| 113 | + </div> | |
| 114 | + </> | |
| 115 | + ); | |
| 116 | +} | |
added
src/app/admin/connexion/LoginForm.tsx
+72 −0
@@ -0,0 +1,72 @@ | ||
| 1 | +"use client"; | |
| 2 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 3 | +// Formulaire de connexion du panel — POST /api/admin/login puis /admin. | |
| 4 | +import { useState } from "react"; | |
| 5 | +import { useRouter } from "next/navigation"; | |
| 6 | + | |
| 7 | +export default function LoginForm() { | |
| 8 | + const router = useRouter(); | |
| 9 | + const [username, setUsername] = useState(""); | |
| 10 | + const [password, setPassword] = useState(""); | |
| 11 | + const [error, setError] = useState(""); | |
| 12 | + const [busy, setBusy] = useState(false); | |
| 13 | + | |
| 14 | + async function submit(e: React.FormEvent) { | |
| 15 | + e.preventDefault(); | |
| 16 | + if (busy) return; | |
| 17 | + setBusy(true); | |
| 18 | + setError(""); | |
| 19 | + try { | |
| 20 | + const res = await fetch("/api/admin/login", { | |
| 21 | + method: "POST", | |
| 22 | + headers: { "Content-Type": "application/json" }, | |
| 23 | + body: JSON.stringify({ username, password }), | |
| 24 | + }); | |
| 25 | + const data = (await res.json().catch(() => ({}))) as { error?: string }; | |
| 26 | + if (res.ok) { | |
| 27 | + router.replace("/admin"); | |
| 28 | + router.refresh(); | |
| 29 | + return; | |
| 30 | + } | |
| 31 | + setError(data.error ?? "Connexion impossible."); | |
| 32 | + } catch { | |
| 33 | + setError("Connexion impossible — vérifiez le réseau."); | |
| 34 | + } | |
| 35 | + setBusy(false); | |
| 36 | + } | |
| 37 | + | |
| 38 | + return ( | |
| 39 | + <form onSubmit={submit}> | |
| 40 | + <label className="klabel mb-[6px] block" htmlFor="adm-user"> | |
| 41 | + Identifiant | |
| 42 | + </label> | |
| 43 | + <input | |
| 44 | + id="adm-user" | |
| 45 | + className="field" | |
| 46 | + autoComplete="username" | |
| 47 | + autoCapitalize="none" | |
| 48 | + value={username} | |
| 49 | + onChange={(e) => setUsername(e.target.value)} | |
| 50 | + required | |
| 51 | + /> | |
| 52 | + <label className="klabel mt-4 mb-[6px] block" htmlFor="adm-pass"> | |
| 53 | + Mot de passe | |
| 54 | + </label> | |
| 55 | + <input | |
| 56 | + id="adm-pass" | |
| 57 | + type="password" | |
| 58 | + className="field" | |
| 59 | + autoComplete="current-password" | |
| 60 | + value={password} | |
| 61 | + onChange={(e) => setPassword(e.target.value)} | |
| 62 | + required | |
| 63 | + /> | |
| 64 | + {error ? ( | |
| 65 | + <p className="gk-mono mt-4 text-[11px] font-bold text-danger">{error}</p> | |
| 66 | + ) : null} | |
| 67 | + <button type="submit" className="btn btn-primary mt-6 w-full" disabled={busy}> | |
| 68 | + {busy ? "Connexion…" : "Ouvrir le panel"} | |
| 69 | + </button> | |
| 70 | + </form> | |
| 71 | + ); | |
| 72 | +} | |
added
src/app/admin/connexion/page.tsx
+42 −0
@@ -0,0 +1,42 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// /admin/connexion — porte du panel. Hors du groupe (panel) : pas de garde, | |
| 3 | +// pas de coquille — juste la carte de connexion sur fond encre. | |
| 4 | +import type { Metadata } from "next"; | |
| 5 | +import { redirect } from "next/navigation"; | |
| 6 | +import { getAdminSession } from "@/lib/comms/admin-auth"; | |
| 7 | +import LoginForm from "./LoginForm"; | |
| 8 | + | |
| 9 | +export const metadata: Metadata = { | |
| 10 | + title: "Connexion — administration", | |
| 11 | + robots: { index: false, follow: false }, | |
| 12 | +}; | |
| 13 | + | |
| 14 | +export default async function AdminLoginPage() { | |
| 15 | + if (await getAdminSession()) redirect("/admin"); | |
| 16 | + return ( | |
| 17 | + <main | |
| 18 | + data-admin-root | |
| 19 | + className="flex min-h-[100dvh] items-center justify-center bg-ink px-4" | |
| 20 | + > | |
| 21 | + <div className="w-full max-w-sm"> | |
| 22 | + <p className="gk-display text-center text-[26px] font-bold tracking-[-0.04em] text-paper"> | |
| 23 | + Groupe{" "} | |
| 24 | + <span className="inline-block -rotate-2 rounded-md bg-lime px-[7px] pb-[2px] text-ink"> | |
| 25 | + KA | |
| 26 | + </span> | |
| 27 | + </p> | |
| 28 | + <p className="gk-mono mt-2 text-center text-[10px] font-bold tracking-[0.22em] text-[rgba(245,243,238,0.5)] uppercase"> | |
| 29 | + Centre de communication · Administration | |
| 30 | + </p> | |
| 31 | + <div className="gk-card mt-8 p-7"> | |
| 32 | + <LoginForm /> | |
| 33 | + </div> | |
| 34 | + <p className="gk-mono mt-6 text-center text-[10px] leading-relaxed text-[rgba(245,243,238,0.4)]"> | |
| 35 | + Accès réservé à l'équipe Groupe KA. | |
| 36 | + <br /> | |
| 37 | + Toutes les actions sont journalisées. | |
| 38 | + </p> | |
| 39 | + </div> | |
| 40 | + </main> | |
| 41 | + ); | |
| 42 | +} | |
added
src/app/api/admin/attachments/[id]/route.ts
+47 −0
@@ -0,0 +1,47 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// /api/admin/attachments/[id] — téléchargement d'une pièce jointe. | |
| 3 | +// Les fichiers vivent HORS de /public (data/contact-uploads) : seul un admin | |
| 4 | +// autorisé à voir la demande peut les lire. Nom stocké aléatoire — le nom | |
| 5 | +// d'origine n'est restitué que dans l'en-tête Content-Disposition. | |
| 6 | +import { NextRequest, NextResponse } from "next/server"; | |
| 7 | +import fs from "fs/promises"; | |
| 8 | +import path from "path"; | |
| 9 | +import { findAttachment, findSubmissionById } from "@/lib/comms/db"; | |
| 10 | +import { getAdminSession, canViewSubmission } from "@/lib/comms/admin-auth"; | |
| 11 | + | |
| 12 | +const UPLOAD_DIR = path.join(process.cwd(), "data", "contact-uploads"); | |
| 13 | + | |
| 14 | +export async function GET( | |
| 15 | + _req: NextRequest, | |
| 16 | + { params }: { params: Promise<{ id: string }> }, | |
| 17 | +) { | |
| 18 | + const session = await getAdminSession(); | |
| 19 | + if (!session) | |
| 20 | + return NextResponse.json({ error: "Non autorisé." }, { status: 401 }); | |
| 21 | + const id = Number((await params).id); | |
| 22 | + const att = Number.isInteger(id) ? findAttachment(id) : undefined; | |
| 23 | + const sub = att ? findSubmissionById(att.submission_id) : undefined; | |
| 24 | + if (!att || !sub || !canViewSubmission(session, sub)) | |
| 25 | + return NextResponse.json({ error: "Fichier introuvable." }, { status: 404 }); | |
| 26 | + | |
| 27 | + // stored_name est produit par nous ("2026/KA-…-abcd.pdf") — on neutralise | |
| 28 | + // quand même toute traversée de chemin. | |
| 29 | + const safe = path.normalize(att.stored_name).replace(/^(\.\.[/\\])+/, ""); | |
| 30 | + const full = path.join(UPLOAD_DIR, safe); | |
| 31 | + if (!full.startsWith(UPLOAD_DIR)) | |
| 32 | + return NextResponse.json({ error: "Chemin refusé." }, { status: 400 }); | |
| 33 | + let buf: Buffer; | |
| 34 | + try { | |
| 35 | + buf = await fs.readFile(full); | |
| 36 | + } catch { | |
| 37 | + return NextResponse.json({ error: "Fichier manquant sur le disque." }, { status: 404 }); | |
| 38 | + } | |
| 39 | + const filename = att.original_name.replace(/["\r\n]/g, ""); | |
| 40 | + return new NextResponse(new Uint8Array(buf), { | |
| 41 | + headers: { | |
| 42 | + "Content-Type": att.mime, | |
| 43 | + "Content-Disposition": `attachment; filename="${filename}"`, | |
| 44 | + "Cache-Control": "private, no-store", | |
| 45 | + }, | |
| 46 | + }); | |
| 47 | +} | |
added
src/app/api/admin/badge/route.ts
+13 −0
@@ -0,0 +1,13 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// /api/admin/badge — compteur de demandes non lues (badge de la nav du | |
| 3 | +// panel, rafraîchi par sondage léger côté client). | |
| 4 | +import { NextResponse } from "next/server"; | |
| 5 | +import { getAdminSession } from "@/lib/comms/admin-auth"; | |
| 6 | +import { unreadCount } from "@/lib/comms/queries"; | |
| 7 | + | |
| 8 | +export async function GET() { | |
| 9 | + const session = await getAdminSession(); | |
| 10 | + if (!session) | |
| 11 | + return NextResponse.json({ error: "Non autorisé." }, { status: 401 }); | |
| 12 | + return NextResponse.json({ unread: unreadCount(session) }); | |
| 13 | +} | |
added
src/app/api/admin/login/route.ts
+47 −0
@@ -0,0 +1,47 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// /api/admin/login — ouverture de session du panel (cookie ka_admin, 12 h). | |
| 3 | +// Délai constant sur échec (anti-énumération) + fenêtre glissante par IP. | |
| 4 | +import { NextRequest, NextResponse } from "next/server"; | |
| 5 | +import { cookies } from "next/headers"; | |
| 6 | +import { | |
| 7 | + findAdminByUsername, | |
| 8 | + verifyAdminPassword, | |
| 9 | + touchAdminLogin, | |
| 10 | +} from "@/lib/comms/db"; | |
| 11 | +import { | |
| 12 | + mintAdminToken, | |
| 13 | + adminCookieOptions, | |
| 14 | + ADMIN_COOKIE, | |
| 15 | +} from "@/lib/comms/admin-auth"; | |
| 16 | +import { allowSubmission, ipFingerprint } from "@/lib/comms/rate-limit"; | |
| 17 | + | |
| 18 | +export async function POST(req: NextRequest) { | |
| 19 | + if (!allowSubmission("login:" + ipFingerprint(req))) | |
| 20 | + return NextResponse.json( | |
| 21 | + { error: "Trop de tentatives — réessayez dans quelques minutes." }, | |
| 22 | + { status: 429 }, | |
| 23 | + ); | |
| 24 | + const body = (await req.json().catch(() => null)) as { | |
| 25 | + username?: string; | |
| 26 | + password?: string; | |
| 27 | + } | null; | |
| 28 | + const username = (body?.username ?? "").trim().toLowerCase().slice(0, 80); | |
| 29 | + const password = (body?.password ?? "").slice(0, 200); | |
| 30 | + const fail = async () => { | |
| 31 | + await new Promise((r) => setTimeout(r, 400)); | |
| 32 | + return NextResponse.json( | |
| 33 | + { error: "Identifiant ou mot de passe invalide." }, | |
| 34 | + { status: 401 }, | |
| 35 | + ); | |
| 36 | + }; | |
| 37 | + if (!username || !password) return fail(); | |
| 38 | + const admin = findAdminByUsername(username); | |
| 39 | + if (!admin || !admin.active) return fail(); | |
| 40 | + if (!verifyAdminPassword(password, admin.password_hash)) return fail(); | |
| 41 | + | |
| 42 | + touchAdminLogin(admin.id); | |
| 43 | + const token = await mintAdminToken(admin); | |
| 44 | + const jar = await cookies(); | |
| 45 | + jar.set(ADMIN_COOKIE, token, adminCookieOptions); | |
| 46 | + return NextResponse.json({ ok: true }); | |
| 47 | +} | |
added
src/app/api/admin/logout/route.ts
+11 −0
@@ -0,0 +1,11 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// /api/admin/logout — fermeture de session du panel. | |
| 3 | +import { NextResponse } from "next/server"; | |
| 4 | +import { cookies } from "next/headers"; | |
| 5 | +import { ADMIN_COOKIE } from "@/lib/comms/admin-auth"; | |
| 6 | + | |
| 7 | +export async function POST() { | |
| 8 | + const jar = await cookies(); | |
| 9 | + jar.delete(ADMIN_COOKIE); | |
| 10 | + return NextResponse.json({ ok: true }); | |
| 11 | +} | |
added
src/app/api/admin/password/route.ts
+44 −0
@@ -0,0 +1,44 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// /api/admin/password — changement de SON propre mot de passe (tout admin). | |
| 3 | +// Exige le mot de passe actuel ; invalide toutes les autres sessions puis | |
| 4 | +// réémet un cookie frais pour celle-ci. | |
| 5 | +import { NextRequest, NextResponse } from "next/server"; | |
| 6 | +import { cookies } from "next/headers"; | |
| 7 | +import { | |
| 8 | + findAdminById, | |
| 9 | + setAdminPassword, | |
| 10 | + verifyAdminPassword, | |
| 11 | +} from "@/lib/comms/db"; | |
| 12 | +import { | |
| 13 | + getAdminSession, | |
| 14 | + mintAdminToken, | |
| 15 | + adminCookieOptions, | |
| 16 | + ADMIN_COOKIE, | |
| 17 | +} from "@/lib/comms/admin-auth"; | |
| 18 | + | |
| 19 | +export async function POST(req: NextRequest) { | |
| 20 | + const session = await getAdminSession(); | |
| 21 | + if (!session) | |
| 22 | + return NextResponse.json({ error: "Non autorisé." }, { status: 401 }); | |
| 23 | + const body = (await req.json().catch(() => null)) as { | |
| 24 | + current?: string; | |
| 25 | + next?: string; | |
| 26 | + } | null; | |
| 27 | + const current = body?.current ?? ""; | |
| 28 | + const next = body?.next ?? ""; | |
| 29 | + const row = findAdminById(session.id); | |
| 30 | + if (!row || !verifyAdminPassword(current, row.password_hash)) | |
| 31 | + return NextResponse.json({ error: "Mot de passe actuel invalide." }, { status: 403 }); | |
| 32 | + if (next.length < 8) | |
| 33 | + return NextResponse.json( | |
| 34 | + { error: "Nouveau mot de passe trop court (8 caractères minimum)." }, | |
| 35 | + { status: 422 }, | |
| 36 | + ); | |
| 37 | + setAdminPassword(session.id, next); | |
| 38 | + // Réémettre un jeton valide pour la session courante. | |
| 39 | + const fresh = findAdminById(session.id)!; | |
| 40 | + const token = await mintAdminToken(fresh); | |
| 41 | + const jar = await cookies(); | |
| 42 | + jar.set(ADMIN_COOKIE, token, adminCookieOptions); | |
| 43 | + return NextResponse.json({ ok: true }); | |
| 44 | +} | |
added
src/app/api/admin/submissions/[id]/notes/route.ts
+27 −0
@@ -0,0 +1,27 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// /api/admin/submissions/[id]/notes — note interne (jamais visible du | |
| 3 | +// demandeur) : auteur + date + demande, journalisée dans l'historique. | |
| 4 | +import { NextRequest, NextResponse } from "next/server"; | |
| 5 | +import { findSubmissionById, insertNote, logEvent } from "@/lib/comms/db"; | |
| 6 | +import { getAdminSession, canViewSubmission } from "@/lib/comms/admin-auth"; | |
| 7 | + | |
| 8 | +export async function POST( | |
| 9 | + req: NextRequest, | |
| 10 | + { params }: { params: Promise<{ id: string }> }, | |
| 11 | +) { | |
| 12 | + const session = await getAdminSession(); | |
| 13 | + if (!session) | |
| 14 | + return NextResponse.json({ error: "Non autorisé." }, { status: 401 }); | |
| 15 | + const id = Number((await params).id); | |
| 16 | + const sub = Number.isInteger(id) ? findSubmissionById(id) : undefined; | |
| 17 | + if (!sub || !canViewSubmission(session, sub)) | |
| 18 | + return NextResponse.json({ error: "Demande introuvable." }, { status: 404 }); | |
| 19 | + | |
| 20 | + const body = (await req.json().catch(() => null)) as { body?: string } | null; | |
| 21 | + const text = (body?.body ?? "").trim().slice(0, 8000); | |
| 22 | + if (!text) | |
| 23 | + return NextResponse.json({ error: "Note vide." }, { status: 422 }); | |
| 24 | + insertNote(sub.id, session.id, text); | |
| 25 | + logEvent(sub.id, session.id, "note", "Note interne ajoutée"); | |
| 26 | + return NextResponse.json({ ok: true }); | |
| 27 | +} | |
added
src/app/api/admin/submissions/[id]/reply/route.ts
+61 −0
@@ -0,0 +1,61 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// /api/admin/submissions/[id]/reply — réponse au demandeur depuis le panel. | |
| 3 | +// Le message est D'ABORD écrit en base (source de vérité), puis envoyé par | |
| 4 | +// courriel (Resend) ; l'échec d'envoi est signalé mais le message reste. | |
| 5 | +// Pose first_response_at (délai de première réponse du dashboard). | |
| 6 | +import { NextRequest, NextResponse } from "next/server"; | |
| 7 | +import { | |
| 8 | + findSubmissionById, | |
| 9 | + insertMessage, | |
| 10 | + logEvent, | |
| 11 | + touchFirstResponse, | |
| 12 | +} from "@/lib/comms/db"; | |
| 13 | +import { getAdminSession, canViewSubmission } from "@/lib/comms/admin-auth"; | |
| 14 | +import { sendAdminReply } from "@/lib/comms/receipt-email"; | |
| 15 | + | |
| 16 | +export async function POST( | |
| 17 | + req: NextRequest, | |
| 18 | + { params }: { params: Promise<{ id: string }> }, | |
| 19 | +) { | |
| 20 | + const session = await getAdminSession(); | |
| 21 | + if (!session) | |
| 22 | + return NextResponse.json({ error: "Non autorisé." }, { status: 401 }); | |
| 23 | + const id = Number((await params).id); | |
| 24 | + const sub = Number.isInteger(id) ? findSubmissionById(id) : undefined; | |
| 25 | + if (!sub || !canViewSubmission(session, sub)) | |
| 26 | + return NextResponse.json({ error: "Demande introuvable." }, { status: 404 }); | |
| 27 | + | |
| 28 | + const body = (await req.json().catch(() => null)) as { body?: string } | null; | |
| 29 | + const text = (body?.body ?? "").trim().slice(0, 12000); | |
| 30 | + if (!text) | |
| 31 | + return NextResponse.json({ error: "Message vide." }, { status: 422 }); | |
| 32 | + if (!sub.email) | |
| 33 | + return NextResponse.json( | |
| 34 | + { error: "Cette demande n'a pas de courriel (soumission anonyme) — utilisez une note interne." }, | |
| 35 | + { status: 422 }, | |
| 36 | + ); | |
| 37 | + | |
| 38 | + insertMessage({ | |
| 39 | + submission_id: sub.id, | |
| 40 | + sender_type: "admin", | |
| 41 | + sender_admin_id: session.id, | |
| 42 | + body: text, | |
| 43 | + channel: "email", | |
| 44 | + }); | |
| 45 | + touchFirstResponse(sub.id); | |
| 46 | + logEvent(sub.id, session.id, "reponse", "Réponse envoyée au demandeur"); | |
| 47 | + | |
| 48 | + let sent = true; | |
| 49 | + try { | |
| 50 | + await sendAdminReply({ | |
| 51 | + to: sub.email, | |
| 52 | + name: [sub.first_name, sub.last_name].filter(Boolean).join(" ") || "bonjour", | |
| 53 | + reference: sub.reference, | |
| 54 | + body: text, | |
| 55 | + }); | |
| 56 | + } catch (e) { | |
| 57 | + sent = false; | |
| 58 | + console.error("[admin/reply] Resend :", (e as Error).message); | |
| 59 | + } | |
| 60 | + return NextResponse.json({ ok: true, sent }); | |
| 61 | +} | |
added
src/app/api/admin/submissions/[id]/route.ts
+119 −0
@@ -0,0 +1,119 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// /api/admin/submissions/[id] — workflow d'une demande depuis le panel. | |
| 3 | +// PATCH { status | priority | confidentiality | assigned_to | tags | is_read | |
| 4 | +// | is_spam | is_archived } — chaque changement est journalisé dans | |
| 5 | +// l'historique d'activité. Contrôle d'accès PAR demande (anti-IDOR) : l'ID | |
| 6 | +// vient de l'URL mais le droit est revérifié serveur à chaque appel. | |
| 7 | +import { NextRequest, NextResponse } from "next/server"; | |
| 8 | +import { | |
| 9 | + findSubmissionById, | |
| 10 | + updateSubmission, | |
| 11 | + markResolved, | |
| 12 | + logEvent, | |
| 13 | + findAdminById, | |
| 14 | +} from "@/lib/comms/db"; | |
| 15 | +import { | |
| 16 | + getAdminSession, | |
| 17 | + canViewSubmission, | |
| 18 | + isSuperAdmin, | |
| 19 | + CATEGORY_ROLE, | |
| 20 | +} from "@/lib/comms/admin-auth"; | |
| 21 | +import { | |
| 22 | + statusesFor, | |
| 23 | + TERMINAL_STATUSES, | |
| 24 | + PRIORITIES, | |
| 25 | + CONFIDENTIALITY_LEVELS, | |
| 26 | +} from "@/lib/comms/categories"; | |
| 27 | + | |
| 28 | +export async function PATCH( | |
| 29 | + req: NextRequest, | |
| 30 | + { params }: { params: Promise<{ id: string }> }, | |
| 31 | +) { | |
| 32 | + const session = await getAdminSession(); | |
| 33 | + if (!session) | |
| 34 | + return NextResponse.json({ error: "Non autorisé." }, { status: 401 }); | |
| 35 | + const id = Number((await params).id); | |
| 36 | + const sub = Number.isInteger(id) ? findSubmissionById(id) : undefined; | |
| 37 | + if (!sub || !canViewSubmission(session, sub)) | |
| 38 | + // 404 (et non 403) : ne pas révéler l'existence d'une demande interdite. | |
| 39 | + return NextResponse.json({ error: "Demande introuvable." }, { status: 404 }); | |
| 40 | + | |
| 41 | + const body = (await req.json().catch(() => null)) as Record<string, unknown> | null; | |
| 42 | + if (!body) | |
| 43 | + return NextResponse.json({ error: "Requête illisible." }, { status: 400 }); | |
| 44 | + | |
| 45 | + const patch: Record<string, unknown> = {}; | |
| 46 | + const events: [string, string][] = []; | |
| 47 | + | |
| 48 | + if (typeof body.status === "string") { | |
| 49 | + const allowed = statusesFor(sub.category); | |
| 50 | + if (!allowed[body.status]) | |
| 51 | + return NextResponse.json({ error: "Statut invalide." }, { status: 422 }); | |
| 52 | + patch.status = body.status; | |
| 53 | + events.push(["statut", `${allowed[sub.status] ?? sub.status} → ${allowed[body.status]}`]); | |
| 54 | + } | |
| 55 | + if (typeof body.priority === "string") { | |
| 56 | + if (!(body.priority in PRIORITIES)) | |
| 57 | + return NextResponse.json({ error: "Priorité invalide." }, { status: 422 }); | |
| 58 | + patch.priority = body.priority; | |
| 59 | + events.push(["priorite", `${sub.priority} → ${body.priority}`]); | |
| 60 | + } | |
| 61 | + if (typeof body.confidentiality === "string") { | |
| 62 | + if (!(body.confidentiality in CONFIDENTIALITY_LEVELS)) | |
| 63 | + return NextResponse.json({ error: "Niveau invalide." }, { status: 422 }); | |
| 64 | + // Relever/abaisser la confidentialité d'une catégorie sensible : | |
| 65 | + // super_admin ou rôle dédié seulement. | |
| 66 | + const dedicated = CATEGORY_ROLE[sub.category]; | |
| 67 | + if ( | |
| 68 | + body.confidentiality === "restreinte" && | |
| 69 | + !isSuperAdmin(session) && | |
| 70 | + !(dedicated && session.roles.includes(dedicated)) | |
| 71 | + ) | |
| 72 | + return NextResponse.json( | |
| 73 | + { error: "Seul le rôle dédié peut restreindre cette demande." }, | |
| 74 | + { status: 403 }, | |
| 75 | + ); | |
| 76 | + patch.confidentiality = body.confidentiality; | |
| 77 | + events.push(["confidentialite", `${sub.confidentiality} → ${body.confidentiality}`]); | |
| 78 | + } | |
| 79 | + if (body.assigned_to !== undefined) { | |
| 80 | + if (body.assigned_to === null) { | |
| 81 | + patch.assigned_to = null; | |
| 82 | + events.push(["assignation", "Demande désassignée"]); | |
| 83 | + } else { | |
| 84 | + const target = findAdminById(Number(body.assigned_to)); | |
| 85 | + if (!target || !target.active) | |
| 86 | + return NextResponse.json({ error: "Admin inconnu." }, { status: 422 }); | |
| 87 | + patch.assigned_to = target.id; | |
| 88 | + events.push(["assignation", `Assignée à ${target.display_name}`]); | |
| 89 | + } | |
| 90 | + } | |
| 91 | + if (Array.isArray(body.tags)) { | |
| 92 | + const tags = body.tags | |
| 93 | + .filter((t): t is string => typeof t === "string") | |
| 94 | + .map((t) => t.trim().slice(0, 40)) | |
| 95 | + .filter(Boolean) | |
| 96 | + .slice(0, 12); | |
| 97 | + patch.tags = JSON.stringify(tags); | |
| 98 | + events.push(["tags", tags.join(", ") || "(aucun)"]); | |
| 99 | + } | |
| 100 | + for (const flag of ["is_read", "is_spam", "is_archived"] as const) { | |
| 101 | + if (typeof body[flag] === "boolean") { | |
| 102 | + patch[flag] = body[flag] ? 1 : 0; | |
| 103 | + if (flag !== "is_read") | |
| 104 | + events.push([ | |
| 105 | + flag === "is_spam" ? "spam" : "archive", | |
| 106 | + body[flag] ? "activé" : "retiré", | |
| 107 | + ]); | |
| 108 | + } | |
| 109 | + } | |
| 110 | + | |
| 111 | + if (!Object.keys(patch).length) | |
| 112 | + return NextResponse.json({ error: "Rien à modifier." }, { status: 400 }); | |
| 113 | + | |
| 114 | + updateSubmission(sub.id, patch); | |
| 115 | + if (typeof patch.status === "string") | |
| 116 | + markResolved(sub.id, TERMINAL_STATUSES.has(patch.status)); | |
| 117 | + for (const [kind, detail] of events) logEvent(sub.id, session.id, kind, detail); | |
| 118 | + return NextResponse.json({ ok: true }); | |
| 119 | +} | |
added
src/app/api/admin/team/[id]/route.ts
+104 −0
@@ -0,0 +1,104 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// /api/admin/team/[id] — gestion d'un compte admin (super_admin seulement) : | |
| 3 | +// rôles, activation, nom, courriel, réinitialisation du mot de passe. | |
| 4 | +// Garde-fous : impossible de se désactiver soi-même ou de retirer le dernier | |
| 5 | +// super_admin actif. | |
| 6 | +import { NextRequest, NextResponse } from "next/server"; | |
| 7 | +import { | |
| 8 | + findAdminById, | |
| 9 | + listAdmins, | |
| 10 | + setAdminPassword, | |
| 11 | + updateAdmin, | |
| 12 | +} from "@/lib/comms/db"; | |
| 13 | +import { | |
| 14 | + getAdminSession, | |
| 15 | + isSuperAdmin, | |
| 16 | + parseRoles, | |
| 17 | + ADMIN_ROLES, | |
| 18 | +} from "@/lib/comms/admin-auth"; | |
| 19 | + | |
| 20 | +function lastActiveSuperAdmin(targetId: number): boolean { | |
| 21 | + const remaining = listAdmins().filter( | |
| 22 | + (a) => a.id !== targetId && a.active && parseRoles(a.roles).includes("super_admin"), | |
| 23 | + ); | |
| 24 | + return remaining.length === 0; | |
| 25 | +} | |
| 26 | + | |
| 27 | +export async function PATCH( | |
| 28 | + req: NextRequest, | |
| 29 | + { params }: { params: Promise<{ id: string }> }, | |
| 30 | +) { | |
| 31 | + const session = await getAdminSession(); | |
| 32 | + if (!session) | |
| 33 | + return NextResponse.json({ error: "Non autorisé." }, { status: 401 }); | |
| 34 | + if (!isSuperAdmin(session)) | |
| 35 | + return NextResponse.json({ error: "Réservé au super administrateur." }, { status: 403 }); | |
| 36 | + const id = Number((await params).id); | |
| 37 | + const target = Number.isInteger(id) ? findAdminById(id) : undefined; | |
| 38 | + if (!target) | |
| 39 | + return NextResponse.json({ error: "Compte introuvable." }, { status: 404 }); | |
| 40 | + | |
| 41 | + const body = (await req.json().catch(() => null)) as { | |
| 42 | + display_name?: string; | |
| 43 | + email?: string | null; | |
| 44 | + roles?: string[]; | |
| 45 | + active?: boolean; | |
| 46 | + password?: string; | |
| 47 | + } | null; | |
| 48 | + if (!body) | |
| 49 | + return NextResponse.json({ error: "Requête illisible." }, { status: 400 }); | |
| 50 | + | |
| 51 | + if (body.roles !== undefined) { | |
| 52 | + const roles = body.roles.filter((r) => r in ADMIN_ROLES); | |
| 53 | + if (!roles.length) | |
| 54 | + return NextResponse.json({ error: "Au moins un rôle requis." }, { status: 422 }); | |
| 55 | + if ( | |
| 56 | + parseRoles(target.roles).includes("super_admin") && | |
| 57 | + !roles.includes("super_admin") && | |
| 58 | + lastActiveSuperAdmin(target.id) | |
| 59 | + ) | |
| 60 | + return NextResponse.json( | |
| 61 | + { error: "Impossible de retirer le dernier super administrateur." }, | |
| 62 | + { status: 422 }, | |
| 63 | + ); | |
| 64 | + updateAdmin(target.id, { roles }); | |
| 65 | + } | |
| 66 | + if (body.active !== undefined) { | |
| 67 | + if (target.id === session.id && !body.active) | |
| 68 | + return NextResponse.json( | |
| 69 | + { error: "Impossible de désactiver votre propre compte." }, | |
| 70 | + { status: 422 }, | |
| 71 | + ); | |
| 72 | + if ( | |
| 73 | + !body.active && | |
| 74 | + parseRoles(target.roles).includes("super_admin") && | |
| 75 | + lastActiveSuperAdmin(target.id) | |
| 76 | + ) | |
| 77 | + return NextResponse.json( | |
| 78 | + { error: "Impossible de désactiver le dernier super administrateur." }, | |
| 79 | + { status: 422 }, | |
| 80 | + ); | |
| 81 | + updateAdmin(target.id, { active: body.active }); | |
| 82 | + } | |
| 83 | + if (body.display_name !== undefined) { | |
| 84 | + const dn = body.display_name.trim().slice(0, 120); | |
| 85 | + if (!dn) | |
| 86 | + return NextResponse.json({ error: "Nom d'affichage requis." }, { status: 422 }); | |
| 87 | + updateAdmin(target.id, { display_name: dn }); | |
| 88 | + } | |
| 89 | + if (body.email !== undefined) | |
| 90 | + updateAdmin(target.id, { | |
| 91 | + email: body.email ? body.email.trim().slice(0, 200) : null, | |
| 92 | + }); | |
| 93 | + if (body.password !== undefined) { | |
| 94 | + if (body.password.length < 8) | |
| 95 | + return NextResponse.json( | |
| 96 | + { error: "Mot de passe trop court (8 caractères minimum)." }, | |
| 97 | + { status: 422 }, | |
| 98 | + ); | |
| 99 | + // Change le mot de passe ET invalide toutes les sessions du compte | |
| 100 | + // (l'empreinte pwv du jeton ne correspond plus). | |
| 101 | + setAdminPassword(target.id, body.password); | |
| 102 | + } | |
| 103 | + return NextResponse.json({ ok: true }); | |
| 104 | +} | |
added
src/app/api/admin/team/route.ts
+51 −0
@@ -0,0 +1,51 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// /api/admin/team — création d'un compte admin (super_admin seulement). | |
| 3 | +import { NextRequest, NextResponse } from "next/server"; | |
| 4 | +import { createAdmin, findAdminByUsername } from "@/lib/comms/db"; | |
| 5 | +import { getAdminSession, isSuperAdmin, ADMIN_ROLES } from "@/lib/comms/admin-auth"; | |
| 6 | + | |
| 7 | +export async function POST(req: NextRequest) { | |
| 8 | + const session = await getAdminSession(); | |
| 9 | + if (!session) | |
| 10 | + return NextResponse.json({ error: "Non autorisé." }, { status: 401 }); | |
| 11 | + if (!isSuperAdmin(session)) | |
| 12 | + return NextResponse.json({ error: "Réservé au super administrateur." }, { status: 403 }); | |
| 13 | + | |
| 14 | + const body = (await req.json().catch(() => null)) as { | |
| 15 | + username?: string; | |
| 16 | + display_name?: string; | |
| 17 | + email?: string; | |
| 18 | + password?: string; | |
| 19 | + roles?: string[]; | |
| 20 | + } | null; | |
| 21 | + const username = (body?.username ?? "").trim().toLowerCase().slice(0, 40); | |
| 22 | + const displayName = (body?.display_name ?? "").trim().slice(0, 120); | |
| 23 | + const password = body?.password ?? ""; | |
| 24 | + const roles = (body?.roles ?? []).filter((r) => r in ADMIN_ROLES); | |
| 25 | + | |
| 26 | + if (!/^[a-z0-9._-]{3,40}$/.test(username)) | |
| 27 | + return NextResponse.json( | |
| 28 | + { error: "Identifiant invalide (3-40 caractères : a-z, 0-9, . _ -)." }, | |
| 29 | + { status: 422 }, | |
| 30 | + ); | |
| 31 | + if (!displayName) | |
| 32 | + return NextResponse.json({ error: "Nom d'affichage requis." }, { status: 422 }); | |
| 33 | + if (password.length < 8) | |
| 34 | + return NextResponse.json( | |
| 35 | + { error: "Mot de passe trop court (8 caractères minimum)." }, | |
| 36 | + { status: 422 }, | |
| 37 | + ); | |
| 38 | + if (!roles.length) | |
| 39 | + return NextResponse.json({ error: "Au moins un rôle requis." }, { status: 422 }); | |
| 40 | + if (findAdminByUsername(username)) | |
| 41 | + return NextResponse.json({ error: "Cet identifiant existe déjà." }, { status: 409 }); | |
| 42 | + | |
| 43 | + const id = createAdmin({ | |
| 44 | + username, | |
| 45 | + display_name: displayName, | |
| 46 | + email: (body?.email ?? "").trim().slice(0, 200) || null, | |
| 47 | + password, | |
| 48 | + roles, | |
| 49 | + }); | |
| 50 | + return NextResponse.json({ ok: true, id }); | |
| 51 | +} | |
added
src/app/api/contact/route.ts
+247 −0
@@ -0,0 +1,247 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// KA Communication Hub — réception des soumissions publiques (/contact/*). | |
| 3 | +// POST multipart/form-data : { category, _hp (honeypot), _ts (horodatage), | |
| 4 | +// …champs du registre, fichiers }. Toute la validation est SERVEUR (le | |
| 5 | +// registre src/lib/comms/categories.ts fait foi) ; le frontend n'est jamais | |
| 6 | +// cru — ni pour la priorité, ni pour l'identité KA ID, ni pour les fichiers. | |
| 7 | +import { NextRequest, NextResponse } from "next/server"; | |
| 8 | +import fs from "fs/promises"; | |
| 9 | +import path from "path"; | |
| 10 | +import crypto from "crypto"; | |
| 11 | +import { getSessionUser } from "@/lib/auth"; | |
| 12 | +import { | |
| 13 | + categoryBySlug, | |
| 14 | + computePriority, | |
| 15 | + CORE_FIELDS, | |
| 16 | + type FieldDef, | |
| 17 | +} from "@/lib/comms/categories"; | |
| 18 | +import { | |
| 19 | + insertSubmission, | |
| 20 | + insertAttachment, | |
| 21 | + logEvent, | |
| 22 | + nextReference, | |
| 23 | +} from "@/lib/comms/db"; | |
| 24 | +import { allowSubmission, ipFingerprint, looksLikeSpam } from "@/lib/comms/rate-limit"; | |
| 25 | +import { sendReceipt } from "@/lib/comms/receipt-email"; | |
| 26 | + | |
| 27 | +const UPLOAD_DIR = path.join(process.cwd(), "data", "contact-uploads"); | |
| 28 | +const MAX_FILE_BYTES = 10 * 1024 * 1024; | |
| 29 | +const MAX_FILES = 3; | |
| 30 | + | |
| 31 | +// Liste blanche stricte des types de fichiers (extension ET MIME). | |
| 32 | +const ALLOWED_FILES: Record<string, string[]> = { | |
| 33 | + ".pdf": ["application/pdf"], | |
| 34 | + ".doc": ["application/msword"], | |
| 35 | + ".docx": ["application/vnd.openxmlformats-officedocument.wordprocessingml.document"], | |
| 36 | + ".png": ["image/png"], | |
| 37 | + ".jpg": ["image/jpeg"], | |
| 38 | + ".jpeg": ["image/jpeg"], | |
| 39 | + ".webp": ["image/webp"], | |
| 40 | + ".txt": ["text/plain"], | |
| 41 | +}; | |
| 42 | + | |
| 43 | +function clean(v: FormDataEntryValue | null, maxLen = 500): string | null { | |
| 44 | + if (typeof v !== "string") return null; | |
| 45 | + // sanitation : caractères de contrôle retirés (le \\n des textarea survit), | |
| 46 | + // longueur bornée | |
| 47 | + const s = v.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "").trim(); | |
| 48 | + return s ? s.slice(0, maxLen) : null; | |
| 49 | +} | |
| 50 | + | |
| 51 | +function isEmail(s: string): boolean { | |
| 52 | + return /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(s) && s.length <= 200; | |
| 53 | +} | |
| 54 | + | |
| 55 | +function isUrl(s: string): boolean { | |
| 56 | + try { | |
| 57 | + const u = new URL(s.startsWith("http") ? s : `https://${s}`); | |
| 58 | + return u.protocol === "https:" || u.protocol === "http:"; | |
| 59 | + } catch { | |
| 60 | + return false; | |
| 61 | + } | |
| 62 | +} | |
| 63 | + | |
| 64 | +/** Le champ est-il actif compte tenu des conditions showIf/showIfUnchecked ? */ | |
| 65 | +function fieldActive(f: FieldDef, values: Record<string, string | null>): boolean { | |
| 66 | + if (f.showIf && values[f.showIf.field] !== f.showIf.equals) return false; | |
| 67 | + if (f.showIfUnchecked && values[f.showIfUnchecked] === "on") return false; | |
| 68 | + return true; | |
| 69 | +} | |
| 70 | + | |
| 71 | +export async function POST(req: NextRequest) { | |
| 72 | + const fd = await req.formData().catch(() => null); | |
| 73 | + if (!fd) | |
| 74 | + return NextResponse.json({ error: "Requête illisible." }, { status: 400 }); | |
| 75 | + | |
| 76 | + const cat = categoryBySlug(clean(fd.get("category"), 40) ?? ""); | |
| 77 | + if (!cat) | |
| 78 | + return NextResponse.json({ error: "Catégorie inconnue." }, { status: 400 }); | |
| 79 | + | |
| 80 | + // Anti-abus : honeypot + temps de remplissage + fenêtre glissante par IP | |
| 81 | + // (empreinte salée, en mémoire seulement — jamais d'IP en base). | |
| 82 | + const spammy = looksLikeSpam( | |
| 83 | + clean(fd.get("_hp"), 200), | |
| 84 | + typeof fd.get("_ts") === "string" ? (fd.get("_ts") as string) : null, | |
| 85 | + ); | |
| 86 | + if (!allowSubmission(ipFingerprint(req))) | |
| 87 | + return NextResponse.json( | |
| 88 | + { error: "Trop de soumissions rapprochées — réessayez dans quelques minutes." }, | |
| 89 | + { status: 429 }, | |
| 90 | + ); | |
| 91 | + | |
| 92 | + /* ---------- validation champ par champ selon le registre ---------- */ | |
| 93 | + const values: Record<string, string | null> = {}; | |
| 94 | + for (const f of cat.fields) { | |
| 95 | + if (f.type === "file") continue; | |
| 96 | + if (f.type === "checkbox") { | |
| 97 | + values[f.name] = fd.get(f.name) === "on" ? "on" : null; | |
| 98 | + continue; | |
| 99 | + } | |
| 100 | + values[f.name] = clean(fd.get(f.name), f.maxLen ?? 500); | |
| 101 | + } | |
| 102 | + | |
| 103 | + const errors: Record<string, string> = {}; | |
| 104 | + for (const f of cat.fields) { | |
| 105 | + if (f.type === "file") continue; | |
| 106 | + if (!fieldActive(f, values)) { | |
| 107 | + values[f.name] = null; // champ inactif : on ne garde rien | |
| 108 | + continue; | |
| 109 | + } | |
| 110 | + const v = values[f.name]; | |
| 111 | + if (f.required && !v) { | |
| 112 | + errors[f.name] = "Ce champ est requis."; | |
| 113 | + continue; | |
| 114 | + } | |
| 115 | + if (!v) continue; | |
| 116 | + if (f.type === "email" && !isEmail(v)) errors[f.name] = "Courriel invalide."; | |
| 117 | + if (f.type === "url" && !isUrl(v)) errors[f.name] = "Adresse web invalide."; | |
| 118 | + if (f.type === "select" && f.options && !f.options.includes(v)) | |
| 119 | + errors[f.name] = "Valeur non reconnue."; | |
| 120 | + if (f.type === "date" && !/^\d{4}-\d{2}-\d{2}$/.test(v)) | |
| 121 | + errors[f.name] = "Date invalide."; | |
| 122 | + } | |
| 123 | + if (Object.keys(errors).length) | |
| 124 | + return NextResponse.json({ error: "Formulaire incomplet.", fields: errors }, { status: 422 }); | |
| 125 | + | |
| 126 | + /* ---------- fichiers ---------- */ | |
| 127 | + type Staged = { field: string; name: string; mime: string; buf: Buffer }; | |
| 128 | + const staged: Staged[] = []; | |
| 129 | + for (const f of cat.fields) { | |
| 130 | + if (f.type !== "file" || !fieldActive(f, values)) continue; | |
| 131 | + const file = fd.get(f.name); | |
| 132 | + const present = file instanceof File && file.size > 0; | |
| 133 | + if (f.required && !present) | |
| 134 | + return NextResponse.json( | |
| 135 | + { error: "Formulaire incomplet.", fields: { [f.name]: "Fichier requis." } }, | |
| 136 | + { status: 422 }, | |
| 137 | + ); | |
| 138 | + if (!present) continue; | |
| 139 | + if (staged.length >= MAX_FILES) | |
| 140 | + return NextResponse.json({ error: "Trop de fichiers (3 max)." }, { status: 422 }); | |
| 141 | + if (file.size > MAX_FILE_BYTES) | |
| 142 | + return NextResponse.json( | |
| 143 | + { error: "Fichier trop lourd — 10 Mo maximum.", fields: { [f.name]: "10 Mo maximum." } }, | |
| 144 | + { status: 413 }, | |
| 145 | + ); | |
| 146 | + const ext = path.extname(file.name || "").toLowerCase(); | |
| 147 | + const mimes = ALLOWED_FILES[ext]; | |
| 148 | + if (!mimes || !mimes.includes(file.type)) | |
| 149 | + return NextResponse.json( | |
| 150 | + { error: "Format refusé.", fields: { [f.name]: "Formats acceptés : PDF, Word, image." } }, | |
| 151 | + { status: 415 }, | |
| 152 | + ); | |
| 153 | + staged.push({ | |
| 154 | + field: f.name, | |
| 155 | + name: (file.name || `fichier${ext}`).slice(0, 200), | |
| 156 | + mime: file.type, | |
| 157 | + buf: Buffer.from(await file.arrayBuffer()), | |
| 158 | + }); | |
| 159 | + } | |
| 160 | + | |
| 161 | + /* ---------- identité : session KA ID côté serveur uniquement ---------- */ | |
| 162 | + const user = await getSessionUser().catch(() => null); | |
| 163 | + const isAnonymous = values.is_anonymous === "on"; | |
| 164 | + | |
| 165 | + /* ---------- colonnes cœur vs metadata ---------- */ | |
| 166 | + const core: Record<string, string | null> = {}; | |
| 167 | + const metadata: Record<string, unknown> = {}; | |
| 168 | + for (const f of cat.fields) { | |
| 169 | + if (f.type === "file") continue; | |
| 170 | + const v = values[f.name]; | |
| 171 | + if (f.name === "is_anonymous") continue; | |
| 172 | + if (CORE_FIELDS.has(f.name)) core[f.name] = v; | |
| 173 | + else if (v != null) metadata[f.name] = f.type === "checkbox" ? true : v; | |
| 174 | + } | |
| 175 | + // fournisseurs/carrières : « Nom » simple saisi dans first_name — OK tel quel. | |
| 176 | + | |
| 177 | + const subCategory = cat.subCategoryFrom | |
| 178 | + ? (values[cat.subCategoryFrom] ?? null) | |
| 179 | + : null; | |
| 180 | + const priority = spammy | |
| 181 | + ? "basse" | |
| 182 | + : computePriority(cat.slug, subCategory, metadata); | |
| 183 | + | |
| 184 | + const reference = nextReference(cat.prefix); | |
| 185 | + const id = insertSubmission({ | |
| 186 | + reference, | |
| 187 | + category: cat.slug, | |
| 188 | + sub_category: subCategory, | |
| 189 | + first_name: core.first_name ?? null, | |
| 190 | + last_name: core.last_name ?? null, | |
| 191 | + email: isAnonymous ? null : (core.email ?? null), | |
| 192 | + phone: core.phone ?? null, | |
| 193 | + organization: core.organization ?? null, | |
| 194 | + job_title: core.job_title ?? null, | |
| 195 | + website: core.website ?? null, | |
| 196 | + linkedin_url: core.linkedin_url ?? null, | |
| 197 | + github_url: core.github_url ?? null, | |
| 198 | + site_concerned: core.site_concerned ?? null, | |
| 199 | + subject: core.subject ?? null, | |
| 200 | + message: core.message ?? null, | |
| 201 | + metadata: Object.keys(metadata).length ? metadata : null, | |
| 202 | + priority, | |
| 203 | + confidentiality: cat.confidentiality, | |
| 204 | + user_id: isAnonymous ? null : (user?.id ?? null), | |
| 205 | + ka_id: isAnonymous ? null : (user?.kaId ?? null), | |
| 206 | + is_anonymous: isAnonymous, | |
| 207 | + }); | |
| 208 | + if (spammy) { | |
| 209 | + // On enregistre quand même (faux positifs possibles) mais marqué spam. | |
| 210 | + const { commsDb } = await import("@/lib/comms/db"); | |
| 211 | + commsDb | |
| 212 | + .prepare("UPDATE contact_submissions SET is_spam = 1 WHERE id = ?") | |
| 213 | + .run(id); | |
| 214 | + } | |
| 215 | + logEvent(id, null, "creation", `Soumission ${cat.slug} reçue (web)`); | |
| 216 | + | |
| 217 | + /* ---------- pièces jointes : nom aléatoire, hors du dossier public ---------- */ | |
| 218 | + if (staged.length) { | |
| 219 | + const year = String(new Date().getFullYear()); | |
| 220 | + const dir = path.join(UPLOAD_DIR, year); | |
| 221 | + await fs.mkdir(dir, { recursive: true }); | |
| 222 | + for (const s of staged) { | |
| 223 | + const stored = `${reference}-${s.field}-${crypto.randomBytes(8).toString("hex")}${path.extname(s.name).toLowerCase()}`; | |
| 224 | + await fs.writeFile(path.join(dir, stored), s.buf); | |
| 225 | + insertAttachment({ | |
| 226 | + submission_id: id, | |
| 227 | + field: s.field, | |
| 228 | + original_name: s.name, | |
| 229 | + stored_name: `${year}/${stored}`, | |
| 230 | + mime: s.mime, | |
| 231 | + size: s.buf.length, | |
| 232 | + }); | |
| 233 | + } | |
| 234 | + } | |
| 235 | + | |
| 236 | + /* ---------- accusé de réception (non bloquant) ---------- */ | |
| 237 | + const to = isAnonymous ? null : (core.email ?? null); | |
| 238 | + if (to && !spammy) { | |
| 239 | + const name = | |
| 240 | + [core.first_name, core.last_name].filter(Boolean).join(" ") || "bonjour"; | |
| 241 | + sendReceipt({ to, name, reference, categoryTitle: cat.title }).catch((e) => | |
| 242 | + console.error("[contact] accusé de réception :", (e as Error).message), | |
| 243 | + ); | |
| 244 | + } | |
| 245 | + | |
| 246 | + return NextResponse.json({ ok: true, reference }); | |
| 247 | +} | |
modified
src/app/api/wallet/pass/route.ts
+1 −1
@@ -104,7 +104,7 @@ export async function GET() { | ||
| 104 | 104 | { |
| 105 | 105 | key: "contact", |
| 106 | 106 | label: "Contact", |
| 107 | − value: "contact@groupe-ka.com", | |
| 107 | + value: "www.groupe-ka.com/contact", | |
| 108 | 108 | }, |
| 109 | 109 | ], |
| 110 | 110 | }, |
modified
src/app/bots/page.tsx
+15 −15
@@ -77,10 +77,10 @@ const PRINCIPES: { title: string; body: React.ReactNode }[] = [ | ||
| 77 | 77 | body: ( |
| 78 | 78 | <> |
| 79 | 79 | Tout exploitant de site ou titulaire de droits peut demander |
| 80 | − l'exclusion de son site ou le retrait d'un contenu en | |
| 81 | − écrivant à{" "} | |
| 82 | − <a className={MAILTO} href="mailto:admin@groupe-ka.com"> | |
| 83 | − admin@groupe-ka.com | |
| 80 | + l'exclusion de son site ou le retrait d'un contenu via | |
| 81 | + le{" "} | |
| 82 | + <a className={MAILTO} href="/contact/legal"> | |
| 83 | + formulaire Légal, vie privée & Loi 25 | |
| 84 | 84 | </a> |
| 85 | 85 | . Les demandes sont traitées dans un délai de 48 à 72 heures |
| 86 | 86 | ouvrables — voir notre{" "} |
@@ -198,9 +198,9 @@ const BOTS_FICHES: BotFiche[] = [ | ||
| 198 | 198 | <> |
| 199 | 199 | toute personne figurant dans les données de ka·6 peut demander la |
| 200 | 200 | consultation, la rectification ou le retrait complet de son profil |
| 201 | − en écrivant à{" "} | |
| 202 | − <a className={MAILTO} href="mailto:admin@groupe-ka.com"> | |
| 203 | − admin@groupe-ka.com | |
| 201 | + via le{" "} | |
| 202 | + <a className={MAILTO} href="/contact/legal"> | |
| 203 | + formulaire Légal, vie privée & Loi 25 | |
| 204 | 204 | </a> |
| 205 | 205 | , sans avoir à justifier sa demande. Le retrait est traité en |
| 206 | 206 | priorité. |
@@ -378,9 +378,9 @@ export default function Bots() { | ||
| 378 | 378 | Établir un partenariat de données |
| 379 | 379 | </strong>{" "} |
| 380 | 380 | (flux officiel plutôt que crawl) : nous sommes ouverts à toute |
| 381 | − entente, écrivez à{" "} | |
| 382 | − <a className={MAILTO} href="mailto:contact@groupe-ka.com"> | |
| 383 | − contact@groupe-ka.com | |
| 381 | + entente, passez par le{" "} | |
| 382 | + <a className={MAILTO} href="/contact/data"> | |
| 383 | + formulaire Données, API & intégrations | |
| 384 | 384 | </a> |
| 385 | 385 | . |
| 386 | 386 | </Bullet> |
@@ -392,13 +392,13 @@ export default function Bots() { | ||
| 392 | 392 | Responsable des robots d'indexation :{" "} |
| 393 | 393 | <strong className="text-ink">Simon-Pierre Boucher</strong> |
| 394 | 394 | <br /> |
| 395 | − Courriel :{" "} | |
| 396 | − <a className={MAILTO} href="mailto:contact@groupe-ka.com"> | |
| 397 | − contact@groupe-ka.com | |
| 395 | + Contact :{" "} | |
| 396 | + <a className={MAILTO} href="/contact/data"> | |
| 397 | + formulaire Données & API | |
| 398 | 398 | </a>{" "} |
| 399 | 399 | · Demandes de retrait :{" "} |
| 400 | − <a className={MAILTO} href="mailto:admin@groupe-ka.com"> | |
| 401 | − admin@groupe-ka.com | |
| 400 | + <a className={MAILTO} href="/contact/legal"> | |
| 401 | + formulaire Légal & vie privée | |
| 402 | 402 | </a> |
| 403 | 403 | </p> |
| 404 | 404 | </Card> |
modified
src/app/compte/page.tsx
+2 −2
@@ -513,8 +513,8 @@ export default async function Compte() { | ||
| 513 | 513 | <a href="/confidentialite" className="btn btn-ghost"> |
| 514 | 514 | Confidentialité |
| 515 | 515 | </a> |
| 516 | − <a href="mailto:admin@groupe-ka.com" className="btn btn-ghost"> | |
| 517 | − Vie privée : admin@groupe-ka.com | |
| 516 | + <a href="/contact/legal" className="btn btn-ghost"> | |
| 517 | + Vie privée : nous joindre | |
| 518 | 518 | </a> |
| 519 | 519 | </div> |
| 520 | 520 | </main> |
modified
src/app/conditions/page.tsx
+6 −6
@@ -101,16 +101,16 @@ const SECTIONS: { num: string; title: string; body: React.ReactNode }[] = [ | ||
| 101 | 101 | body: ( |
| 102 | 102 | <> |
| 103 | 103 | Pour toute question relative aux présentes conditions :{" "} |
| 104 | − <a className="gk-mono font-bold underline underline-offset-4" href="mailto:admin@groupe-ka.com"> | |
| 105 | − admin@groupe-ka.com | |
| 104 | + <a className="gk-mono font-bold underline underline-offset-4" href="/contact/legal"> | |
| 105 | + formulaire Légal, vie privée & Loi 25 | |
| 106 | 106 | </a> |
| 107 | 107 | . Pour les questions générales :{" "} |
| 108 | − <a className="gk-mono font-bold underline underline-offset-4" href="mailto:info@groupe-ka.com"> | |
| 109 | − info@groupe-ka.com | |
| 108 | + <a className="gk-mono font-bold underline underline-offset-4" href="/contact/general"> | |
| 109 | + formulaire Questions générales | |
| 110 | 110 | </a> |
| 111 | 111 | . Pour les projets et partenariats :{" "} |
| 112 | − <a className="gk-mono font-bold underline underline-offset-4" href="mailto:contact@groupe-ka.com"> | |
| 113 | − contact@groupe-ka.com | |
| 112 | + <a className="gk-mono font-bold underline underline-offset-4" href="/contact/partenariats"> | |
| 113 | + formulaire Projets & partenariats | |
| 114 | 114 | </a> |
| 115 | 115 | . |
| 116 | 116 | </> |
modified
src/app/confidentialite/page.tsx
+7 −7
@@ -83,13 +83,13 @@ const SECTIONS: { num: string; title: string; body: React.ReactNode }[] = [ | ||
| 83 | 83 | body: ( |
| 84 | 84 | <> |
| 85 | 85 | Responsable de la protection des renseignements personnels : |
| 86 | − Simon-Pierre Boucher, Groupe KA —{" "} | |
| 87 | − <a className="gk-mono font-bold underline underline-offset-4" href="mailto:admin@groupe-ka.com"> | |
| 88 | − admin@groupe-ka.com | |
| 89 | − </a>{" "} | |
| 90 | − (légal & vie privée). Questions générales :{" "} | |
| 91 | − <a className="gk-mono font-bold underline underline-offset-4" href="mailto:info@groupe-ka.com"> | |
| 92 | − info@groupe-ka.com | |
| 86 | + Simon-Pierre Boucher, Groupe KA — joignable via le{" "} | |
| 87 | + <a className="gk-mono font-bold underline underline-offset-4" href="/contact/legal"> | |
| 88 | + formulaire Légal, vie privée & Loi 25 | |
| 89 | + </a> | |
| 90 | + . Questions générales :{" "} | |
| 91 | + <a className="gk-mono font-bold underline underline-offset-4" href="/contact/general"> | |
| 92 | + formulaire Questions générales | |
| 93 | 93 | </a> |
| 94 | 94 | . Si notre réponse ne vous satisfait pas, vous pouvez saisir la |
| 95 | 95 | Commission d'accès à l'information du Québec (cai.gouv.qc.ca). |
added
src/app/contact/ContactForm.tsx
+261 −0
@@ -0,0 +1,261 @@ | ||
| 1 | +"use client"; | |
| 2 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 3 | +// KA Communication Hub — formulaire public générique, piloté par le registre | |
| 4 | +// (src/lib/comms/categories.ts). Un seul composant pour les 10 catégories : | |
| 5 | +// il rend les champs, gère les conditions showIf, soumet en multipart à | |
| 6 | +// /api/contact et affiche la référence publique (KA-XXX-2026-0000) en retour. | |
| 7 | +// La validation client est une courtoisie — le serveur revalide tout. | |
| 8 | +import { useMemo, useRef, useState } from "react"; | |
| 9 | +import type { FieldDef } from "@/lib/comms/categories"; | |
| 10 | + | |
| 11 | +type Props = { | |
| 12 | + slug: string; | |
| 13 | + fields: FieldDef[]; | |
| 14 | + /** préremplissage KA ID (serveur) : { first_name: "…", email: "…" } */ | |
| 15 | + defaults: Record<string, string>; | |
| 16 | + kaConnected: boolean; | |
| 17 | +}; | |
| 18 | + | |
| 19 | +function fieldVisible(f: FieldDef, values: Record<string, string>): boolean { | |
| 20 | + if (f.showIf && values[f.showIf.field] !== f.showIf.equals) return false; | |
| 21 | + if (f.showIfUnchecked && values[f.showIfUnchecked] === "on") return false; | |
| 22 | + return true; | |
| 23 | +} | |
| 24 | + | |
| 25 | +export default function ContactForm({ slug, fields, defaults, kaConnected }: Props) { | |
| 26 | + const [values, setValues] = useState<Record<string, string>>(() => { | |
| 27 | + const v: Record<string, string> = {}; | |
| 28 | + for (const f of fields) v[f.name] = defaults[f.name] ?? ""; | |
| 29 | + return v; | |
| 30 | + }); | |
| 31 | + const [errors, setErrors] = useState<Record<string, string>>({}); | |
| 32 | + const [state, setState] = useState<"idle" | "sending" | "done">("idle"); | |
| 33 | + const [reference, setReference] = useState(""); | |
| 34 | + const [globalError, setGlobalError] = useState(""); | |
| 35 | + const tsRef = useRef(Date.now()); | |
| 36 | + const formRef = useRef<HTMLFormElement>(null); | |
| 37 | + | |
| 38 | + const visible = useMemo( | |
| 39 | + () => fields.filter((f) => fieldVisible(f, values)), | |
| 40 | + [fields, values], | |
| 41 | + ); | |
| 42 | + | |
| 43 | + function set(name: string, value: string) { | |
| 44 | + setValues((v) => ({ ...v, [name]: value })); | |
| 45 | + setErrors((e) => { | |
| 46 | + if (!e[name]) return e; | |
| 47 | + const next = { ...e }; | |
| 48 | + delete next[name]; | |
| 49 | + return next; | |
| 50 | + }); | |
| 51 | + } | |
| 52 | + | |
| 53 | + async function submit(e: React.FormEvent) { | |
| 54 | + e.preventDefault(); | |
| 55 | + if (state === "sending") return; | |
| 56 | + setGlobalError(""); | |
| 57 | + | |
| 58 | + // validation de courtoisie (le serveur fait foi) | |
| 59 | + const errs: Record<string, string> = {}; | |
| 60 | + for (const f of visible) { | |
| 61 | + if (f.type === "file" || f.type === "checkbox") continue; | |
| 62 | + if (f.required && !values[f.name]?.trim()) errs[f.name] = "Ce champ est requis."; | |
| 63 | + } | |
| 64 | + for (const f of visible) { | |
| 65 | + if (f.type === "checkbox" && f.required && values[f.name] !== "on") | |
| 66 | + errs[f.name] = "Votre consentement est requis."; | |
| 67 | + } | |
| 68 | + if (Object.keys(errs).length) { | |
| 69 | + setErrors(errs); | |
| 70 | + document | |
| 71 | + .querySelector(`[data-field="${Object.keys(errs)[0]}"]`) | |
| 72 | + ?.scrollIntoView({ behavior: "smooth", block: "center" }); | |
| 73 | + return; | |
| 74 | + } | |
| 75 | + | |
| 76 | + setState("sending"); | |
| 77 | + const fd = new FormData(formRef.current!); | |
| 78 | + fd.set("category", slug); | |
| 79 | + fd.set("_ts", String(tsRef.current)); | |
| 80 | + try { | |
| 81 | + const res = await fetch("/api/contact", { method: "POST", body: fd }); | |
| 82 | + const data = (await res.json().catch(() => ({}))) as { | |
| 83 | + ok?: boolean; | |
| 84 | + reference?: string; | |
| 85 | + error?: string; | |
| 86 | + fields?: Record<string, string>; | |
| 87 | + }; | |
| 88 | + if (res.ok && data.ok && data.reference) { | |
| 89 | + setReference(data.reference); | |
| 90 | + setState("done"); | |
| 91 | + window.scrollTo({ top: 0, behavior: "smooth" }); | |
| 92 | + return; | |
| 93 | + } | |
| 94 | + if (data.fields) setErrors(data.fields); | |
| 95 | + setGlobalError(data.error ?? "Une erreur est survenue — réessayez."); | |
| 96 | + setState("idle"); | |
| 97 | + } catch { | |
| 98 | + setGlobalError("Connexion impossible — vérifiez votre réseau et réessayez."); | |
| 99 | + setState("idle"); | |
| 100 | + } | |
| 101 | + } | |
| 102 | + | |
| 103 | + if (state === "done") { | |
| 104 | + return ( | |
| 105 | + <div className="gk-card mt-10 p-8 sm:p-10"> | |
| 106 | + <p className="kicker">Demande enregistrée</p> | |
| 107 | + <h2 className="gk-display mt-4 text-[clamp(24px,4vw,34px)] leading-tight font-bold tracking-[-0.03em]"> | |
| 108 | + C'est entre bonnes mains. | |
| 109 | + </h2> | |
| 110 | + <div className="mt-6 inline-block rounded-xl bg-ink px-7 py-5"> | |
| 111 | + <p className="gk-mono text-[10px] font-bold tracking-[0.26em] text-[rgba(217,242,107,0.65)] uppercase"> | |
| 112 | + Votre numéro de demande | |
| 113 | + </p> | |
| 114 | + <p className="gk-mono mt-2 text-[clamp(20px,4vw,28px)] font-bold tracking-[0.12em] text-lime"> | |
| 115 | + {reference} | |
| 116 | + </p> | |
| 117 | + </div> | |
| 118 | + <p className="mt-6 max-w-xl text-[14px] leading-relaxed text-ink-2"> | |
| 119 | + Votre demande est enregistrée dans notre centre de communication et | |
| 120 | + sera lue par l'équipe concernée.{" "} | |
| 121 | + {values.email && values.is_anonymous !== "on" | |
| 122 | + ? "Un accusé de réception vient de partir vers votre courriel — conservez ce numéro, il identifie votre demande dans tous nos échanges." | |
| 123 | + : "Conservez ce numéro : il identifie votre demande."} | |
| 124 | + </p> | |
| 125 | + <a href="/contact" className="cta-link mt-8"> | |
| 126 | + Retour au centre de communication | |
| 127 | + </a> | |
| 128 | + </div> | |
| 129 | + ); | |
| 130 | + } | |
| 131 | + | |
| 132 | + return ( | |
| 133 | + <form ref={formRef} onSubmit={submit} className="mt-10" noValidate> | |
| 134 | + {kaConnected ? ( | |
| 135 | + <p className="gk-mono mb-6 inline-flex items-center gap-2 rounded-full border-[1.5px] border-ink bg-lime-soft px-4 py-2 text-[11px] font-bold tracking-[0.08em] uppercase"> | |
| 136 | + <span className="pulse-dot" aria-hidden="true" /> | |
| 137 | + KA ID connecté — vos informations sont préremplies et la demande sera | |
| 138 | + liée à votre compte | |
| 139 | + </p> | |
| 140 | + ) : null} | |
| 141 | + | |
| 142 | + {/* honeypot : invisible pour les humains, irrésistible pour les robots */} | |
| 143 | + <div aria-hidden="true" className="absolute -left-[9999px] h-0 w-0 overflow-hidden"> | |
| 144 | + <label> | |
| 145 | + Ne pas remplir ce champ | |
| 146 | + <input type="text" name="_hp" tabIndex={-1} autoComplete="off" /> | |
| 147 | + </label> | |
| 148 | + </div> | |
| 149 | + | |
| 150 | + <div className="grid gap-x-5 gap-y-5 sm:grid-cols-2"> | |
| 151 | + {visible.map((f) => { | |
| 152 | + const err = errors[f.name]; | |
| 153 | + const wide = !f.half; | |
| 154 | + if (f.type === "checkbox") | |
| 155 | + return ( | |
| 156 | + <label | |
| 157 | + key={f.name} | |
| 158 | + data-field={f.name} | |
| 159 | + className={`flex cursor-pointer items-start gap-3 ${wide ? "sm:col-span-2" : ""}`} | |
| 160 | + > | |
| 161 | + <input | |
| 162 | + type="checkbox" | |
| 163 | + name={f.name} | |
| 164 | + checked={values[f.name] === "on"} | |
| 165 | + onChange={(e) => set(f.name, e.target.checked ? "on" : "")} | |
| 166 | + className="mt-[3px] h-[18px] w-[18px] flex-none accent-[var(--green)]" | |
| 167 | + /> | |
| 168 | + <span className="text-[13.5px] leading-relaxed text-ink-2"> | |
| 169 | + {f.label} | |
| 170 | + {f.required ? <span className="text-danger"> *</span> : null} | |
| 171 | + {err ? ( | |
| 172 | + <span className="gk-mono mt-1 block text-[11px] font-bold text-danger">{err}</span> | |
| 173 | + ) : null} | |
| 174 | + </span> | |
| 175 | + </label> | |
| 176 | + ); | |
| 177 | + return ( | |
| 178 | + <div key={f.name} data-field={f.name} className={wide ? "sm:col-span-2" : ""}> | |
| 179 | + <label className="klabel mb-[6px] block" htmlFor={`f-${f.name}`}> | |
| 180 | + {f.label} | |
| 181 | + {f.required ? <span className="text-danger"> *</span> : null} | |
| 182 | + </label> | |
| 183 | + {f.type === "textarea" ? ( | |
| 184 | + <textarea | |
| 185 | + id={`f-${f.name}`} | |
| 186 | + name={f.name} | |
| 187 | + rows={f.rows ?? 5} | |
| 188 | + maxLength={f.maxLen} | |
| 189 | + placeholder={f.placeholder} | |
| 190 | + value={values[f.name]} | |
| 191 | + onChange={(e) => set(f.name, e.target.value)} | |
| 192 | + className="field resize-y" | |
| 193 | + /> | |
| 194 | + ) : f.type === "select" ? ( | |
| 195 | + <select | |
| 196 | + id={`f-${f.name}`} | |
| 197 | + name={f.name} | |
| 198 | + value={values[f.name]} | |
| 199 | + onChange={(e) => set(f.name, e.target.value)} | |
| 200 | + className="field cursor-pointer" | |
| 201 | + > | |
| 202 | + <option value="">— Choisir —</option> | |
| 203 | + {(f.options ?? []).map((o) => ( | |
| 204 | + <option key={o} value={o}> | |
| 205 | + {o} | |
| 206 | + </option> | |
| 207 | + ))} | |
| 208 | + </select> | |
| 209 | + ) : f.type === "file" ? ( | |
| 210 | + <input | |
| 211 | + id={`f-${f.name}`} | |
| 212 | + type="file" | |
| 213 | + name={f.name} | |
| 214 | + accept={f.accept} | |
| 215 | + className="field cursor-pointer file:mr-4 file:cursor-pointer file:rounded-full file:border-0 file:bg-ink file:px-4 file:py-[6px] file:font-[var(--font-display)] file:text-[12px] file:font-bold file:text-lime" | |
| 216 | + /> | |
| 217 | + ) : ( | |
| 218 | + <input | |
| 219 | + id={`f-${f.name}`} | |
| 220 | + type={f.type === "date" ? "date" : f.type} | |
| 221 | + name={f.name} | |
| 222 | + maxLength={f.maxLen} | |
| 223 | + placeholder={f.placeholder} | |
| 224 | + value={values[f.name]} | |
| 225 | + onChange={(e) => set(f.name, e.target.value)} | |
| 226 | + className="field" | |
| 227 | + /> | |
| 228 | + )} | |
| 229 | + {f.help ? ( | |
| 230 | + <p className="gk-mono mt-[6px] text-[10.5px] leading-relaxed text-ink-3">{f.help}</p> | |
| 231 | + ) : null} | |
| 232 | + {err ? ( | |
| 233 | + <p className="gk-mono mt-[6px] text-[11px] font-bold text-danger">{err}</p> | |
| 234 | + ) : null} | |
| 235 | + </div> | |
| 236 | + ); | |
| 237 | + })} | |
| 238 | + </div> | |
| 239 | + | |
| 240 | + {globalError ? ( | |
| 241 | + <p className="gk-mono mt-6 rounded-lg border-[1.5px] border-danger bg-[var(--danger-soft)] px-4 py-3 text-[12px] font-bold text-danger"> | |
| 242 | + {globalError} | |
| 243 | + </p> | |
| 244 | + ) : null} | |
| 245 | + | |
| 246 | + <div className="mt-8 flex flex-wrap items-center gap-6"> | |
| 247 | + <button type="submit" className="btn btn-primary" disabled={state === "sending"}> | |
| 248 | + {state === "sending" ? "Envoi en cours…" : "Envoyer ma demande"} | |
| 249 | + </button> | |
| 250 | + <p className="gk-mono max-w-sm text-[10.5px] leading-relaxed text-ink-3"> | |
| 251 | + Votre demande reçoit un numéro de suivi et est lue par une vraie | |
| 252 | + personne. Renseignements traités selon notre{" "} | |
| 253 | + <a href="/confidentialite" className="underline underline-offset-2"> | |
| 254 | + politique de confidentialité | |
| 255 | + </a> | |
| 256 | + . | |
| 257 | + </p> | |
| 258 | + </div> | |
| 259 | + </form> | |
| 260 | + ); | |
| 261 | +} | |
added
src/app/contact/[category]/page.tsx
+178 −0
@@ -0,0 +1,178 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// /contact/[category] — le formulaire d'une porte d'entrée du centre de | |
| 3 | +// communication. Rendu serveur : le registre fournit les champs, la session | |
| 4 | +// KA ID (si présente) préremplit l'identité — on ne redemande pas ce qu'on | |
| 5 | +// sait déjà, et la demande est liée au compte côté serveur. | |
| 6 | +import type { Metadata } from "next"; | |
| 7 | +import { notFound } from "next/navigation"; | |
| 8 | +import { getSessionUser } from "@/lib/auth"; | |
| 9 | +import { findUserById, parseSocials } from "@/lib/db"; | |
| 10 | +import { categoryBySlug } from "@/lib/comms/categories"; | |
| 11 | +import { CategoryIcon } from "../icons"; | |
| 12 | +import ContactForm from "../ContactForm"; | |
| 13 | + | |
| 14 | +// Page dynamique : la session KA ID (cookies) préremplit le formulaire. | |
| 15 | +export const dynamic = "force-dynamic"; | |
| 16 | + | |
| 17 | +export async function generateMetadata({ | |
| 18 | + params, | |
| 19 | +}: { | |
| 20 | + params: Promise<{ category: string }>; | |
| 21 | +}): Promise<Metadata> { | |
| 22 | + const cat = categoryBySlug((await params).category); | |
| 23 | + if (!cat) return {}; | |
| 24 | + return { | |
| 25 | + title: `${cat.title} — nous joindre`, | |
| 26 | + description: cat.intro, | |
| 27 | + }; | |
| 28 | +} | |
| 29 | + | |
| 30 | +/* Contenus éditoriaux propres à certaines catégories */ | |
| 31 | + | |
| 32 | +function InvestisseursIntro() { | |
| 33 | + const PROFILES = [ | |
| 34 | + "Investisseurs privés", | |
| 35 | + "Fonds (VC, PE)", | |
| 36 | + "Family offices", | |
| 37 | + "Investisseurs stratégiques", | |
| 38 | + "Partenaires financiers", | |
| 39 | + "Collaborations & acquisitions", | |
| 40 | + ]; | |
| 41 | + return ( | |
| 42 | + <div className="mt-8 grid gap-8 lg:grid-cols-[1.4fr_1fr]"> | |
| 43 | + <div> | |
| 44 | + <p className="text-[14.5px] leading-relaxed text-ink-2"> | |
| 45 | + Le Groupe KA construit et opère{" "} | |
| 46 | + <strong className="text-ink">quatorze plateformes d'agrégation</strong>{" "} | |
| 47 | + entièrement automatisées sur sa propre infrastructure — et publie ses | |
| 48 | + chiffres comme ses données : en entier. Aucune ronde de financement | |
| 49 | + n'est en cours, mais nous sommes ouverts aux discussions | |
| 50 | + sérieuses : investissement, partenariat financier, acquisition, joint | |
| 51 | + venture ou simple introduction. | |
| 52 | + </p> | |
| 53 | + <p className="mt-4 text-[14.5px] leading-relaxed text-ink-2"> | |
| 54 | + Commencez par les{" "} | |
| 55 | + <a href="/investisseurs" className="font-bold text-green underline underline-offset-4"> | |
| 56 | + documents corporatifs en libre accès | |
| 57 | + </a>{" "} | |
| 58 | + (plan d'affaires, prévisions 2026–2029, infrastructure, coûts | |
| 59 | + mesurés), puis présentez-vous ci-dessous. Ces demandes sont traitées | |
| 60 | + confidentiellement par les relations investisseurs. | |
| 61 | + </p> | |
| 62 | + </div> | |
| 63 | + <ul className="rule-ink grid grid-cols-1 gap-y-2 self-start pt-4"> | |
| 64 | + {PROFILES.map((p) => ( | |
| 65 | + <li key={p} className="gk-mono flex items-center gap-3 text-[11.5px] font-bold tracking-[0.06em] uppercase"> | |
| 66 | + <span className="h-[6px] w-[6px] flex-none rotate-45 bg-green" aria-hidden="true" /> | |
| 67 | + {p} | |
| 68 | + </li> | |
| 69 | + ))} | |
| 70 | + </ul> | |
| 71 | + </div> | |
| 72 | + ); | |
| 73 | +} | |
| 74 | + | |
| 75 | +function CarrieresIntro() { | |
| 76 | + return ( | |
| 77 | + <div className="mt-8 max-w-3xl"> | |
| 78 | + <p className="text-[14.5px] leading-relaxed text-ink-2"> | |
| 79 | + <strong className="text-ink">Carrières chez Groupe KA</strong> — nous | |
| 80 | + construisons des agrégateurs automatisés, une infrastructure de calcul | |
| 81 | + maison et des outils d'IA appliquée, depuis le Québec. Deux façons | |
| 82 | + de postuler : répondre à un poste précis, ou nous faire une{" "} | |
| 83 | + <strong className="text-ink">candidature spontanée</strong> — « je ne | |
| 84 | + vois pas de poste correspondant, mais je veux travailler avec Groupe | |
| 85 | + KA ». Les deux sont enregistrées et lues avec la même attention. | |
| 86 | + </p> | |
| 87 | + <p className="gk-mono mt-4 text-[11px] leading-relaxed text-ink-3"> | |
| 88 | + Les offres publiées par les employeurs du Québec sont sur{" "} | |
| 89 | + <a href="https://www.job-ka.com" target="_blank" rel="noopener noreferrer" className="underline underline-offset-2"> | |
| 90 | + Job·Ka | |
| 91 | + </a>{" "} | |
| 92 | + — ce formulaire-ci concerne les emplois chez Groupe KA même. Votre CV | |
| 93 | + est conservé de façon sécurisée et n'est jamais partagé. | |
| 94 | + </p> | |
| 95 | + </div> | |
| 96 | + ); | |
| 97 | +} | |
| 98 | + | |
| 99 | +function SecuriteIntro() { | |
| 100 | + return ( | |
| 101 | + <div className="mt-8 max-w-3xl rounded-xl border-[1.5px] border-ink bg-lime-soft p-5"> | |
| 102 | + <p className="text-[13.5px] leading-relaxed text-ink-2"> | |
| 103 | + <strong className="text-ink">Divulgation responsable :</strong> si vous | |
| 104 | + avez trouvé une vulnérabilité, décrivez-la ici sans l'exploiter | |
| 105 | + au-delà du nécessaire. Le signalement peut être{" "} | |
| 106 | + <strong className="text-ink">anonyme</strong> — ne laissez un courriel | |
| 107 | + que si vous souhaitez un suivi. Les signalements critiques passent | |
| 108 | + automatiquement en priorité urgente. | |
| 109 | + </p> | |
| 110 | + </div> | |
| 111 | + ); | |
| 112 | +} | |
| 113 | + | |
| 114 | +export default async function ContactCategoryPage({ | |
| 115 | + params, | |
| 116 | +}: { | |
| 117 | + params: Promise<{ category: string }>; | |
| 118 | +}) { | |
| 119 | + const cat = categoryBySlug((await params).category); | |
| 120 | + if (!cat) notFound(); | |
| 121 | + | |
| 122 | + // Préremplissage KA ID (serveur) — jamais confiance au client pour le lien | |
| 123 | + // user_id/ka_id : /api/contact relit la session lui-même. | |
| 124 | + const user = await getSessionUser(); | |
| 125 | + const defaults: Record<string, string> = {}; | |
| 126 | + if (user) { | |
| 127 | + const row = findUserById(user.id); | |
| 128 | + const [first, ...rest] = (user.name ?? "").trim().split(/\s+/); | |
| 129 | + if (first) defaults.first_name = first; | |
| 130 | + if (rest.length) defaults.last_name = rest.join(" "); | |
| 131 | + defaults.email = user.email; | |
| 132 | + if (row?.phone) defaults.phone = row.phone; | |
| 133 | + if (row?.company) defaults.organization = row.company; | |
| 134 | + if (row?.job_title) defaults.job_title = row.job_title; | |
| 135 | + if (row?.website) defaults.website = row.website; | |
| 136 | + if (row?.city) defaults.ville = row.city; | |
| 137 | + const socials = parseSocials(row?.socials ?? null); | |
| 138 | + if (socials.linkedin) defaults.linkedin_url = socials.linkedin; | |
| 139 | + } | |
| 140 | + | |
| 141 | + return ( | |
| 142 | + <main className="mx-auto max-w-4xl px-4 py-12 sm:px-6"> | |
| 143 | + <nav aria-label="Fil d'Ariane"> | |
| 144 | + <a href="/contact" className="gk-mono text-[11px] font-bold tracking-[0.1em] text-ink-3 uppercase no-underline hover:text-green"> | |
| 145 | + ← Centre de communication | |
| 146 | + </a> | |
| 147 | + </nav> | |
| 148 | + | |
| 149 | + <div className="mt-6 flex items-start gap-5"> | |
| 150 | + <span className="hidden h-14 w-14 flex-none items-center justify-center rounded-xl border-[1.5px] border-ink bg-ink text-lime sm:flex"> | |
| 151 | + <CategoryIcon name={cat.icon} className="h-7 w-7" /> | |
| 152 | + </span> | |
| 153 | + <div> | |
| 154 | + <p className="kicker">Nous joindre · KA-{cat.prefix}</p> | |
| 155 | + <h1 className="gk-display mt-2 text-[clamp(26px,4.5vw,40px)] leading-[1.02] font-bold tracking-[-0.03em] uppercase"> | |
| 156 | + {cat.title} | |
| 157 | + </h1> | |
| 158 | + </div> | |
| 159 | + </div> | |
| 160 | + | |
| 161 | + {cat.slug === "investisseurs" ? ( | |
| 162 | + <InvestisseursIntro /> | |
| 163 | + ) : cat.slug === "carrieres" ? ( | |
| 164 | + <CarrieresIntro /> | |
| 165 | + ) : ( | |
| 166 | + <p className="mt-6 max-w-3xl text-[14.5px] leading-relaxed text-ink-2">{cat.intro}</p> | |
| 167 | + )} | |
| 168 | + {cat.slug === "securite" ? <SecuriteIntro /> : null} | |
| 169 | + | |
| 170 | + <ContactForm | |
| 171 | + slug={cat.slug} | |
| 172 | + fields={cat.fields} | |
| 173 | + defaults={defaults} | |
| 174 | + kaConnected={!!user} | |
| 175 | + /> | |
| 176 | + </main> | |
| 177 | + ); | |
| 178 | +} | |
added
src/app/contact/icons.tsx
+92 −0
@@ -0,0 +1,92 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// KA Communication Hub — pictogrammes des catégories (trait 1.8, style du site). | |
| 3 | +const P = { | |
| 4 | + handshake: ( | |
| 5 | + <> | |
| 6 | + <path d="M11 17l-1.5 1.5a2.1 2.1 0 0 1-3-3L11 11l2.5-1.5a3 3 0 0 1 3 0L19 11l3 3" /> | |
| 7 | + <path d="M2 14l4-4 3-2" /> | |
| 8 | + <path d="M14 18l1.5 1.5a2.1 2.1 0 0 0 3-3" /> | |
| 9 | + <path d="M2 10l4 8" /> | |
| 10 | + <path d="M22 10l-3 8" /> | |
| 11 | + </> | |
| 12 | + ), | |
| 13 | + data: ( | |
| 14 | + <> | |
| 15 | + <ellipse cx="12" cy="5" rx="8" ry="3" /> | |
| 16 | + <path d="M4 5v6c0 1.66 3.58 3 8 3s8-1.34 8-3V5" /> | |
| 17 | + <path d="M4 11v6c0 1.66 3.58 3 8 3s8-1.34 8-3v-6" /> | |
| 18 | + </> | |
| 19 | + ), | |
| 20 | + chart: ( | |
| 21 | + <> | |
| 22 | + <path d="M3 3v18h18" /> | |
| 23 | + <path d="M7 15l4-5 3 3 5-7" /> | |
| 24 | + <circle cx="19" cy="6" r="1.4" /> | |
| 25 | + </> | |
| 26 | + ), | |
| 27 | + career: ( | |
| 28 | + <> | |
| 29 | + <circle cx="12" cy="7" r="4" /> | |
| 30 | + <path d="M5 21v-2a7 7 0 0 1 14 0v2" /> | |
| 31 | + </> | |
| 32 | + ), | |
| 33 | + briefcase: ( | |
| 34 | + <> | |
| 35 | + <rect x="3" y="7" width="18" height="13" rx="2" /> | |
| 36 | + <path d="M8 7V5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /> | |
| 37 | + <path d="M3 13h18" /> | |
| 38 | + </> | |
| 39 | + ), | |
| 40 | + press: ( | |
| 41 | + <> | |
| 42 | + <path d="M12 8a4 4 0 0 1 4 4v8H8v-8a4 4 0 0 1 4-4z" /> | |
| 43 | + <path d="M12 8V4" /> | |
| 44 | + <path d="M8 20h8" /> | |
| 45 | + <path d="M4 12H2M22 12h-2M6 6L4.5 4.5M18 6l1.5-1.5" /> | |
| 46 | + </> | |
| 47 | + ), | |
| 48 | + chat: ( | |
| 49 | + <> | |
| 50 | + <path d="M21 11.5a8.38 8.38 0 0 1-9 8.35 8.5 8.5 0 0 1-3.4-.65L3 21l1.8-5.6A8.38 8.38 0 0 1 12 3a8.38 8.38 0 0 1 9 8.5z" /> | |
| 51 | + <path d="M8 11h8M8 14h5" /> | |
| 52 | + </> | |
| 53 | + ), | |
| 54 | + shield: ( | |
| 55 | + <> | |
| 56 | + <path d="M12 22s8-3.5 8-10V5l-8-3-8 3v7c0 6.5 8 10 8 10z" /> | |
| 57 | + <path d="M9 12l2 2 4-4" /> | |
| 58 | + </> | |
| 59 | + ), | |
| 60 | + alert: ( | |
| 61 | + <> | |
| 62 | + <path d="M10.3 3.9L1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z" /> | |
| 63 | + <path d="M12 9v4M12 17h.01" /> | |
| 64 | + </> | |
| 65 | + ), | |
| 66 | + spark: ( | |
| 67 | + <> | |
| 68 | + <path d="M12 3v4M12 17v4M3 12h4M17 12h4" /> | |
| 69 | + <path d="M5.6 5.6l2.8 2.8M15.6 15.6l2.8 2.8M18.4 5.6l-2.8 2.8M8.4 15.6l-2.8 2.8" /> | |
| 70 | + </> | |
| 71 | + ), | |
| 72 | +} as const; | |
| 73 | + | |
| 74 | +export type IconName = keyof typeof P; | |
| 75 | + | |
| 76 | +export function CategoryIcon({ name, className }: { name: string; className?: string }) { | |
| 77 | + const paths = P[name as IconName] ?? P.spark; | |
| 78 | + return ( | |
| 79 | + <svg | |
| 80 | + viewBox="0 0 24 24" | |
| 81 | + className={className} | |
| 82 | + aria-hidden="true" | |
| 83 | + fill="none" | |
| 84 | + stroke="currentColor" | |
| 85 | + strokeWidth="1.8" | |
| 86 | + strokeLinecap="round" | |
| 87 | + strokeLinejoin="round" | |
| 88 | + > | |
| 89 | + {paths} | |
| 90 | + </svg> | |
| 91 | + ); | |
| 92 | +} | |
added
src/app/contact/page.tsx
+91 −0
@@ -0,0 +1,91 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// /contact — le centre de communication du Groupe KA. | |
| 3 | +// Fini les adresses courriel publiques : chaque type de relation (partenariat, | |
| 4 | +// données, investisseur, candidature, fournisseur, média, légal, sécurité…) | |
| 5 | +// a sa porte d'entrée, son formulaire structuré et son numéro de suivi. | |
| 6 | +import type { Metadata } from "next"; | |
| 7 | +import { CATEGORIES } from "@/lib/comms/categories"; | |
| 8 | +import { CategoryIcon } from "./icons"; | |
| 9 | + | |
| 10 | +export const metadata: Metadata = { | |
| 11 | + title: "Nous joindre — le centre de communication", | |
| 12 | + description: | |
| 13 | + "Partenariats, données & API, investisseurs, carrières, fournisseurs, médias, questions, légal & Loi 25, sécurité : chaque demande passe par un formulaire dédié, reçoit un numéro de suivi et est lue par l'équipe concernée.", | |
| 14 | +}; | |
| 15 | + | |
| 16 | +const STEPS = [ | |
| 17 | + ["01", "Choisissez votre porte d'entrée", "Dix catégories, chacune avec un formulaire pensé pour son contexte."], | |
| 18 | + ["02", "Recevez votre numéro de demande", "Chaque soumission est enregistrée et reçoit une référence du type KA-PAR-2026-0042."], | |
| 19 | + ["03", "Une vraie personne vous répond", "Votre demande arrive directement dans l'inbox de l'équipe concernée — priorisée, assignée, suivie."], | |
| 20 | +] as const; | |
| 21 | + | |
| 22 | +export default function ContactPage() { | |
| 23 | + return ( | |
| 24 | + <main className="mx-auto max-w-6xl px-4 py-12 sm:px-6"> | |
| 25 | + <p className="kicker">Centre de communication · Groupe KA</p> | |
| 26 | + <h1 className="gk-display mt-3 text-[clamp(30px,5.5vw,56px)] leading-[1.0] font-bold tracking-[-0.035em] uppercase"> | |
| 27 | + Nous <span className="hl">joindre</span> | |
| 28 | + </h1> | |
| 29 | + <p className="mt-5 max-w-2xl text-[15px] leading-relaxed text-ink-2"> | |
| 30 | + Pas d'adresse courriel à deviner, pas de boîte noire — comme pour | |
| 31 | + nos données. Choisissez le type de demande : elle est enregistrée dans | |
| 32 | + notre centre de communication, reçoit un{" "} | |
| 33 | + <strong className="text-ink">numéro de suivi</strong> et est lue par | |
| 34 | + l'équipe concernée. Connecté avec votre{" "} | |
| 35 | + <a href="/connexion?next=/contact" className="font-bold text-green underline underline-offset-4"> | |
| 36 | + KA ID | |
| 37 | + </a>{" "} | |
| 38 | + ? Vos informations sont préremplies et vos demandes liées à votre compte. | |
| 39 | + </p> | |
| 40 | + | |
| 41 | + {/* comment ça marche */} | |
| 42 | + <div className="mt-10 grid gap-x-10 gap-y-6 sm:grid-cols-3"> | |
| 43 | + {STEPS.map(([num, title, body]) => ( | |
| 44 | + <article key={num} className="rule-ink pt-4"> | |
| 45 | + <span className="gk-mono tnum text-[14px] font-bold text-green">{num}</span> | |
| 46 | + <h2 className="gk-display mt-2 text-[15.5px] font-bold">{title}</h2> | |
| 47 | + <p className="mt-2 text-[13px] leading-relaxed text-ink-2">{body}</p> | |
| 48 | + </article> | |
| 49 | + ))} | |
| 50 | + </div> | |
| 51 | + | |
| 52 | + {/* les portes d'entrée */} | |
| 53 | + <div className="sec-head mt-14"> | |
| 54 | + <p className="kicker">Choisissez votre porte d'entrée</p> | |
| 55 | + <span className="sec-index">{CATEGORIES.length} catégories</span> | |
| 56 | + </div> | |
| 57 | + <div className="mt-8 grid gap-6 sm:grid-cols-2 lg:grid-cols-3"> | |
| 58 | + {CATEGORIES.map((c) => ( | |
| 59 | + <a | |
| 60 | + key={c.slug} | |
| 61 | + href={`/contact/${c.slug}`} | |
| 62 | + className="gk-card gk-card-hover group flex flex-col p-6 no-underline" | |
| 63 | + > | |
| 64 | + <div className="flex items-start justify-between gap-3"> | |
| 65 | + <span className="flex h-12 w-12 items-center justify-center rounded-xl border-[1.5px] border-ink bg-ink text-lime"> | |
| 66 | + <CategoryIcon name={c.icon} className="h-6 w-6" /> | |
| 67 | + </span> | |
| 68 | + <span className="gk-mono text-[10px] font-bold tracking-[0.1em] text-ink-3 uppercase"> | |
| 69 | + KA-{c.prefix} | |
| 70 | + </span> | |
| 71 | + </div> | |
| 72 | + <h2 className="gk-display mt-4 text-[18px] leading-tight font-bold">{c.title}</h2> | |
| 73 | + <p className="mt-2 flex-1 text-[13px] leading-relaxed text-ink-2">{c.tagline}</p> | |
| 74 | + <span className="cta-link mt-5 !border-b-0 text-[13.5px]">Ouvrir le formulaire</span> | |
| 75 | + </a> | |
| 76 | + ))} | |
| 77 | + </div> | |
| 78 | + | |
| 79 | + <p className="gk-mono mt-12 max-w-3xl text-[11px] leading-relaxed text-ink-3"> | |
| 80 | + Les demandes investisseurs, légales et de sécurité sont traitées avec un | |
| 81 | + niveau de confidentialité élevé, par les seules personnes habilitées. | |
| 82 | + Les signalements de sécurité peuvent être anonymes. Renseignements | |
| 83 | + personnels traités selon la{" "} | |
| 84 | + <a href="/loi-25" className="underline underline-offset-2">Loi 25</a> et notre{" "} | |
| 85 | + <a href="/confidentialite" className="underline underline-offset-2"> | |
| 86 | + politique de confidentialité | |
| 87 | + </a>. | |
| 88 | + </p> | |
| 89 | + </main> | |
| 90 | + ); | |
| 91 | +} | |
modified
src/app/globals.css
+102 −0
@@ -1134,3 +1134,105 @@ h4 { | ||
| 1134 | 1134 | margin: 14mm 12mm; |
| 1135 | 1135 | } |
| 1136 | 1136 | } |
| 1137 | + | |
| 1138 | +/* ================================================================== | |
| 1139 | + PANEL /admin — KA Communication Hub | |
| 1140 | + Coquille dédiée : le chrome public (header, ticker, footer, overlay | |
| 1141 | + mobile, widget agent) est masqué dès que la page porte | |
| 1142 | + [data-admin-root]. Design « outil interne » : dense, sobre, rapide. | |
| 1143 | + ================================================================== */ | |
| 1144 | + | |
| 1145 | +body:has([data-admin-root]) > header, | |
| 1146 | +body:has([data-admin-root]) > .ticker, | |
| 1147 | +body:has([data-admin-root]) > footer, | |
| 1148 | +body:has([data-admin-root]) > .gk-mobile-overlay, | |
| 1149 | +body:has([data-admin-root]) [class*="kaa-"] { | |
| 1150 | + display: none !important; | |
| 1151 | +} | |
| 1152 | +body:has([data-admin-root]) { | |
| 1153 | + background: #eeece5; | |
| 1154 | +} | |
| 1155 | + | |
| 1156 | +/* Pastilles de statut/priorité de l'inbox */ | |
| 1157 | +.adm-chip { | |
| 1158 | + display: inline-flex; | |
| 1159 | + align-items: center; | |
| 1160 | + gap: 6px; | |
| 1161 | + padding: 3px 10px 4px; | |
| 1162 | + border-radius: 999px; | |
| 1163 | + border: 1.5px solid var(--ink); | |
| 1164 | + font-family: var(--font-mono); | |
| 1165 | + font-size: 10px; | |
| 1166 | + font-weight: 700; | |
| 1167 | + letter-spacing: 0.08em; | |
| 1168 | + text-transform: uppercase; | |
| 1169 | + white-space: nowrap; | |
| 1170 | + background: var(--surface); | |
| 1171 | + color: var(--ink); | |
| 1172 | +} | |
| 1173 | +.adm-chip--urgente { | |
| 1174 | + background: var(--danger); | |
| 1175 | + border-color: var(--danger); | |
| 1176 | + color: #fff; | |
| 1177 | +} | |
| 1178 | +.adm-chip--haute { | |
| 1179 | + background: var(--amber-soft); | |
| 1180 | + border-color: var(--amber); | |
| 1181 | + color: #7a4d0d; | |
| 1182 | +} | |
| 1183 | +.adm-chip--basse { | |
| 1184 | + border-color: var(--line-strong); | |
| 1185 | + color: var(--ink-3); | |
| 1186 | +} | |
| 1187 | +.adm-chip--lime { | |
| 1188 | + background: var(--lime); | |
| 1189 | +} | |
| 1190 | +.adm-chip--ghost { | |
| 1191 | + border-style: dashed; | |
| 1192 | + color: var(--ink-3); | |
| 1193 | +} | |
| 1194 | + | |
| 1195 | +/* Rangée d'inbox : lisible, dense, non-lu marqué à gauche */ | |
| 1196 | +.adm-row { | |
| 1197 | + display: block; | |
| 1198 | + border-top: 1px solid var(--line); | |
| 1199 | + padding: 12px 14px; | |
| 1200 | + text-decoration: none; | |
| 1201 | + color: inherit; | |
| 1202 | + transition: background 0.12s ease; | |
| 1203 | +} | |
| 1204 | +.adm-row:hover { | |
| 1205 | + background: rgba(255, 255, 255, 0.85); | |
| 1206 | +} | |
| 1207 | +.adm-row--unread { | |
| 1208 | + background: #ffffff; | |
| 1209 | + box-shadow: inset 3px 0 0 var(--green); | |
| 1210 | +} | |
| 1211 | + | |
| 1212 | +/* Navigation latérale du panel */ | |
| 1213 | +.adm-nav-link { | |
| 1214 | + display: flex; | |
| 1215 | + align-items: center; | |
| 1216 | + justify-content: space-between; | |
| 1217 | + gap: 10px; | |
| 1218 | + padding: 9px 12px; | |
| 1219 | + border-radius: 8px; | |
| 1220 | + font-family: var(--font-display); | |
| 1221 | + font-size: 13.5px; | |
| 1222 | + font-weight: 700; | |
| 1223 | + color: rgba(245, 243, 238, 0.72); | |
| 1224 | + text-decoration: none; | |
| 1225 | + transition: background 0.12s ease, color 0.12s ease; | |
| 1226 | +} | |
| 1227 | +.adm-nav-link:hover { | |
| 1228 | + color: var(--lime); | |
| 1229 | + background: rgba(217, 242, 107, 0.08); | |
| 1230 | +} | |
| 1231 | +.adm-nav-link--active { | |
| 1232 | + color: var(--ink); | |
| 1233 | + background: var(--lime); | |
| 1234 | +} | |
| 1235 | +.adm-nav-link--active:hover { | |
| 1236 | + color: var(--ink); | |
| 1237 | + background: var(--lime); | |
| 1238 | +} | |
modified
src/app/investisseurs/page.tsx
+23 −2
@@ -139,11 +139,32 @@ export default function InvestisseursPage() { | ||
| 139 | 139 | ))} |
| 140 | 140 | </div> |
| 141 | 141 | |
| 142 | + {/* Discussion d'investissement : la porte d'entrée officielle est le | |
| 143 | + centre de communication (demandes confidentielles, suivies). */} | |
| 144 | + <div className="gk-card mt-12 flex flex-wrap items-center justify-between gap-5 p-7"> | |
| 145 | + <div className="min-w-0"> | |
| 146 | + <p className="kicker">Discuter avec le groupe</p> | |
| 147 | + <h2 className="gk-display mt-2 text-[clamp(19px,3vw,26px)] leading-tight font-bold tracking-[-0.03em]"> | |
| 148 | + Investissement, partenariat financier, acquisition ? | |
| 149 | + </h2> | |
| 150 | + <p className="mt-2 max-w-xl text-[13.5px] text-ink-2"> | |
| 151 | + Les demandes investisseurs passent par le centre de communication — | |
| 152 | + confidentielles, suivies, traitées par les relations investisseurs. | |
| 153 | + </p> | |
| 154 | + </div> | |
| 155 | + <a href="/contact/investisseurs" className="btn btn-primary flex-none"> | |
| 156 | + Ouvrir le formulaire investisseurs | |
| 157 | + </a> | |
| 158 | + </div> | |
| 159 | + | |
| 142 | 160 | <p className="gk-mono mt-10 text-[11px] leading-relaxed text-ink-3"> |
| 143 | 161 | Les prévisions sont préparées par la direction et non auditées ; les |
| 144 | 162 | coûts de développement sont mesurés à partir des journaux de sessions. |
| 145 | − Questions d'affaires et partenariats : contact@groupe-ka.com. | |
| 146 | − Documents produits avec Claude Code sur l'infrastructure du groupe. | |
| 163 | + Questions d'affaires et partenariats :{" "} | |
| 164 | + <a href="/contact/investisseurs" className="underline underline-offset-4"> | |
| 165 | + formulaire Investisseurs | |
| 166 | + </a> | |
| 167 | + . Documents produits avec Claude Code sur l'infrastructure du groupe. | |
| 147 | 168 | </p> |
| 148 | 169 | </main> |
| 149 | 170 | ); |
modified
src/app/layout.tsx
+26 −18
@@ -86,7 +86,12 @@ const JSON_LD = { | ||
| 86 | 86 | logo: `${SITE_URL}og.png`, |
| 87 | 87 | description: |
| 88 | 88 | "Holding québécois d’agrégateurs de produits et services entièrement automatisés — zéro saisie manuelle, zéro boîte noire.", |
| 89 | − email: "contact@groupe-ka.com", | |
| 89 | + contactPoint: { | |
| 90 | + "@type": "ContactPoint", | |
| 91 | + url: `${SITE_URL}contact`, | |
| 92 | + contactType: "customer support", | |
| 93 | + availableLanguage: "fr-CA", | |
| 94 | + }, | |
| 90 | 95 | areaServed: { |
| 91 | 96 | "@type": "AdministrativeArea", |
| 92 | 97 | name: "Québec, Canada", |
@@ -126,7 +131,7 @@ const NAV_ITEMS = [ | ||
| 126 | 131 | { href: "/telecharger", label: "Télécharger" }, |
| 127 | 132 | { href: "/marque", label: "Marque" }, |
| 128 | 133 | { href: "/status", label: "Statut" }, |
| 129 | − { href: "/#contact", label: "Contact" }, | |
| 134 | + { href: "/contact", label: "Contact" }, | |
| 130 | 135 | ]; |
| 131 | 136 | |
| 132 | 137 | // Nav mobile = NAV_ITEMS + « Recherche » (sur desktop, la barre de recherche |
@@ -222,7 +227,7 @@ export default async function RootLayout({ | ||
| 222 | 227 | </li> |
| 223 | 228 | <li> |
| 224 | 229 | <a |
| 225 | − href="mailto:contact@groupe-ka.com" | |
| 230 | + href="/contact" | |
| 226 | 231 | className="gk-display ml-2 inline-block rounded-full bg-ink px-4 py-[8px] text-[13.5px] font-bold text-lime transition-colors hover:bg-green-deep" |
| 227 | 232 | > |
| 228 | 233 | Écrivez-nous |
@@ -280,11 +285,8 @@ export default async function RootLayout({ | ||
| 280 | 285 | </li> |
| 281 | 286 | ))} |
| 282 | 287 | <li className="pt-6"> |
| 283 | − <a | |
| 284 | − href="mailto:contact@groupe-ka.com" | |
| 285 | − className="btn btn-primary w-full" | |
| 286 | − > | |
| 287 | − contact@groupe-ka.com | |
| 288 | + <a href="/contact" className="btn btn-primary w-full"> | |
| 289 | + Nous joindre | |
| 288 | 290 | </a> |
| 289 | 291 | </li> |
| 290 | 292 | </ul> |
@@ -365,30 +367,36 @@ export default async function RootLayout({ | ||
| 365 | 367 | </li> |
| 366 | 368 | ))} |
| 367 | 369 | </ul> |
| 370 | + {/* Centre de communication : les demandes passent par des | |
| 371 | + formulaires structurés (suivies, priorisées) — plus aucune | |
| 372 | + adresse courriel publique. */} | |
| 368 | 373 | <div className="mt-8 grid gap-x-8 gap-y-4 border-t border-[rgba(245,243,238,0.15)] pt-7 sm:grid-cols-3"> |
| 369 | 374 | {[ |
| 370 | 375 | { |
| 371 | − email: "contact@groupe-ka.com", | |
| 372 | − role: "Projets, partenariats & données", | |
| 376 | + href: "/contact/partenariats", | |
| 377 | + label: "Projets, partenariats & données", | |
| 378 | + hint: "aussi : données & API, fournisseurs, investisseurs", | |
| 373 | 379 | }, |
| 374 | 380 | { |
| 375 | − email: "info@groupe-ka.com", | |
| 376 | − role: "Médias & questions générales", | |
| 381 | + href: "/contact/general", | |
| 382 | + label: "Médias & questions générales", | |
| 383 | + hint: "presse, entrevues, questions sur un site", | |
| 377 | 384 | }, |
| 378 | 385 | { |
| 379 | − email: "admin@groupe-ka.com", | |
| 380 | − role: "Légal, vie privée & Loi 25", | |
| 386 | + href: "/contact/legal", | |
| 387 | + label: "Légal, vie privée & Loi 25", | |
| 388 | + hint: "aussi : sécurité & signalements (anonymes possibles)", | |
| 381 | 389 | }, |
| 382 | 390 | ].map((c) => ( |
| 383 | − <p key={c.email} className="text-[12px]"> | |
| 391 | + <p key={c.href} className="text-[12px]"> | |
| 384 | 392 | <a |
| 385 | − href={`mailto:${c.email}`} | |
| 393 | + href={c.href} | |
| 386 | 394 | className="gk-mono font-bold text-[rgba(245,243,238,0.85)] underline-offset-4 hover:text-lime hover:underline" |
| 387 | 395 | > |
| 388 | − {c.email} | |
| 396 | + {c.label} → | |
| 389 | 397 | </a> |
| 390 | 398 | <span className="mt-1 block text-[11px] text-[rgba(245,243,238,0.5)]"> |
| 391 | − {c.role} | |
| 399 | + {c.hint} | |
| 392 | 400 | </span> |
| 393 | 401 | </p> |
| 394 | 402 | ))} |
modified
src/app/loi-25/page.tsx
+8 −8
@@ -154,9 +154,9 @@ export default function Loi25() { | ||
| 154 | 154 | <p> |
| 155 | 155 | <strong className="text-ink">Simon-Pierre Boucher</strong> |
| 156 | 156 | <br /> |
| 157 | − Courriel :{" "} | |
| 158 | − <a className={MAILTO} href="mailto:admin@groupe-ka.com"> | |
| 159 | − admin@groupe-ka.com | |
| 157 | + Contact :{" "} | |
| 158 | + <a className={MAILTO} href="/contact/legal"> | |
| 159 | + formulaire Légal, vie privée & Loi 25 | |
| 160 | 160 | </a> |
| 161 | 161 | <br /> |
| 162 | 162 | Groupe KA, Québec (Canada) |
@@ -448,9 +448,9 @@ export default function Loi25() { | ||
| 448 | 448 | </p> |
| 449 | 449 | <ul className="space-y-2"> |
| 450 | 450 | <Bullet> |
| 451 | − Écrivez-nous à{" "} | |
| 452 | − <a className={MAILTO} href="mailto:admin@groupe-ka.com"> | |
| 453 | − admin@groupe-ka.com | |
| 451 | + Utilisez le{" "} | |
| 452 | + <a className={MAILTO} href="/contact/legal"> | |
| 453 | + formulaire Légal, vie privée & Loi 25 | |
| 454 | 454 | </a>{" "} |
| 455 | 455 | en indiquant le lien (URL) de la page concernée sur notre site; |
| 456 | 456 | </Bullet> |
@@ -511,10 +511,10 @@ export default function Loi25() { | ||
| 511 | 511 | <p className="gk-mono mt-8 text-[11px] text-ink-3"> |
| 512 | 512 | Pour toute question :{" "} |
| 513 | 513 | <a |
| 514 | − href="mailto:admin@groupe-ka.com" | |
| 514 | + href="/contact/legal" | |
| 515 | 515 | className="underline underline-offset-4 hover:text-ink" |
| 516 | 516 | > |
| 517 | − admin@groupe-ka.com | |
| 517 | + le formulaire Légal, vie privée & Loi 25 | |
| 518 | 518 | </a>{" "} |
| 519 | 519 | · Voir aussi :{" "} |
| 520 | 520 | <a |
modified
src/app/marque/page.tsx
+5 −1
@@ -543,7 +543,11 @@ export default function MarquePage() { | ||
| 543 | 543 | Palettes lues en direct dans ecosystem.json (ka-ui) — les valeurs |
| 544 | 544 | affichées ne peuvent pas dériver du code. Logos collectés depuis les |
| 545 | 545 | sites en production le 2026-08-23. Questions de marque et demandes |
| 546 | − presse : info@groupe-ka.com. | |
| 546 | + presse :{" "} | |
| 547 | + <a href="/contact/medias" className="underline underline-offset-4"> | |
| 548 | + formulaire Médias & presse | |
| 549 | + </a> | |
| 550 | + . | |
| 547 | 551 | </p> |
| 548 | 552 | </section> |
| 549 | 553 | </main> |
added
src/app/nous-joindre/page.tsx
+7 −0
@@ -0,0 +1,7 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// /nous-joindre — alias francophone du centre de communication. | |
| 3 | +import { redirect } from "next/navigation"; | |
| 4 | + | |
| 5 | +export default function NousJoindre() { | |
| 6 | + redirect("/contact"); | |
| 7 | +} | |
modified
src/app/page.tsx
+7 −9
@@ -1337,19 +1337,17 @@ export default async function Home() { | ||
| 1337 | 1337 | ramener à un seul endroit. |
| 1338 | 1338 | </p> |
| 1339 | 1339 | <div className="mt-9 flex flex-wrap items-center gap-x-8 gap-y-4"> |
| 1340 | − <a | |
| 1341 | − href="mailto:contact@groupe-ka.com" | |
| 1342 | − className="btn btn-lime !border-lime" | |
| 1343 | − > | |
| 1344 | − contact@groupe-ka.com | |
| 1340 | + <a href="/contact" className="btn btn-lime !border-lime"> | |
| 1341 | + Ouvrir le centre de communication | |
| 1345 | 1342 | </a> |
| 1346 | − <a href="mailto:info@groupe-ka.com" className="cta-link cta-link--paper"> | |
| 1347 | − info@groupe-ka.com | |
| 1343 | + <a href="/contact/partenariats" className="cta-link cta-link--paper"> | |
| 1344 | + Proposer un projet | |
| 1348 | 1345 | </a> |
| 1349 | 1346 | </div> |
| 1350 | 1347 | <p className="gk-mono mt-7 text-[11px] text-[rgba(245,243,238,0.5)]"> |
| 1351 | − contact@ — projets & partenariats · info@ — médias & | |
| 1352 | − questions générales · admin@ — légal & vie privée | |
| 1348 | + partenariats · données & API · investisseurs · carrières · | |
| 1349 | + fournisseurs · médias · légal & Loi 25 · sécurité — chaque | |
| 1350 | + demande reçoit un numéro de suivi | |
| 1353 | 1351 | </p> |
| 1354 | 1352 | </div> |
| 1355 | 1353 | </section> |
modified
src/app/retrait/page.tsx
+18 −18
@@ -115,9 +115,9 @@ export default function Retrait() { | ||
| 115 | 115 | Vrai-Prix et ValoPlex. |
| 116 | 116 | </p> |
| 117 | 117 | <p className="rise mt-4 max-w-2xl text-[15px] text-ink-2 [animation-delay:0.2s]"> |
| 118 | − Toutes les demandes se font par courriel à{" "} | |
| 119 | − <a className={MAILTO} href="mailto:admin@groupe-ka.com"> | |
| 120 | − admin@groupe-ka.com | |
| 118 | + Toutes les demandes se font via le{" "} | |
| 119 | + <a className={MAILTO} href="/contact/legal"> | |
| 120 | + formulaire Légal, vie privée & Loi 25 | |
| 121 | 121 | </a>{" "} |
| 122 | 122 | et sont traitées dans un délai de{" "} |
| 123 | 123 | <strong className="text-ink">48 à 72 heures ouvrables</strong>. |
@@ -214,11 +214,11 @@ export default function Retrait() { | ||
| 214 | 214 | |
| 215 | 215 | <Card num="A3" title="Comment faire votre demande" index={2}> |
| 216 | 216 | <p> |
| 217 | − Envoyez un courriel à{" "} | |
| 218 | − <a className={MAILTO} href="mailto:admin@groupe-ka.com"> | |
| 219 | − admin@groupe-ka.com | |
| 217 | + Soumettez le{" "} | |
| 218 | + <a className={MAILTO} href="/contact/legal"> | |
| 219 | + formulaire Légal, vie privée & Loi 25 | |
| 220 | 220 | </a>{" "} |
| 221 | − contenant : | |
| 221 | + en indiquant : | |
| 222 | 222 | </p> |
| 223 | 223 | <Numbered |
| 224 | 224 | items={[ |
@@ -336,9 +336,9 @@ export default function Retrait() { | ||
| 336 | 336 | demander une révision. |
| 337 | 337 | </p> |
| 338 | 338 | <p> |
| 339 | − <strong className="text-ink">Comment faire :</strong> écrivez à{" "} | |
| 340 | − <a className={MAILTO} href="mailto:admin@groupe-ka.com"> | |
| 341 | − admin@groupe-ka.com | |
| 339 | + <strong className="text-ink">Comment faire :</strong> utilisez le{" "} | |
| 340 | + <a className={MAILTO} href="/contact/legal"> | |
| 341 | + formulaire Légal, vie privée & Loi 25 | |
| 342 | 342 | </a>{" "} |
| 343 | 343 | avec : |
| 344 | 344 | </p> |
@@ -393,9 +393,9 @@ export default function Retrait() { | ||
| 393 | 393 | votre décision. |
| 394 | 394 | </p> |
| 395 | 395 | <p> |
| 396 | − <strong className="text-ink">Comment faire :</strong> écrivez à{" "} | |
| 397 | − <a className={MAILTO} href="mailto:admin@groupe-ka.com"> | |
| 398 | − admin@groupe-ka.com | |
| 396 | + <strong className="text-ink">Comment faire :</strong> utilisez le{" "} | |
| 397 | + <a className={MAILTO} href="/contact/legal"> | |
| 398 | + formulaire Légal, vie privée & Loi 25 | |
| 399 | 399 | </a>{" "} |
| 400 | 400 | avec : |
| 401 | 401 | </p> |
@@ -487,9 +487,9 @@ export default function Retrait() { | ||
| 487 | 487 | <strong className="text-ink">Refus :</strong> tout refus est |
| 488 | 488 | motivé par écrit. Vous pouvez alors compléter votre demande ou |
| 489 | 489 | la contester auprès de notre responsable de la protection des |
| 490 | − renseignements personnels ( | |
| 491 | − <a className={MAILTO} href="mailto:admin@groupe-ka.com"> | |
| 492 | − admin@groupe-ka.com | |
| 490 | + renseignements personnels (via le{" "} | |
| 491 | + <a className={MAILTO} href="/contact/legal"> | |
| 492 | + formulaire légal & vie privée | |
| 493 | 493 | </a> |
| 494 | 494 | ). |
| 495 | 495 | </Bullet> |
@@ -532,10 +532,10 @@ export default function Retrait() { | ||
| 532 | 532 | </a> |
| 533 | 533 | . Contact :{" "} |
| 534 | 534 | <a |
| 535 | − href="mailto:admin@groupe-ka.com" | |
| 535 | + href="/contact/legal" | |
| 536 | 536 | className="underline underline-offset-4 hover:text-ink" |
| 537 | 537 | > |
| 538 | − admin@groupe-ka.com | |
| 538 | + le formulaire Légal, vie privée & Loi 25 | |
| 539 | 539 | </a> |
| 540 | 540 | </p> |
| 541 | 541 | </main> |
modified
src/app/robots.ts
+1 −1
@@ -9,7 +9,7 @@ export default function robots(): MetadataRoute.Robots { | ||
| 9 | 9 | userAgent: "*", |
| 10 | 10 | allow: "/", |
| 11 | 11 | // Routes techniques ou de compte : rien à indexer. |
| 12 | − disallow: ["/api/", "/compte", "/sso"], | |
| 12 | + disallow: ["/api/", "/compte", "/sso", "/admin"], | |
| 13 | 13 | }, |
| 14 | 14 | ], |
| 15 | 15 | sitemap: "https://www.groupe-ka.com/sitemap.xml", |
modified
src/app/sitemap.ts
+10 −0
@@ -14,6 +14,16 @@ export default function sitemap(): MetadataRoute.Sitemap { | ||
| 14 | 14 | priority: number; |
| 15 | 15 | }[] = [ |
| 16 | 16 | { path: "/", changeFrequency: "daily", priority: 1 }, |
| 17 | + { path: "/contact", changeFrequency: "monthly", priority: 0.8 }, | |
| 18 | + { path: "/contact/partenariats", changeFrequency: "monthly", priority: 0.6 }, | |
| 19 | + { path: "/contact/data", changeFrequency: "monthly", priority: 0.6 }, | |
| 20 | + { path: "/contact/investisseurs", changeFrequency: "monthly", priority: 0.6 }, | |
| 21 | + { path: "/contact/carrieres", changeFrequency: "monthly", priority: 0.6 }, | |
| 22 | + { path: "/contact/fournisseurs", changeFrequency: "monthly", priority: 0.5 }, | |
| 23 | + { path: "/contact/medias", changeFrequency: "monthly", priority: 0.5 }, | |
| 24 | + { path: "/contact/general", changeFrequency: "monthly", priority: 0.5 }, | |
| 25 | + { path: "/contact/legal", changeFrequency: "monthly", priority: 0.5 }, | |
| 26 | + { path: "/contact/securite", changeFrequency: "monthly", priority: 0.5 }, | |
| 17 | 27 | { path: "/recherche", changeFrequency: "weekly", priority: 0.7 }, |
| 18 | 28 | { path: "/stats", changeFrequency: "daily", priority: 0.8 }, |
| 19 | 29 | { path: "/rapports", changeFrequency: "daily", priority: 0.7 }, |
added
src/lib/comms/admin-auth.ts
+130 −0
@@ -0,0 +1,130 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// KA Communication Hub — sessions admin (/admin) + RBAC côté serveur. | |
| 3 | +// Cookie distinct de ka_session : le panel est un outil interne, ses comptes | |
| 4 | +// (admin_users) ne sont pas des KA ID. Le jeton embarque une empreinte du | |
| 5 | +// mot de passe : changer le mot de passe invalide toutes les sessions. | |
| 6 | +import { SignJWT, jwtVerify } from "jose"; | |
| 7 | +import { cookies } from "next/headers"; | |
| 8 | +import crypto from "crypto"; | |
| 9 | +import { findAdminById, type AdminRow, type SubmissionRow } from "./db"; | |
| 10 | + | |
| 11 | +export const ADMIN_COOKIE = "ka_admin"; | |
| 12 | +const ISSUER = "https://www.groupe-ka.com/admin"; | |
| 13 | + | |
| 14 | +function secret(): Uint8Array { | |
| 15 | + const s = process.env.KA_AUTH_SECRET; | |
| 16 | + if (!s) { | |
| 17 | + if (process.env.NODE_ENV === "production") | |
| 18 | + throw new Error("KA_AUTH_SECRET manquant"); | |
| 19 | + return new TextEncoder().encode("ka-dev-secret-non-production"); | |
| 20 | + } | |
| 21 | + return new TextEncoder().encode(s); | |
| 22 | +} | |
| 23 | + | |
| 24 | +function pwFingerprint(hash: string): string { | |
| 25 | + return crypto.createHash("sha256").update(hash).digest("hex").slice(0, 12); | |
| 26 | +} | |
| 27 | + | |
| 28 | +/* ---------- rôles ---------- */ | |
| 29 | + | |
| 30 | +export { ADMIN_ROLES } from "./categories"; | |
| 31 | + | |
| 32 | +/** Catégories sensibles → rôle dédié requis (en plus de super_admin). */ | |
| 33 | +export const CATEGORY_ROLE: Record<string, string> = { | |
| 34 | + investisseurs: "investor_relations", | |
| 35 | + legal: "legal", | |
| 36 | + securite: "security", | |
| 37 | + carrieres: "hr", | |
| 38 | + medias: "media", | |
| 39 | +}; | |
| 40 | + | |
| 41 | +/** Catégories dont l'accès est limité même pour le rôle « admin ». */ | |
| 42 | +export const SENSITIVE_CATEGORIES = new Set(["investisseurs", "legal", "securite"]); | |
| 43 | + | |
| 44 | +export type AdminSession = { | |
| 45 | + id: number; | |
| 46 | + username: string; | |
| 47 | + displayName: string; | |
| 48 | + roles: string[]; | |
| 49 | +}; | |
| 50 | + | |
| 51 | +export function parseRoles(raw: string): string[] { | |
| 52 | + try { | |
| 53 | + const arr = JSON.parse(raw) as unknown; | |
| 54 | + return Array.isArray(arr) ? arr.filter((r): r is string => typeof r === "string") : []; | |
| 55 | + } catch { | |
| 56 | + return []; | |
| 57 | + } | |
| 58 | +} | |
| 59 | + | |
| 60 | +export async function mintAdminToken(admin: AdminRow): Promise<string> { | |
| 61 | + return await new SignJWT({ adm: admin.id, pwv: pwFingerprint(admin.password_hash) }) | |
| 62 | + .setProtectedHeader({ alg: "HS256" }) | |
| 63 | + .setIssuedAt() | |
| 64 | + .setIssuer(ISSUER) | |
| 65 | + .setExpirationTime("12h") | |
| 66 | + .sign(secret()); | |
| 67 | +} | |
| 68 | + | |
| 69 | +export const adminCookieOptions = { | |
| 70 | + httpOnly: true, | |
| 71 | + secure: process.env.NODE_ENV === "production", | |
| 72 | + sameSite: "lax" as const, | |
| 73 | + path: "/", | |
| 74 | + maxAge: 60 * 60 * 12, | |
| 75 | +}; | |
| 76 | + | |
| 77 | +export async function getAdminSession(): Promise<AdminSession | null> { | |
| 78 | + const jar = await cookies(); | |
| 79 | + const token = jar.get(ADMIN_COOKIE)?.value; | |
| 80 | + if (!token) return null; | |
| 81 | + try { | |
| 82 | + const { payload } = await jwtVerify(token, secret(), { issuer: ISSUER }); | |
| 83 | + const row = findAdminById(Number(payload.adm)); | |
| 84 | + if (!row || !row.active) return null; | |
| 85 | + // Le mot de passe a changé depuis l'émission → session invalide. | |
| 86 | + if (payload.pwv !== pwFingerprint(row.password_hash)) return null; | |
| 87 | + return { | |
| 88 | + id: row.id, | |
| 89 | + username: row.username, | |
| 90 | + displayName: row.display_name, | |
| 91 | + roles: parseRoles(row.roles), | |
| 92 | + }; | |
| 93 | + } catch { | |
| 94 | + return null; | |
| 95 | + } | |
| 96 | +} | |
| 97 | + | |
| 98 | +/* ---------- règles d'accès (appliquées SERVEUR, jamais confiance client) ---------- */ | |
| 99 | + | |
| 100 | +export function isSuperAdmin(s: AdminSession): boolean { | |
| 101 | + return s.roles.includes("super_admin"); | |
| 102 | +} | |
| 103 | + | |
| 104 | +/** L'admin peut-il voir les demandes de cette catégorie ? */ | |
| 105 | +export function canViewCategory(s: AdminSession, category: string): boolean { | |
| 106 | + if (isSuperAdmin(s)) return true; | |
| 107 | + const dedicated = CATEGORY_ROLE[category]; | |
| 108 | + if (dedicated && s.roles.includes(dedicated)) return true; | |
| 109 | + if (SENSITIVE_CATEGORIES.has(category)) | |
| 110 | + // Sensible sans rôle dédié : seul « admin » passe (le niveau | |
| 111 | + // « restreinte » est ensuite re-filtré par canViewSubmission). | |
| 112 | + return s.roles.includes("admin"); | |
| 113 | + return s.roles.includes("admin") || s.roles.includes("support"); | |
| 114 | +} | |
| 115 | + | |
| 116 | +/** Contrôle d'accès PAR demande (catégorie + niveau de confidentialité). */ | |
| 117 | +export function canViewSubmission(s: AdminSession, sub: SubmissionRow): boolean { | |
| 118 | + if (!canViewCategory(s, sub.category)) return false; | |
| 119 | + if (sub.confidentiality === "restreinte") { | |
| 120 | + // Restreinte : super_admin ou rôle dédié de la catégorie, personne d'autre. | |
| 121 | + const dedicated = CATEGORY_ROLE[sub.category]; | |
| 122 | + return isSuperAdmin(s) || (!!dedicated && s.roles.includes(dedicated)); | |
| 123 | + } | |
| 124 | + return true; | |
| 125 | +} | |
| 126 | + | |
| 127 | +/** Liste des catégories visibles — pour filtrer les requêtes SQL de l'inbox. */ | |
| 128 | +export function visibleCategories(s: AdminSession, all: string[]): string[] { | |
| 129 | + return all.filter((c) => canViewCategory(s, c)); | |
| 130 | +} | |
added
src/lib/comms/categories.ts
+658 −0
@@ -0,0 +1,658 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// KA Communication Hub — registre des catégories de demandes. | |
| 3 | +// UNE source de vérité pour le public ET l'admin : chaque catégorie décrit | |
| 4 | +// sa carte (/contact), son formulaire (champs, validation), son préfixe de | |
| 5 | +// référence, sa confidentialité par défaut et sa règle de priorité. | |
| 6 | +// Les champs « cœur » (CORE_FIELDS) vont en colonnes SQL filtrables ; | |
| 7 | +// tout le reste va proprement dans metadata (JSON). | |
| 8 | + | |
| 9 | +export type FieldDef = { | |
| 10 | + name: string; | |
| 11 | + label: string; | |
| 12 | + type: | |
| 13 | + | "text" | |
| 14 | + | "email" | |
| 15 | + | "tel" | |
| 16 | + | "url" | |
| 17 | + | "textarea" | |
| 18 | + | "select" | |
| 19 | + | "checkbox" | |
| 20 | + | "file" | |
| 21 | + | "date"; | |
| 22 | + required?: boolean; | |
| 23 | + options?: string[]; | |
| 24 | + placeholder?: string; | |
| 25 | + help?: string; | |
| 26 | + /** demi-largeur sur desktop (grille 2 colonnes) */ | |
| 27 | + half?: boolean; | |
| 28 | + rows?: number; | |
| 29 | + maxLen?: number; | |
| 30 | + /** extensions acceptées pour type=file (ex. ".pdf,.doc,.docx") */ | |
| 31 | + accept?: string; | |
| 32 | + /** champ affiché (et requis) seulement si un autre champ a cette valeur */ | |
| 33 | + showIf?: { field: string; equals: string }; | |
| 34 | + /** pour type=checkbox : affiché seulement si la case N'EST PAS cochée */ | |
| 35 | + showIfUnchecked?: string; | |
| 36 | +}; | |
| 37 | + | |
| 38 | +export type CategoryDef = { | |
| 39 | + slug: string; | |
| 40 | + prefix: string; | |
| 41 | + title: string; | |
| 42 | + short: string; | |
| 43 | + tagline: string; | |
| 44 | + intro: string; | |
| 45 | + icon: string; | |
| 46 | + confidentiality: "standard" | "elevee"; | |
| 47 | + subCategoryFrom?: string; | |
| 48 | + fields: FieldDef[]; | |
| 49 | +}; | |
| 50 | + | |
| 51 | +/** Champs qui vont en colonnes SQL (filtrables) — le reste part en metadata. */ | |
| 52 | +export const CORE_FIELDS = new Set([ | |
| 53 | + "first_name", | |
| 54 | + "last_name", | |
| 55 | + "email", | |
| 56 | + "phone", | |
| 57 | + "organization", | |
| 58 | + "job_title", | |
| 59 | + "website", | |
| 60 | + "linkedin_url", | |
| 61 | + "github_url", | |
| 62 | + "site_concerned", | |
| 63 | + "subject", | |
| 64 | + "message", | |
| 65 | +]); | |
| 66 | + | |
| 67 | +export const SITES_KA = [ | |
| 68 | + "Groupe KA (portail)", | |
| 69 | + "Trouve·Ka", | |
| 70 | + "Lou·Ka", | |
| 71 | + "Immo·Ka", | |
| 72 | + "Vrai-Prix", | |
| 73 | + "ValoPlex", | |
| 74 | + "Auto·Ka", | |
| 75 | + "Fabri·Ka", | |
| 76 | + "Food·Ka", | |
| 77 | + "Resto·Ka", | |
| 78 | + "Sorti·Ka", | |
| 79 | + "Créa·Ka", | |
| 80 | + "API·Ka", | |
| 81 | + "Job·Ka", | |
| 82 | + "Ka·Stats", | |
| 83 | + "Plusieurs sites / tout l'écosystème", | |
| 84 | +]; | |
| 85 | + | |
| 86 | +/* ---------- gabarits de champs réutilisés ---------- */ | |
| 87 | + | |
| 88 | +const IDENT: FieldDef[] = [ | |
| 89 | + { name: "first_name", label: "Prénom", type: "text", required: true, half: true, maxLen: 80 }, | |
| 90 | + { name: "last_name", label: "Nom", type: "text", required: true, half: true, maxLen: 80 }, | |
| 91 | + { name: "email", label: "Courriel", type: "email", required: true, half: true, maxLen: 200 }, | |
| 92 | + { name: "phone", label: "Téléphone (facultatif)", type: "tel", half: true, maxLen: 40 }, | |
| 93 | +]; | |
| 94 | + | |
| 95 | +/* ---------- le registre ---------- */ | |
| 96 | + | |
| 97 | +export const CATEGORIES: CategoryDef[] = [ | |
| 98 | + { | |
| 99 | + slug: "partenariats", | |
| 100 | + prefix: "PAR", | |
| 101 | + title: "Projets & partenariats", | |
| 102 | + short: "Partenariats", | |
| 103 | + tagline: "Construire quelque chose avec Groupe KA.", | |
| 104 | + intro: | |
| 105 | + "Partenariat stratégique, distribution, collaboration technologique, projet commun — décrivez ce que vous imaginez : de vraies personnes lisent chaque demande.", | |
| 106 | + icon: "handshake", | |
| 107 | + confidentiality: "standard", | |
| 108 | + subCategoryFrom: "type_partenariat", | |
| 109 | + fields: [ | |
| 110 | + ...IDENT, | |
| 111 | + { name: "organization", label: "Organisation / entreprise", type: "text", required: true, half: true, maxLen: 160 }, | |
| 112 | + { name: "job_title", label: "Poste", type: "text", half: true, maxLen: 120 }, | |
| 113 | + { name: "website", label: "Site web", type: "url", half: true, maxLen: 300, placeholder: "https://" }, | |
| 114 | + { | |
| 115 | + name: "type_partenariat", | |
| 116 | + label: "Type de partenariat", | |
| 117 | + type: "select", | |
| 118 | + required: true, | |
| 119 | + half: true, | |
| 120 | + options: [ | |
| 121 | + "Partenariat stratégique", | |
| 122 | + "Distribution", | |
| 123 | + "Collaboration technologique", | |
| 124 | + "Données", | |
| 125 | + "Commercial", | |
| 126 | + "Publicité", | |
| 127 | + "Contenu", | |
| 128 | + "Institutionnel", | |
| 129 | + "Projet commun", | |
| 130 | + "Autre", | |
| 131 | + ], | |
| 132 | + }, | |
| 133 | + { name: "message", label: "Description du projet", type: "textarea", required: true, rows: 6, maxLen: 8000 }, | |
| 134 | + { name: "objectif", label: "Objectif recherché", type: "textarea", required: true, rows: 3, maxLen: 2000 }, | |
| 135 | + { name: "echeancier", label: "Échéancier approximatif", type: "text", half: true, maxLen: 160, placeholder: "ex. T1 2027" }, | |
| 136 | + { name: "budget", label: "Budget approximatif (facultatif)", type: "text", half: true, maxLen: 120 }, | |
| 137 | + { name: "piece_jointe", label: "Pièce jointe (facultative)", type: "file", accept: ".pdf,.doc,.docx,.png,.jpg,.jpeg,.webp", help: "PDF, Word ou image — 10 Mo max." }, | |
| 138 | + ], | |
| 139 | + }, | |
| 140 | + { | |
| 141 | + slug: "data", | |
| 142 | + prefix: "DAT", | |
| 143 | + title: "Données, API & intégrations", | |
| 144 | + short: "Données & API", | |
| 145 | + tagline: "Accéder à nos données ou les intégrer.", | |
| 146 | + intro: | |
| 147 | + "Accès API, licence de données, dataset, partage ou intégration technique — dites-nous ce dont vous avez besoin, sur quels produits, et l'usage prévu.", | |
| 148 | + icon: "data", | |
| 149 | + confidentiality: "standard", | |
| 150 | + subCategoryFrom: "type_demande", | |
| 151 | + fields: [ | |
| 152 | + { name: "first_name", label: "Prénom", type: "text", required: true, half: true, maxLen: 80 }, | |
| 153 | + { name: "last_name", label: "Nom", type: "text", required: true, half: true, maxLen: 80 }, | |
| 154 | + { name: "organization", label: "Organisation", type: "text", required: true, half: true, maxLen: 160 }, | |
| 155 | + { name: "job_title", label: "Poste", type: "text", half: true, maxLen: 120 }, | |
| 156 | + { name: "email", label: "Courriel professionnel", type: "email", required: true, half: true, maxLen: 200 }, | |
| 157 | + { name: "website", label: "Site web", type: "url", half: true, maxLen: 300, placeholder: "https://" }, | |
| 158 | + { | |
| 159 | + name: "type_demande", | |
| 160 | + label: "Type de demande", | |
| 161 | + type: "select", | |
| 162 | + required: true, | |
| 163 | + half: true, | |
| 164 | + options: [ | |
| 165 | + "Accès API", | |
| 166 | + "Licence de données", | |
| 167 | + "Dataset", | |
| 168 | + "Partage de données", | |
| 169 | + "Intégration technique", | |
| 170 | + "Accès recherche", | |
| 171 | + "Utilisation académique", | |
| 172 | + "Autre", | |
| 173 | + ], | |
| 174 | + }, | |
| 175 | + { name: "site_concerned", label: "Produit Groupe KA concerné", type: "select", required: true, half: true, options: SITES_KA }, | |
| 176 | + { name: "volume", label: "Volume estimé", type: "text", half: true, maxLen: 160, placeholder: "ex. 50 000 requêtes / mois" }, | |
| 177 | + { | |
| 178 | + name: "usage_commercial", | |
| 179 | + label: "Usage", | |
| 180 | + type: "select", | |
| 181 | + required: true, | |
| 182 | + half: true, | |
| 183 | + options: ["Non commercial", "Commercial", "À déterminer"], | |
| 184 | + }, | |
| 185 | + { name: "usage_prevu", label: "Usage prévu", type: "textarea", required: true, rows: 3, maxLen: 2000 }, | |
| 186 | + { name: "message", label: "Description détaillée", type: "textarea", required: true, rows: 6, maxLen: 8000 }, | |
| 187 | + { name: "besoin_sla", label: "Nous avons besoin d'un SLA", type: "checkbox", half: true }, | |
| 188 | + { name: "besoin_entente", label: "Nous avons besoin d'une entente contractuelle", type: "checkbox", half: true }, | |
| 189 | + ], | |
| 190 | + }, | |
| 191 | + { | |
| 192 | + slug: "investisseurs", | |
| 193 | + prefix: "INV", | |
| 194 | + title: "Investisseurs", | |
| 195 | + short: "Investisseurs", | |
| 196 | + tagline: "Discuter d'investissement ou de partenariats financiers.", | |
| 197 | + intro: | |
| 198 | + "Le Groupe KA est ouvert aux discussions avec les investisseurs privés, fonds, family offices, investisseurs stratégiques, partenaires financiers et entreprises intéressées à des collaborations ou acquisitions. Ces demandes sont traitées confidentiellement par les relations investisseurs.", | |
| 199 | + icon: "chart", | |
| 200 | + confidentiality: "elevee", | |
| 201 | + subCategoryFrom: "nature", | |
| 202 | + fields: [ | |
| 203 | + { name: "first_name", label: "Prénom", type: "text", required: true, half: true, maxLen: 80 }, | |
| 204 | + { name: "last_name", label: "Nom", type: "text", required: true, half: true, maxLen: 80 }, | |
| 205 | + { name: "organization", label: "Organisation / fonds", type: "text", required: true, half: true, maxLen: 160 }, | |
| 206 | + { name: "job_title", label: "Poste", type: "text", required: true, half: true, maxLen: 120 }, | |
| 207 | + { name: "email", label: "Courriel professionnel", type: "email", required: true, half: true, maxLen: 200 }, | |
| 208 | + { name: "phone", label: "Téléphone", type: "tel", required: true, half: true, maxLen: 40 }, | |
| 209 | + { name: "linkedin_url", label: "Site web / LinkedIn (facultatif)", type: "url", half: true, maxLen: 300, placeholder: "https://" }, | |
| 210 | + { | |
| 211 | + name: "type_investisseur", | |
| 212 | + label: "Type d'investisseur", | |
| 213 | + type: "select", | |
| 214 | + required: true, | |
| 215 | + half: true, | |
| 216 | + options: [ | |
| 217 | + "Ange", | |
| 218 | + "VC", | |
| 219 | + "Private Equity", | |
| 220 | + "Family Office", | |
| 221 | + "Corporate Venture", | |
| 222 | + "Institutionnel", | |
| 223 | + "Investisseur stratégique", | |
| 224 | + "Autre", | |
| 225 | + ], | |
| 226 | + }, | |
| 227 | + { name: "taille_typique", label: "Taille typique des investissements", type: "text", half: true, maxLen: 160, placeholder: "ex. 500 k$ – 2 M$" }, | |
| 228 | + { name: "secteurs", label: "Secteurs d'intérêt", type: "text", half: true, maxLen: 300 }, | |
| 229 | + { name: "site_concerned", label: "Société / projet Groupe KA concerné", type: "select", half: true, options: SITES_KA }, | |
| 230 | + { | |
| 231 | + name: "nature", | |
| 232 | + label: "Nature de la demande", | |
| 233 | + type: "select", | |
| 234 | + required: true, | |
| 235 | + half: true, | |
| 236 | + options: [ | |
| 237 | + "Demande d'information", | |
| 238 | + "Investissement potentiel", | |
| 239 | + "Partenariat financier", | |
| 240 | + "Acquisition", | |
| 241 | + "Joint venture", | |
| 242 | + "Introduction", | |
| 243 | + "Autre", | |
| 244 | + ], | |
| 245 | + }, | |
| 246 | + { name: "message", label: "Message", type: "textarea", required: true, rows: 6, maxLen: 8000 }, | |
| 247 | + { name: "piece_jointe", label: "Pièce jointe (facultative)", type: "file", accept: ".pdf,.doc,.docx", help: "PDF ou Word — 10 Mo max." }, | |
| 248 | + ], | |
| 249 | + }, | |
| 250 | + { | |
| 251 | + slug: "carrieres", | |
| 252 | + prefix: "JOB", | |
| 253 | + title: "Emplois & carrières", | |
| 254 | + short: "Carrières", | |
| 255 | + tagline: "Rejoindre Groupe KA.", | |
| 256 | + intro: | |
| 257 | + "Développement, IA, données, produit, design, opérations… Postulez à un poste, ou dites-nous simplement : « Je ne vois pas de poste correspondant, mais je veux travailler avec Groupe KA. » — les candidatures spontanées sont lues avec la même attention.", | |
| 258 | + icon: "career", | |
| 259 | + confidentiality: "standard", | |
| 260 | + subCategoryFrom: "type_candidature", | |
| 261 | + fields: [ | |
| 262 | + { | |
| 263 | + name: "type_candidature", | |
| 264 | + label: "Type de candidature", | |
| 265 | + type: "select", | |
| 266 | + required: true, | |
| 267 | + options: [ | |
| 268 | + "Candidature à un poste", | |
| 269 | + "Candidature spontanée — je ne vois pas de poste correspondant, mais je veux travailler avec Groupe KA", | |
| 270 | + ], | |
| 271 | + }, | |
| 272 | + ...IDENT, | |
| 273 | + { name: "ville", label: "Ville", type: "text", half: true, maxLen: 120 }, | |
| 274 | + { name: "linkedin_url", label: "LinkedIn", type: "url", half: true, maxLen: 300, placeholder: "https://linkedin.com/in/…" }, | |
| 275 | + { name: "github_url", label: "GitHub / portfolio (facultatif)", type: "url", half: true, maxLen: 300, placeholder: "https://" }, | |
| 276 | + { | |
| 277 | + name: "job_title", | |
| 278 | + label: "Poste recherché", | |
| 279 | + type: "text", | |
| 280 | + required: true, | |
| 281 | + half: true, | |
| 282 | + maxLen: 160, | |
| 283 | + placeholder: "ex. Développeur·euse TypeScript", | |
| 284 | + showIf: { field: "type_candidature", equals: "Candidature à un poste" }, | |
| 285 | + }, | |
| 286 | + { | |
| 287 | + name: "domaine", | |
| 288 | + label: "Domaine", | |
| 289 | + type: "select", | |
| 290 | + required: true, | |
| 291 | + half: true, | |
| 292 | + options: [ | |
| 293 | + "Développement logiciel", | |
| 294 | + "IA / ML", | |
| 295 | + "Data", | |
| 296 | + "Produit", | |
| 297 | + "Design", | |
| 298 | + "Marketing", | |
| 299 | + "Ventes", | |
| 300 | + "Finance", | |
| 301 | + "Immobilier", | |
| 302 | + "Opérations", | |
| 303 | + "Juridique / conformité", | |
| 304 | + "Stagiaire", | |
| 305 | + "Autre", | |
| 306 | + ], | |
| 307 | + }, | |
| 308 | + { | |
| 309 | + name: "type_emploi", | |
| 310 | + label: "Type d'emploi", | |
| 311 | + type: "select", | |
| 312 | + required: true, | |
| 313 | + half: true, | |
| 314 | + options: ["Temps plein", "Temps partiel", "Stage", "Contrat", "Freelance"], | |
| 315 | + }, | |
| 316 | + { | |
| 317 | + name: "mode_travail", | |
| 318 | + label: "Mode de travail", | |
| 319 | + type: "select", | |
| 320 | + half: true, | |
| 321 | + options: ["Télétravail", "Hybride", "Présentiel", "Indifférent"], | |
| 322 | + }, | |
| 323 | + { name: "cv", label: "CV", type: "file", required: true, accept: ".pdf,.doc,.docx", help: "PDF ou Word — 10 Mo max." }, | |
| 324 | + { name: "message", label: "Lettre ou message (facultatif)", type: "textarea", rows: 5, maxLen: 8000 }, | |
| 325 | + { name: "disponibilite", label: "Disponibilité", type: "text", half: true, maxLen: 160, placeholder: "ex. dès maintenant, 2 semaines de préavis…" }, | |
| 326 | + { name: "salaire", label: "Salaire souhaité (facultatif)", type: "text", half: true, maxLen: 120 }, | |
| 327 | + ], | |
| 328 | + }, | |
| 329 | + { | |
| 330 | + slug: "fournisseurs", | |
| 331 | + prefix: "FOU", | |
| 332 | + title: "Fournisseurs & services professionnels", | |
| 333 | + short: "Fournisseurs", | |
| 334 | + tagline: "Proposer un produit ou un service.", | |
| 335 | + intro: | |
| 336 | + "Hébergement, infrastructure, cybersécurité, données, publicité, juridique, comptabilité, consultation, recrutement, design, développement, immobilier, assurances… Présentez votre offre et sa valeur pour le groupe.", | |
| 337 | + icon: "briefcase", | |
| 338 | + confidentiality: "standard", | |
| 339 | + subCategoryFrom: "type_service", | |
| 340 | + fields: [ | |
| 341 | + { name: "organization", label: "Entreprise", type: "text", required: true, half: true, maxLen: 160 }, | |
| 342 | + { name: "first_name", label: "Prénom", type: "text", required: true, half: true, maxLen: 80 }, | |
| 343 | + { name: "last_name", label: "Nom", type: "text", required: true, half: true, maxLen: 80 }, | |
| 344 | + { name: "job_title", label: "Poste", type: "text", half: true, maxLen: 120 }, | |
| 345 | + { name: "email", label: "Courriel", type: "email", required: true, half: true, maxLen: 200 }, | |
| 346 | + { name: "phone", label: "Téléphone", type: "tel", half: true, maxLen: 40 }, | |
| 347 | + { name: "website", label: "Site web", type: "url", half: true, maxLen: 300, placeholder: "https://" }, | |
| 348 | + { | |
| 349 | + name: "type_service", | |
| 350 | + label: "Type de service", | |
| 351 | + type: "select", | |
| 352 | + required: true, | |
| 353 | + half: true, | |
| 354 | + options: [ | |
| 355 | + "Hébergement / infrastructure", | |
| 356 | + "Cybersécurité", | |
| 357 | + "Données", | |
| 358 | + "Publicité / marketing", | |
| 359 | + "Cabinet juridique", | |
| 360 | + "Comptabilité", | |
| 361 | + "Consultation", | |
| 362 | + "Recrutement", | |
| 363 | + "Design", | |
| 364 | + "Développement", | |
| 365 | + "Immobilier", | |
| 366 | + "Assurances", | |
| 367 | + "Autre", | |
| 368 | + ], | |
| 369 | + }, | |
| 370 | + { name: "site_concerned", label: "Produits Groupe KA potentiellement concernés", type: "select", half: true, options: SITES_KA }, | |
| 371 | + { name: "message", label: "Description", type: "textarea", required: true, rows: 5, maxLen: 8000 }, | |
| 372 | + { name: "proposition_valeur", label: "Proposition de valeur", type: "textarea", required: true, rows: 3, maxLen: 2000 }, | |
| 373 | + { name: "tarification", label: "Prix ou modèle tarifaire (facultatif)", type: "text", maxLen: 300 }, | |
| 374 | + { name: "documents", label: "Documents (facultatifs)", type: "file", accept: ".pdf,.doc,.docx,.png,.jpg,.jpeg,.webp", help: "PDF, Word ou image — 10 Mo max." }, | |
| 375 | + ], | |
| 376 | + }, | |
| 377 | + { | |
| 378 | + slug: "medias", | |
| 379 | + prefix: "MED", | |
| 380 | + title: "Médias & presse", | |
| 381 | + short: "Médias", | |
| 382 | + tagline: "Presse, entrevues et statistiques.", | |
| 383 | + intro: | |
| 384 | + "Entrevue, citation, données, communiqué, conférence — indiquez votre échéance : les demandes avec un délai serré sont priorisées automatiquement.", | |
| 385 | + icon: "press", | |
| 386 | + confidentiality: "standard", | |
| 387 | + subCategoryFrom: "type_demande", | |
| 388 | + fields: [ | |
| 389 | + { name: "first_name", label: "Prénom", type: "text", required: true, half: true, maxLen: 80 }, | |
| 390 | + { name: "last_name", label: "Nom", type: "text", required: true, half: true, maxLen: 80 }, | |
| 391 | + { name: "organization", label: "Média / organisation", type: "text", required: true, half: true, maxLen: 160 }, | |
| 392 | + { name: "job_title", label: "Poste", type: "text", half: true, maxLen: 120 }, | |
| 393 | + { name: "email", label: "Courriel", type: "email", required: true, half: true, maxLen: 200 }, | |
| 394 | + { name: "phone", label: "Téléphone", type: "tel", half: true, maxLen: 40 }, | |
| 395 | + { | |
| 396 | + name: "type_demande", | |
| 397 | + label: "Type de demande", | |
| 398 | + type: "select", | |
| 399 | + required: true, | |
| 400 | + half: true, | |
| 401 | + options: [ | |
| 402 | + "Entrevue", | |
| 403 | + "Citation", | |
| 404 | + "Information", | |
| 405 | + "Données / statistiques", | |
| 406 | + "Communiqué", | |
| 407 | + "Conférence", | |
| 408 | + "Autre", | |
| 409 | + ], | |
| 410 | + }, | |
| 411 | + { name: "echeance", label: "Échéance (votre deadline)", type: "date", half: true, help: "Une échéance à moins de 48 h passe automatiquement en priorité haute." }, | |
| 412 | + { name: "subject", label: "Sujet", type: "text", required: true, maxLen: 240 }, | |
| 413 | + { name: "message", label: "Message", type: "textarea", required: true, rows: 5, maxLen: 8000 }, | |
| 414 | + ], | |
| 415 | + }, | |
| 416 | + { | |
| 417 | + slug: "general", | |
| 418 | + prefix: "GEN", | |
| 419 | + title: "Questions générales", | |
| 420 | + short: "Questions", | |
| 421 | + tagline: "Une question, un commentaire, une suggestion.", | |
| 422 | + intro: | |
| 423 | + "Question sur un site, commentaire, suggestion, problème technique ou problème avec une annonce — c'est ici.", | |
| 424 | + icon: "chat", | |
| 425 | + confidentiality: "standard", | |
| 426 | + subCategoryFrom: "type_demande", | |
| 427 | + fields: [ | |
| 428 | + { name: "first_name", label: "Prénom", type: "text", required: true, half: true, maxLen: 80 }, | |
| 429 | + { name: "last_name", label: "Nom", type: "text", half: true, maxLen: 80 }, | |
| 430 | + { name: "email", label: "Courriel", type: "email", required: true, half: true, maxLen: 200 }, | |
| 431 | + { name: "site_concerned", label: "Site Groupe KA concerné", type: "select", required: true, half: true, options: SITES_KA }, | |
| 432 | + { | |
| 433 | + name: "type_demande", | |
| 434 | + label: "Type", | |
| 435 | + type: "select", | |
| 436 | + required: true, | |
| 437 | + half: true, | |
| 438 | + options: [ | |
| 439 | + "Question", | |
| 440 | + "Commentaire", | |
| 441 | + "Suggestion", | |
| 442 | + "Problème technique", | |
| 443 | + "Problème avec une annonce", | |
| 444 | + "Autre", | |
| 445 | + ], | |
| 446 | + }, | |
| 447 | + { name: "subject", label: "Sujet", type: "text", required: true, half: true, maxLen: 240 }, | |
| 448 | + { name: "message", label: "Message", type: "textarea", required: true, rows: 6, maxLen: 8000 }, | |
| 449 | + ], | |
| 450 | + }, | |
| 451 | + { | |
| 452 | + slug: "legal", | |
| 453 | + prefix: "LEG", | |
| 454 | + title: "Légal, vie privée & Loi 25", | |
| 455 | + short: "Vie privée & légal", | |
| 456 | + tagline: "Demandes juridiques et Loi 25.", | |
| 457 | + intro: | |
| 458 | + "Accès, rectification ou suppression de renseignements personnels, retrait de consentement, propriété intellectuelle, droit d'auteur, question juridique — ces demandes sont traitées en priorité et en confidentialité par les personnes responsables (Loi 25).", | |
| 459 | + icon: "shield", | |
| 460 | + confidentiality: "elevee", | |
| 461 | + subCategoryFrom: "type_demande", | |
| 462 | + fields: [ | |
| 463 | + { name: "first_name", label: "Prénom", type: "text", required: true, half: true, maxLen: 80 }, | |
| 464 | + { name: "last_name", label: "Nom", type: "text", required: true, half: true, maxLen: 80 }, | |
| 465 | + { name: "email", label: "Courriel", type: "email", required: true, half: true, maxLen: 200 }, | |
| 466 | + { name: "phone", label: "Téléphone (facultatif)", type: "tel", half: true, maxLen: 40 }, | |
| 467 | + { | |
| 468 | + name: "type_demande", | |
| 469 | + label: "Type de demande", | |
| 470 | + type: "select", | |
| 471 | + required: true, | |
| 472 | + half: true, | |
| 473 | + options: [ | |
| 474 | + "Vie privée", | |
| 475 | + "Loi 25", | |
| 476 | + "Accès aux renseignements personnels", | |
| 477 | + "Rectification", | |
| 478 | + "Suppression", | |
| 479 | + "Retrait de consentement", | |
| 480 | + "Signalement", | |
| 481 | + "Propriété intellectuelle", | |
| 482 | + "Droit d'auteur", | |
| 483 | + "Question juridique", | |
| 484 | + "Sécurité", | |
| 485 | + "Autre", | |
| 486 | + ], | |
| 487 | + }, | |
| 488 | + { name: "site_concerned", label: "Site Groupe KA concerné", type: "select", required: true, half: true, options: SITES_KA }, | |
| 489 | + { name: "message", label: "Description détaillée", type: "textarea", required: true, rows: 7, maxLen: 12000, help: "Pour un retrait ou une rectification : incluez l'URL exacte de la page concernée." }, | |
| 490 | + { name: "pieces", label: "Pièces justificatives (facultatives)", type: "file", accept: ".pdf,.doc,.docx,.png,.jpg,.jpeg,.webp", help: "PDF, Word ou image — 10 Mo max." }, | |
| 491 | + { | |
| 492 | + name: "consentement", | |
| 493 | + label: "Je consens à ce que Groupe KA traite les renseignements fournis dans ce formulaire aux seules fins du traitement de ma demande.", | |
| 494 | + type: "checkbox", | |
| 495 | + required: true, | |
| 496 | + }, | |
| 497 | + ], | |
| 498 | + }, | |
| 499 | + { | |
| 500 | + slug: "securite", | |
| 501 | + prefix: "SEC", | |
| 502 | + title: "Sécurité & signalements", | |
| 503 | + short: "Sécurité", | |
| 504 | + tagline: "Signaler un problème ou une vulnérabilité.", | |
| 505 | + intro: | |
| 506 | + "Vulnérabilité, bug critique, fraude, arnaque, faux listing, abus, usurpation d'identité, contenu illégal — signalez-le ici. La soumission peut être anonyme : ne laissez un courriel que si vous souhaitez un suivi.", | |
| 507 | + icon: "alert", | |
| 508 | + confidentiality: "elevee", | |
| 509 | + subCategoryFrom: "type_signalement", | |
| 510 | + fields: [ | |
| 511 | + { | |
| 512 | + name: "type_signalement", | |
| 513 | + label: "Type de signalement", | |
| 514 | + type: "select", | |
| 515 | + required: true, | |
| 516 | + options: [ | |
| 517 | + "Vulnérabilité de sécurité", | |
| 518 | + "Bug critique", | |
| 519 | + "Fraude", | |
| 520 | + "Tentative d'arnaque", | |
| 521 | + "Faux listing", | |
| 522 | + "Abus", | |
| 523 | + "Usurpation d'identité", | |
| 524 | + "Contenu illégal", | |
| 525 | + "Autre problème de sécurité", | |
| 526 | + ], | |
| 527 | + }, | |
| 528 | + { name: "site_concerned", label: "Site Groupe KA concerné", type: "select", required: true, half: true, options: SITES_KA }, | |
| 529 | + { name: "is_anonymous", label: "Je souhaite rester anonyme", type: "checkbox", half: true }, | |
| 530 | + { | |
| 531 | + name: "first_name", | |
| 532 | + label: "Nom (facultatif)", | |
| 533 | + type: "text", | |
| 534 | + half: true, | |
| 535 | + maxLen: 120, | |
| 536 | + showIfUnchecked: "is_anonymous", | |
| 537 | + }, | |
| 538 | + { | |
| 539 | + name: "email", | |
| 540 | + label: "Courriel — seulement si vous souhaitez un suivi", | |
| 541 | + type: "email", | |
| 542 | + half: true, | |
| 543 | + maxLen: 200, | |
| 544 | + showIfUnchecked: "is_anonymous", | |
| 545 | + }, | |
| 546 | + { name: "message", label: "Description du problème", type: "textarea", required: true, rows: 7, maxLen: 12000, help: "URL concernée, étapes de reproduction, impact observé — tout détail aide." }, | |
| 547 | + ], | |
| 548 | + }, | |
| 549 | + { | |
| 550 | + slug: "autre", | |
| 551 | + prefix: "DEM", | |
| 552 | + title: "Autre demande", | |
| 553 | + short: "Autre", | |
| 554 | + tagline: "Tout ce qui n'entre pas dans les autres cases.", | |
| 555 | + intro: | |
| 556 | + "Votre demande ne correspond à aucune catégorie ? Écrivez-nous ici — elle sera dirigée vers la bonne équipe.", | |
| 557 | + icon: "spark", | |
| 558 | + confidentiality: "standard", | |
| 559 | + fields: [ | |
| 560 | + { name: "first_name", label: "Prénom", type: "text", required: true, half: true, maxLen: 80 }, | |
| 561 | + { name: "last_name", label: "Nom", type: "text", half: true, maxLen: 80 }, | |
| 562 | + { name: "email", label: "Courriel", type: "email", required: true, half: true, maxLen: 200 }, | |
| 563 | + { name: "organization", label: "Organisation (facultative)", type: "text", half: true, maxLen: 160 }, | |
| 564 | + { name: "subject", label: "Sujet", type: "text", required: true, maxLen: 240 }, | |
| 565 | + { name: "message", label: "Message", type: "textarea", required: true, rows: 6, maxLen: 8000 }, | |
| 566 | + ], | |
| 567 | + }, | |
| 568 | +]; | |
| 569 | + | |
| 570 | +export function categoryBySlug(slug: string): CategoryDef | undefined { | |
| 571 | + return CATEGORIES.find((c) => c.slug === slug); | |
| 572 | +} | |
| 573 | + | |
| 574 | +/* ---------- priorité intelligente (centralisée côté backend) ---------- */ | |
| 575 | + | |
| 576 | +export type Priority = "basse" | "normale" | "haute" | "urgente"; | |
| 577 | + | |
| 578 | +const CRITICAL_SIGNALS = new Set([ | |
| 579 | + "Vulnérabilité de sécurité", | |
| 580 | + "Fraude", | |
| 581 | + "Usurpation d'identité", | |
| 582 | + "Contenu illégal", | |
| 583 | +]); | |
| 584 | + | |
| 585 | +/** Priorité initiale d'une soumission — ne dépend JAMAIS du frontend. */ | |
| 586 | +export function computePriority( | |
| 587 | + slug: string, | |
| 588 | + subCategory: string | null, | |
| 589 | + metadata: Record<string, unknown>, | |
| 590 | +): Priority { | |
| 591 | + if (slug === "securite") | |
| 592 | + return subCategory && CRITICAL_SIGNALS.has(subCategory) ? "urgente" : "haute"; | |
| 593 | + if (slug === "legal") return "haute"; | |
| 594 | + if (slug === "investisseurs") return "haute"; | |
| 595 | + if (slug === "medias") { | |
| 596 | + const d = typeof metadata.echeance === "string" ? metadata.echeance : ""; | |
| 597 | + if (/^\d{4}-\d{2}-\d{2}$/.test(d)) { | |
| 598 | + const delta = new Date(d + "T23:59:59").getTime() - Date.now(); | |
| 599 | + if (delta < 48 * 3600 * 1000) return "haute"; | |
| 600 | + } | |
| 601 | + return "normale"; | |
| 602 | + } | |
| 603 | + return "normale"; | |
| 604 | +} | |
| 605 | + | |
| 606 | +/* ---------- statuts ---------- */ | |
| 607 | + | |
| 608 | +export const STATUSES: Record<string, string> = { | |
| 609 | + nouvelle: "Nouvelle", | |
| 610 | + en_cours: "En cours", | |
| 611 | + en_attente: "En attente", | |
| 612 | + resolue: "Résolue", | |
| 613 | +}; | |
| 614 | + | |
| 615 | +/** Pipeline propre aux candidatures (catégorie carrieres). */ | |
| 616 | +export const JOB_STATUSES: Record<string, string> = { | |
| 617 | + nouvelle: "Nouvelle candidature", | |
| 618 | + a_analyser: "À analyser", | |
| 619 | + entrevue: "Entrevue", | |
| 620 | + a_revoir: "À revoir", | |
| 621 | + offre: "Offre", | |
| 622 | + embauchee: "Embauchée", | |
| 623 | + refusee: "Refusée", | |
| 624 | +}; | |
| 625 | + | |
| 626 | +export function statusesFor(slug: string): Record<string, string> { | |
| 627 | + return slug === "carrieres" ? JOB_STATUSES : STATUSES; | |
| 628 | +} | |
| 629 | + | |
| 630 | +/** Statuts terminaux : posent resolved_at. */ | |
| 631 | +export const TERMINAL_STATUSES = new Set(["resolue", "refusee", "embauchee"]); | |
| 632 | + | |
| 633 | +export const PRIORITIES: Record<Priority, string> = { | |
| 634 | + basse: "Basse", | |
| 635 | + normale: "Normale", | |
| 636 | + haute: "Haute", | |
| 637 | + urgente: "Urgente", | |
| 638 | +}; | |
| 639 | + | |
| 640 | +export const CONFIDENTIALITY_LEVELS: Record<string, string> = { | |
| 641 | + standard: "Standard", | |
| 642 | + elevee: "Élevée", | |
| 643 | + restreinte: "Restreinte", | |
| 644 | +}; | |
| 645 | + | |
| 646 | +/* ---------- rôles admin (libellés — la LOGIQUE d'accès vit dans | |
| 647 | + src/lib/comms/admin-auth.ts, côté serveur uniquement) ---------- */ | |
| 648 | + | |
| 649 | +export const ADMIN_ROLES: Record<string, string> = { | |
| 650 | + super_admin: "Super administrateur", | |
| 651 | + admin: "Administrateur", | |
| 652 | + support: "Support", | |
| 653 | + legal: "Légal & vie privée", | |
| 654 | + investor_relations: "Relations investisseurs", | |
| 655 | + hr: "Ressources humaines", | |
| 656 | + media: "Relations médias", | |
| 657 | + security: "Sécurité", | |
| 658 | +}; | |
added
src/lib/comms/db.ts
+606 −0
@@ -0,0 +1,606 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// KA Communication Hub — base de données du centre de communication. | |
| 3 | +// Composant transversal du Groupe KA : toutes les demandes entrantes | |
| 4 | +// (partenariats, données, investisseurs, candidatures, médias, légal, | |
| 5 | +// sécurité…) vivent ici — la DB est LA source de vérité, jamais le courriel. | |
| 6 | +// Base séparée de ka-id.db (data/ka-comms.db, SQLite WAL) : le hub identité | |
| 7 | +// reste minimal, le hub communication évolue à son rythme. | |
| 8 | +import Database from "better-sqlite3"; | |
| 9 | +import path from "path"; | |
| 10 | +import fs from "fs"; | |
| 11 | +import crypto from "crypto"; | |
| 12 | + | |
| 13 | +function open() { | |
| 14 | + const dbPath = | |
| 15 | + process.env.KA_COMMS_DB_PATH ?? | |
| 16 | + path.join(process.cwd(), "data", "ka-comms.db"); | |
| 17 | + fs.mkdirSync(path.dirname(dbPath), { recursive: true }); | |
| 18 | + const db = new Database(dbPath); | |
| 19 | + db.pragma("journal_mode = WAL"); | |
| 20 | + db.exec(` | |
| 21 | + CREATE TABLE IF NOT EXISTS contact_submissions ( | |
| 22 | + id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 23 | + reference TEXT UNIQUE NOT NULL, | |
| 24 | + category TEXT NOT NULL, | |
| 25 | + sub_category TEXT, | |
| 26 | + first_name TEXT, | |
| 27 | + last_name TEXT, | |
| 28 | + email TEXT, | |
| 29 | + phone TEXT, | |
| 30 | + organization TEXT, | |
| 31 | + job_title TEXT, | |
| 32 | + website TEXT, | |
| 33 | + linkedin_url TEXT, | |
| 34 | + github_url TEXT, | |
| 35 | + site_concerned TEXT, | |
| 36 | + subject TEXT, | |
| 37 | + message TEXT, | |
| 38 | + metadata TEXT, | |
| 39 | + status TEXT NOT NULL DEFAULT 'nouvelle', | |
| 40 | + priority TEXT NOT NULL DEFAULT 'normale', | |
| 41 | + confidentiality TEXT NOT NULL DEFAULT 'standard', | |
| 42 | + assigned_to INTEGER, | |
| 43 | + tags TEXT, | |
| 44 | + source TEXT NOT NULL DEFAULT 'web', | |
| 45 | + user_id INTEGER, | |
| 46 | + ka_id TEXT, | |
| 47 | + is_anonymous INTEGER NOT NULL DEFAULT 0, | |
| 48 | + is_read INTEGER NOT NULL DEFAULT 0, | |
| 49 | + is_spam INTEGER NOT NULL DEFAULT 0, | |
| 50 | + is_archived INTEGER NOT NULL DEFAULT 0, | |
| 51 | + ai_category TEXT, | |
| 52 | + ai_priority TEXT, | |
| 53 | + ai_summary TEXT, | |
| 54 | + ai_confidence REAL, | |
| 55 | + created_at TEXT NOT NULL DEFAULT (datetime('now')), | |
| 56 | + updated_at TEXT NOT NULL DEFAULT (datetime('now')), | |
| 57 | + first_response_at TEXT, | |
| 58 | + resolved_at TEXT | |
| 59 | + ); | |
| 60 | + CREATE INDEX IF NOT EXISTS cs_category ON contact_submissions(category, created_at); | |
| 61 | + CREATE INDEX IF NOT EXISTS cs_status ON contact_submissions(status); | |
| 62 | + CREATE INDEX IF NOT EXISTS cs_user ON contact_submissions(user_id); | |
| 63 | + CREATE INDEX IF NOT EXISTS cs_read ON contact_submissions(is_read); | |
| 64 | + | |
| 65 | + -- Compteurs de références publiques (KA-INV-2026-0012) : un compteur | |
| 66 | + -- par préfixe et par année — jamais d'ID interne exposé. | |
| 67 | + CREATE TABLE IF NOT EXISTS contact_counters ( | |
| 68 | + prefix TEXT NOT NULL, | |
| 69 | + year INTEGER NOT NULL, | |
| 70 | + n INTEGER NOT NULL DEFAULT 0, | |
| 71 | + PRIMARY KEY (prefix, year) | |
| 72 | + ); | |
| 73 | + | |
| 74 | + -- Pièces jointes : stockées sous data/contact-uploads/, nom aléatoire, | |
| 75 | + -- servies UNIQUEMENT par la route admin authentifiée. | |
| 76 | + CREATE TABLE IF NOT EXISTS contact_attachments ( | |
| 77 | + id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 78 | + submission_id INTEGER NOT NULL, | |
| 79 | + field TEXT NOT NULL, | |
| 80 | + original_name TEXT NOT NULL, | |
| 81 | + stored_name TEXT NOT NULL, | |
| 82 | + mime TEXT NOT NULL, | |
| 83 | + size INTEGER NOT NULL, | |
| 84 | + created_at TEXT NOT NULL DEFAULT (datetime('now')) | |
| 85 | + ); | |
| 86 | + CREATE INDEX IF NOT EXISTS ca_submission ON contact_attachments(submission_id); | |
| 87 | + | |
| 88 | + -- Notes internes : jamais visibles par le demandeur. | |
| 89 | + CREATE TABLE IF NOT EXISTS contact_notes ( | |
| 90 | + id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 91 | + submission_id INTEGER NOT NULL, | |
| 92 | + admin_id INTEGER NOT NULL, | |
| 93 | + body TEXT NOT NULL, | |
| 94 | + created_at TEXT NOT NULL DEFAULT (datetime('now')) | |
| 95 | + ); | |
| 96 | + CREATE INDEX IF NOT EXISTS cn_submission ON contact_notes(submission_id); | |
| 97 | + | |
| 98 | + -- Conversation : messages échangés dans une demande (admin ↔ demandeur). | |
| 99 | + -- La DB reste la source de vérité ; le courriel (Resend) n'est qu'un canal. | |
| 100 | + CREATE TABLE IF NOT EXISTS contact_messages ( | |
| 101 | + id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 102 | + submission_id INTEGER NOT NULL, | |
| 103 | + sender_type TEXT NOT NULL, -- 'admin' | 'user' | 'system' | |
| 104 | + sender_admin_id INTEGER, | |
| 105 | + sender_user_id INTEGER, | |
| 106 | + body TEXT NOT NULL, | |
| 107 | + channel TEXT NOT NULL DEFAULT 'email', | |
| 108 | + provider_message_id TEXT, | |
| 109 | + created_at TEXT NOT NULL DEFAULT (datetime('now')) | |
| 110 | + ); | |
| 111 | + CREATE INDEX IF NOT EXISTS cm_submission ON contact_messages(submission_id); | |
| 112 | + | |
| 113 | + -- Historique d'activité (workflow) : chaque changement est journalisé. | |
| 114 | + CREATE TABLE IF NOT EXISTS contact_events ( | |
| 115 | + id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 116 | + submission_id INTEGER NOT NULL, | |
| 117 | + admin_id INTEGER, | |
| 118 | + kind TEXT NOT NULL, | |
| 119 | + detail TEXT, | |
| 120 | + created_at TEXT NOT NULL DEFAULT (datetime('now')) | |
| 121 | + ); | |
| 122 | + CREATE INDEX IF NOT EXISTS ce_submission ON contact_events(submission_id); | |
| 123 | + | |
| 124 | + -- Comptes administrateurs du panel /admin — RBAC par rôles (JSON). | |
| 125 | + -- Distincts des KA ID : le panel est un outil interne. | |
| 126 | + CREATE TABLE IF NOT EXISTS admin_users ( | |
| 127 | + id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 128 | + username TEXT UNIQUE NOT NULL, | |
| 129 | + display_name TEXT NOT NULL, | |
| 130 | + email TEXT, | |
| 131 | + password_hash TEXT NOT NULL, | |
| 132 | + roles TEXT NOT NULL DEFAULT '["support"]', | |
| 133 | + active INTEGER NOT NULL DEFAULT 1, | |
| 134 | + created_at TEXT NOT NULL DEFAULT (datetime('now')), | |
| 135 | + last_login TEXT | |
| 136 | + ); | |
| 137 | + `); | |
| 138 | + return db; | |
| 139 | +} | |
| 140 | + | |
| 141 | +// Singleton (survit au rechargement à chaud en dev). | |
| 142 | +const g = globalThis as unknown as { __kaCommsDb?: Database.Database }; | |
| 143 | +export const commsDb: Database.Database = g.__kaCommsDb ?? (g.__kaCommsDb = open()); | |
| 144 | + | |
| 145 | +/* ---------- types ---------- */ | |
| 146 | + | |
| 147 | +export type SubmissionRow = { | |
| 148 | + id: number; | |
| 149 | + reference: string; | |
| 150 | + category: string; | |
| 151 | + sub_category: string | null; | |
| 152 | + first_name: string | null; | |
| 153 | + last_name: string | null; | |
| 154 | + email: string | null; | |
| 155 | + phone: string | null; | |
| 156 | + organization: string | null; | |
| 157 | + job_title: string | null; | |
| 158 | + website: string | null; | |
| 159 | + linkedin_url: string | null; | |
| 160 | + github_url: string | null; | |
| 161 | + site_concerned: string | null; | |
| 162 | + subject: string | null; | |
| 163 | + message: string | null; | |
| 164 | + metadata: string | null; | |
| 165 | + status: string; | |
| 166 | + priority: string; | |
| 167 | + confidentiality: string; | |
| 168 | + assigned_to: number | null; | |
| 169 | + tags: string | null; | |
| 170 | + source: string; | |
| 171 | + user_id: number | null; | |
| 172 | + ka_id: string | null; | |
| 173 | + is_anonymous: number; | |
| 174 | + is_read: number; | |
| 175 | + is_spam: number; | |
| 176 | + is_archived: number; | |
| 177 | + ai_category: string | null; | |
| 178 | + ai_priority: string | null; | |
| 179 | + ai_summary: string | null; | |
| 180 | + ai_confidence: number | null; | |
| 181 | + created_at: string; | |
| 182 | + updated_at: string; | |
| 183 | + first_response_at: string | null; | |
| 184 | + resolved_at: string | null; | |
| 185 | +}; | |
| 186 | + | |
| 187 | +export type AttachmentRow = { | |
| 188 | + id: number; | |
| 189 | + submission_id: number; | |
| 190 | + field: string; | |
| 191 | + original_name: string; | |
| 192 | + stored_name: string; | |
| 193 | + mime: string; | |
| 194 | + size: number; | |
| 195 | + created_at: string; | |
| 196 | +}; | |
| 197 | + | |
| 198 | +export type NoteRow = { | |
| 199 | + id: number; | |
| 200 | + submission_id: number; | |
| 201 | + admin_id: number; | |
| 202 | + body: string; | |
| 203 | + created_at: string; | |
| 204 | +}; | |
| 205 | + | |
| 206 | +export type MessageRow = { | |
| 207 | + id: number; | |
| 208 | + submission_id: number; | |
| 209 | + sender_type: string; | |
| 210 | + sender_admin_id: number | null; | |
| 211 | + sender_user_id: number | null; | |
| 212 | + body: string; | |
| 213 | + channel: string; | |
| 214 | + provider_message_id: string | null; | |
| 215 | + created_at: string; | |
| 216 | +}; | |
| 217 | + | |
| 218 | +export type EventRow = { | |
| 219 | + id: number; | |
| 220 | + submission_id: number; | |
| 221 | + admin_id: number | null; | |
| 222 | + kind: string; | |
| 223 | + detail: string | null; | |
| 224 | + created_at: string; | |
| 225 | +}; | |
| 226 | + | |
| 227 | +export type AdminRow = { | |
| 228 | + id: number; | |
| 229 | + username: string; | |
| 230 | + display_name: string; | |
| 231 | + email: string | null; | |
| 232 | + password_hash: string; | |
| 233 | + roles: string; | |
| 234 | + active: number; | |
| 235 | + created_at: string; | |
| 236 | + last_login: string | null; | |
| 237 | +}; | |
| 238 | + | |
| 239 | +/* ---------- référence publique ---------- */ | |
| 240 | + | |
| 241 | +/** Référence humaine du type KA-INV-2026-0012 — jamais l'ID interne. */ | |
| 242 | +export function nextReference(prefix: string): string { | |
| 243 | + const year = new Date().getFullYear(); | |
| 244 | + const bump = commsDb.transaction(() => { | |
| 245 | + commsDb | |
| 246 | + .prepare( | |
| 247 | + `INSERT INTO contact_counters (prefix, year, n) VALUES (?, ?, 1) | |
| 248 | + ON CONFLICT(prefix, year) DO UPDATE SET n = n + 1`, | |
| 249 | + ) | |
| 250 | + .run(prefix, year); | |
| 251 | + return ( | |
| 252 | + commsDb | |
| 253 | + .prepare("SELECT n FROM contact_counters WHERE prefix = ? AND year = ?") | |
| 254 | + .get(prefix, year) as { n: number } | |
| 255 | + ).n; | |
| 256 | + }); | |
| 257 | + const n = bump(); | |
| 258 | + return `KA-${prefix}-${year}-${String(n).padStart(4, "0")}`; | |
| 259 | +} | |
| 260 | + | |
| 261 | +/* ---------- soumissions ---------- */ | |
| 262 | + | |
| 263 | +export function insertSubmission(s: { | |
| 264 | + reference: string; | |
| 265 | + category: string; | |
| 266 | + sub_category?: string | null; | |
| 267 | + first_name?: string | null; | |
| 268 | + last_name?: string | null; | |
| 269 | + email?: string | null; | |
| 270 | + phone?: string | null; | |
| 271 | + organization?: string | null; | |
| 272 | + job_title?: string | null; | |
| 273 | + website?: string | null; | |
| 274 | + linkedin_url?: string | null; | |
| 275 | + github_url?: string | null; | |
| 276 | + site_concerned?: string | null; | |
| 277 | + subject?: string | null; | |
| 278 | + message?: string | null; | |
| 279 | + metadata?: unknown; | |
| 280 | + priority: string; | |
| 281 | + confidentiality: string; | |
| 282 | + user_id?: number | null; | |
| 283 | + ka_id?: string | null; | |
| 284 | + is_anonymous?: boolean; | |
| 285 | + source?: string; | |
| 286 | +}): number { | |
| 287 | + const info = commsDb | |
| 288 | + .prepare( | |
| 289 | + `INSERT INTO contact_submissions | |
| 290 | + (reference, category, sub_category, first_name, last_name, email, phone, | |
| 291 | + organization, job_title, website, linkedin_url, github_url, | |
| 292 | + site_concerned, subject, message, metadata, priority, confidentiality, | |
| 293 | + user_id, ka_id, is_anonymous, source) | |
| 294 | + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, | |
| 295 | + ) | |
| 296 | + .run( | |
| 297 | + s.reference, | |
| 298 | + s.category, | |
| 299 | + s.sub_category ?? null, | |
| 300 | + s.first_name ?? null, | |
| 301 | + s.last_name ?? null, | |
| 302 | + s.email ?? null, | |
| 303 | + s.phone ?? null, | |
| 304 | + s.organization ?? null, | |
| 305 | + s.job_title ?? null, | |
| 306 | + s.website ?? null, | |
| 307 | + s.linkedin_url ?? null, | |
| 308 | + s.github_url ?? null, | |
| 309 | + s.site_concerned ?? null, | |
| 310 | + s.subject ?? null, | |
| 311 | + s.message ?? null, | |
| 312 | + s.metadata != null ? JSON.stringify(s.metadata) : null, | |
| 313 | + s.priority, | |
| 314 | + s.confidentiality, | |
| 315 | + s.user_id ?? null, | |
| 316 | + s.ka_id ?? null, | |
| 317 | + s.is_anonymous ? 1 : 0, | |
| 318 | + s.source ?? "web", | |
| 319 | + ); | |
| 320 | + return Number(info.lastInsertRowid); | |
| 321 | +} | |
| 322 | + | |
| 323 | +export function findSubmissionByRef(ref: string): SubmissionRow | undefined { | |
| 324 | + return commsDb | |
| 325 | + .prepare("SELECT * FROM contact_submissions WHERE reference = ?") | |
| 326 | + .get(ref) as SubmissionRow | undefined; | |
| 327 | +} | |
| 328 | + | |
| 329 | +export function findSubmissionById(id: number): SubmissionRow | undefined { | |
| 330 | + return commsDb | |
| 331 | + .prepare("SELECT * FROM contact_submissions WHERE id = ?") | |
| 332 | + .get(id) as SubmissionRow | undefined; | |
| 333 | +} | |
| 334 | + | |
| 335 | +/** Champs modifiables depuis le panel — liste blanche stricte (anti-IDOR). */ | |
| 336 | +const UPDATABLE = new Set([ | |
| 337 | + "status", | |
| 338 | + "priority", | |
| 339 | + "confidentiality", | |
| 340 | + "assigned_to", | |
| 341 | + "tags", | |
| 342 | + "is_read", | |
| 343 | + "is_spam", | |
| 344 | + "is_archived", | |
| 345 | +]); | |
| 346 | + | |
| 347 | +export function updateSubmission( | |
| 348 | + id: number, | |
| 349 | + patch: Record<string, unknown>, | |
| 350 | +): void { | |
| 351 | + const keys = Object.keys(patch).filter((k) => UPDATABLE.has(k)); | |
| 352 | + if (!keys.length) return; | |
| 353 | + const sets = keys.map((k) => `${k} = ?`).join(", "); | |
| 354 | + commsDb | |
| 355 | + .prepare( | |
| 356 | + `UPDATE contact_submissions SET ${sets}, updated_at = datetime('now') WHERE id = ?`, | |
| 357 | + ) | |
| 358 | + .run(...keys.map((k) => patch[k] as string | number | null), id); | |
| 359 | +} | |
| 360 | + | |
| 361 | +export function markResolved(id: number, resolved: boolean): void { | |
| 362 | + commsDb | |
| 363 | + .prepare( | |
| 364 | + `UPDATE contact_submissions | |
| 365 | + SET resolved_at = ${resolved ? "datetime('now')" : "NULL"}, | |
| 366 | + updated_at = datetime('now') | |
| 367 | + WHERE id = ?`, | |
| 368 | + ) | |
| 369 | + .run(id); | |
| 370 | +} | |
| 371 | + | |
| 372 | +export function touchFirstResponse(id: number): void { | |
| 373 | + commsDb | |
| 374 | + .prepare( | |
| 375 | + `UPDATE contact_submissions | |
| 376 | + SET first_response_at = COALESCE(first_response_at, datetime('now')), | |
| 377 | + updated_at = datetime('now') | |
| 378 | + WHERE id = ?`, | |
| 379 | + ) | |
| 380 | + .run(id); | |
| 381 | +} | |
| 382 | + | |
| 383 | +/* ---------- pièces jointes / notes / messages / événements ---------- */ | |
| 384 | + | |
| 385 | +export function insertAttachment(a: { | |
| 386 | + submission_id: number; | |
| 387 | + field: string; | |
| 388 | + original_name: string; | |
| 389 | + stored_name: string; | |
| 390 | + mime: string; | |
| 391 | + size: number; | |
| 392 | +}): void { | |
| 393 | + commsDb | |
| 394 | + .prepare( | |
| 395 | + `INSERT INTO contact_attachments | |
| 396 | + (submission_id, field, original_name, stored_name, mime, size) | |
| 397 | + VALUES (?, ?, ?, ?, ?, ?)`, | |
| 398 | + ) | |
| 399 | + .run(a.submission_id, a.field, a.original_name, a.stored_name, a.mime, a.size); | |
| 400 | +} | |
| 401 | + | |
| 402 | +export function attachmentsOf(submissionId: number): AttachmentRow[] { | |
| 403 | + return commsDb | |
| 404 | + .prepare("SELECT * FROM contact_attachments WHERE submission_id = ?") | |
| 405 | + .all(submissionId) as AttachmentRow[]; | |
| 406 | +} | |
| 407 | + | |
| 408 | +export function findAttachment(id: number): AttachmentRow | undefined { | |
| 409 | + return commsDb | |
| 410 | + .prepare("SELECT * FROM contact_attachments WHERE id = ?") | |
| 411 | + .get(id) as AttachmentRow | undefined; | |
| 412 | +} | |
| 413 | + | |
| 414 | +export function insertNote(submissionId: number, adminId: number, body: string): void { | |
| 415 | + commsDb | |
| 416 | + .prepare( | |
| 417 | + "INSERT INTO contact_notes (submission_id, admin_id, body) VALUES (?, ?, ?)", | |
| 418 | + ) | |
| 419 | + .run(submissionId, adminId, body); | |
| 420 | +} | |
| 421 | + | |
| 422 | +export function notesOf(submissionId: number): (NoteRow & { author: string })[] { | |
| 423 | + return commsDb | |
| 424 | + .prepare( | |
| 425 | + `SELECT n.*, COALESCE(a.display_name, 'Admin retiré') AS author | |
| 426 | + FROM contact_notes n LEFT JOIN admin_users a ON a.id = n.admin_id | |
| 427 | + WHERE n.submission_id = ? ORDER BY n.created_at DESC`, | |
| 428 | + ) | |
| 429 | + .all(submissionId) as (NoteRow & { author: string })[]; | |
| 430 | +} | |
| 431 | + | |
| 432 | +export function insertMessage(m: { | |
| 433 | + submission_id: number; | |
| 434 | + sender_type: "admin" | "user" | "system"; | |
| 435 | + sender_admin_id?: number | null; | |
| 436 | + sender_user_id?: number | null; | |
| 437 | + body: string; | |
| 438 | + channel?: string; | |
| 439 | + provider_message_id?: string | null; | |
| 440 | +}): void { | |
| 441 | + commsDb | |
| 442 | + .prepare( | |
| 443 | + `INSERT INTO contact_messages | |
| 444 | + (submission_id, sender_type, sender_admin_id, sender_user_id, body, | |
| 445 | + channel, provider_message_id) | |
| 446 | + VALUES (?, ?, ?, ?, ?, ?, ?)`, | |
| 447 | + ) | |
| 448 | + .run( | |
| 449 | + m.submission_id, | |
| 450 | + m.sender_type, | |
| 451 | + m.sender_admin_id ?? null, | |
| 452 | + m.sender_user_id ?? null, | |
| 453 | + m.body, | |
| 454 | + m.channel ?? "email", | |
| 455 | + m.provider_message_id ?? null, | |
| 456 | + ); | |
| 457 | +} | |
| 458 | + | |
| 459 | +export function messagesOf( | |
| 460 | + submissionId: number, | |
| 461 | +): (MessageRow & { author: string | null })[] { | |
| 462 | + return commsDb | |
| 463 | + .prepare( | |
| 464 | + `SELECT m.*, a.display_name AS author | |
| 465 | + FROM contact_messages m LEFT JOIN admin_users a ON a.id = m.sender_admin_id | |
| 466 | + WHERE m.submission_id = ? ORDER BY m.created_at ASC`, | |
| 467 | + ) | |
| 468 | + .all(submissionId) as (MessageRow & { author: string | null })[]; | |
| 469 | +} | |
| 470 | + | |
| 471 | +export function logEvent( | |
| 472 | + submissionId: number, | |
| 473 | + adminId: number | null, | |
| 474 | + kind: string, | |
| 475 | + detail?: string, | |
| 476 | +): void { | |
| 477 | + commsDb | |
| 478 | + .prepare( | |
| 479 | + "INSERT INTO contact_events (submission_id, admin_id, kind, detail) VALUES (?, ?, ?, ?)", | |
| 480 | + ) | |
| 481 | + .run(submissionId, adminId, kind, detail ?? null); | |
| 482 | +} | |
| 483 | + | |
| 484 | +export function eventsOf( | |
| 485 | + submissionId: number, | |
| 486 | +): (EventRow & { author: string | null })[] { | |
| 487 | + return commsDb | |
| 488 | + .prepare( | |
| 489 | + `SELECT e.*, a.display_name AS author | |
| 490 | + FROM contact_events e LEFT JOIN admin_users a ON a.id = e.admin_id | |
| 491 | + WHERE e.submission_id = ? ORDER BY e.created_at DESC`, | |
| 492 | + ) | |
| 493 | + .all(submissionId) as (EventRow & { author: string | null })[]; | |
| 494 | +} | |
| 495 | + | |
| 496 | +/* ---------- comptes admin ---------- */ | |
| 497 | + | |
| 498 | +function hashPassword(password: string): string { | |
| 499 | + const salt = crypto.randomBytes(16).toString("hex"); | |
| 500 | + const hash = crypto.scryptSync(password, salt, 64).toString("hex"); | |
| 501 | + return `${salt}:${hash}`; | |
| 502 | +} | |
| 503 | + | |
| 504 | +export function verifyAdminPassword(password: string, stored: string): boolean { | |
| 505 | + const [salt, hash] = stored.split(":"); | |
| 506 | + if (!salt || !hash) return false; | |
| 507 | + const test = crypto.scryptSync(password, salt, 64); | |
| 508 | + return crypto.timingSafeEqual(test, Buffer.from(hash, "hex")); | |
| 509 | +} | |
| 510 | + | |
| 511 | +export function findAdminByUsername(username: string): AdminRow | undefined { | |
| 512 | + return commsDb | |
| 513 | + .prepare("SELECT * FROM admin_users WHERE username = ?") | |
| 514 | + .get(username.toLowerCase()) as AdminRow | undefined; | |
| 515 | +} | |
| 516 | + | |
| 517 | +export function findAdminById(id: number): AdminRow | undefined { | |
| 518 | + return commsDb.prepare("SELECT * FROM admin_users WHERE id = ?").get(id) as | |
| 519 | + | AdminRow | |
| 520 | + | undefined; | |
| 521 | +} | |
| 522 | + | |
| 523 | +export function listAdmins(): AdminRow[] { | |
| 524 | + return commsDb | |
| 525 | + .prepare("SELECT * FROM admin_users ORDER BY created_at ASC") | |
| 526 | + .all() as AdminRow[]; | |
| 527 | +} | |
| 528 | + | |
| 529 | +export function createAdmin(a: { | |
| 530 | + username: string; | |
| 531 | + display_name: string; | |
| 532 | + email?: string | null; | |
| 533 | + password: string; | |
| 534 | + roles: string[]; | |
| 535 | +}): number { | |
| 536 | + const info = commsDb | |
| 537 | + .prepare( | |
| 538 | + `INSERT INTO admin_users (username, display_name, email, password_hash, roles) | |
| 539 | + VALUES (?, ?, ?, ?, ?)`, | |
| 540 | + ) | |
| 541 | + .run( | |
| 542 | + a.username.toLowerCase(), | |
| 543 | + a.display_name, | |
| 544 | + a.email ?? null, | |
| 545 | + hashPassword(a.password), | |
| 546 | + JSON.stringify(a.roles), | |
| 547 | + ); | |
| 548 | + return Number(info.lastInsertRowid); | |
| 549 | +} | |
| 550 | + | |
| 551 | +export function setAdminPassword(id: number, password: string): void { | |
| 552 | + commsDb | |
| 553 | + .prepare("UPDATE admin_users SET password_hash = ? WHERE id = ?") | |
| 554 | + .run(hashPassword(password), id); | |
| 555 | +} | |
| 556 | + | |
| 557 | +export function updateAdmin( | |
| 558 | + id: number, | |
| 559 | + patch: { display_name?: string; email?: string | null; roles?: string[]; active?: boolean }, | |
| 560 | +): void { | |
| 561 | + if (patch.display_name !== undefined) | |
| 562 | + commsDb | |
| 563 | + .prepare("UPDATE admin_users SET display_name = ? WHERE id = ?") | |
| 564 | + .run(patch.display_name, id); | |
| 565 | + if (patch.email !== undefined) | |
| 566 | + commsDb.prepare("UPDATE admin_users SET email = ? WHERE id = ?").run(patch.email, id); | |
| 567 | + if (patch.roles !== undefined) | |
| 568 | + commsDb | |
| 569 | + .prepare("UPDATE admin_users SET roles = ? WHERE id = ?") | |
| 570 | + .run(JSON.stringify(patch.roles), id); | |
| 571 | + if (patch.active !== undefined) | |
| 572 | + commsDb | |
| 573 | + .prepare("UPDATE admin_users SET active = ? WHERE id = ?") | |
| 574 | + .run(patch.active ? 1 : 0, id); | |
| 575 | +} | |
| 576 | + | |
| 577 | +export function touchAdminLogin(id: number): void { | |
| 578 | + commsDb | |
| 579 | + .prepare("UPDATE admin_users SET last_login = datetime('now') WHERE id = ?") | |
| 580 | + .run(id); | |
| 581 | +} | |
| 582 | + | |
| 583 | +// Amorçage : les deux comptes maîtres demandés (mot de passe changeable | |
| 584 | +// depuis /admin/equipe — à changer dès la première connexion). | |
| 585 | +(function seedAdmins() { | |
| 586 | + try { | |
| 587 | + const count = ( | |
| 588 | + commsDb.prepare("SELECT COUNT(*) AS c FROM admin_users").get() as { c: number } | |
| 589 | + ).c; | |
| 590 | + if (count > 0) return; | |
| 591 | + createAdmin({ | |
| 592 | + username: "erikabc", | |
| 593 | + display_name: "Erika BC", | |
| 594 | + password: "admin123", | |
| 595 | + roles: ["super_admin"], | |
| 596 | + }); | |
| 597 | + createAdmin({ | |
| 598 | + username: "spboucher", | |
| 599 | + display_name: "Simon-Pierre Boucher", | |
| 600 | + password: "admin123", | |
| 601 | + roles: ["super_admin"], | |
| 602 | + }); | |
| 603 | + } catch { | |
| 604 | + /* course entre workers de build (UNIQUE username) : un seul amorce */ | |
| 605 | + } | |
| 606 | +})(); | |
added
src/lib/comms/queries.ts
+215 −0
@@ -0,0 +1,215 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// KA Communication Hub — requêtes du panel /admin (inbox, dashboard). | |
| 3 | +// Le RBAC est appliqué DANS la requête : un admin ne reçoit jamais une ligne | |
| 4 | +// qu'il n'a pas le droit de voir (pas de filtrage côté client). | |
| 5 | +import { commsDb, type SubmissionRow } from "./db"; | |
| 6 | +import { CATEGORIES } from "./categories"; | |
| 7 | +import { | |
| 8 | + canViewCategory, | |
| 9 | + isSuperAdmin, | |
| 10 | + CATEGORY_ROLE, | |
| 11 | + type AdminSession, | |
| 12 | +} from "./admin-auth"; | |
| 13 | + | |
| 14 | +export type InboxFilters = { | |
| 15 | + category?: string; | |
| 16 | + subCategory?: string; | |
| 17 | + status?: string; | |
| 18 | + priority?: string; | |
| 19 | + site?: string; | |
| 20 | + assigned?: string; // id numérique, "moi", "personne" | |
| 21 | + kaId?: string; | |
| 22 | + q?: string; | |
| 23 | + view?: "actives" | "archivees" | "spam" | "toutes"; | |
| 24 | + page?: number; | |
| 25 | +}; | |
| 26 | + | |
| 27 | +const PAGE_SIZE = 40; | |
| 28 | + | |
| 29 | +/** WHERE RBAC : catégories visibles + niveaux restreints réservés. */ | |
| 30 | +function rbacWhere(s: AdminSession): { sql: string; args: (string | number)[] } { | |
| 31 | + if (isSuperAdmin(s)) return { sql: "1=1", args: [] }; | |
| 32 | + const cats = CATEGORIES.map((c) => c.slug).filter((c) => canViewCategory(s, c)); | |
| 33 | + if (!cats.length) return { sql: "1=0", args: [] }; | |
| 34 | + const dedicated = Object.entries(CATEGORY_ROLE) | |
| 35 | + .filter(([, role]) => s.roles.includes(role)) | |
| 36 | + .map(([cat]) => cat); | |
| 37 | + // « restreinte » : visible seulement si l'admin porte le rôle dédié. | |
| 38 | + const dedic = dedicated.length | |
| 39 | + ? `category IN (${dedicated.map(() => "?").join(",")})` | |
| 40 | + : "1=0"; | |
| 41 | + return { | |
| 42 | + sql: `category IN (${cats.map(() => "?").join(",")}) | |
| 43 | + AND (confidentiality != 'restreinte' OR ${dedic})`, | |
| 44 | + args: [...cats, ...dedicated], | |
| 45 | + }; | |
| 46 | +} | |
| 47 | + | |
| 48 | +export function listSubmissions( | |
| 49 | + s: AdminSession, | |
| 50 | + f: InboxFilters, | |
| 51 | +): { rows: (SubmissionRow & { assignee: string | null })[]; total: number } { | |
| 52 | + const rbac = rbacWhere(s); | |
| 53 | + const where: string[] = [rbac.sql]; | |
| 54 | + const args: (string | number)[] = [...rbac.args]; | |
| 55 | + | |
| 56 | + const view = f.view ?? "actives"; | |
| 57 | + if (view === "actives") where.push("is_archived = 0 AND is_spam = 0"); | |
| 58 | + else if (view === "archivees") where.push("is_archived = 1"); | |
| 59 | + else if (view === "spam") where.push("is_spam = 1"); | |
| 60 | + | |
| 61 | + if (f.category) { | |
| 62 | + where.push("category = ?"); | |
| 63 | + args.push(f.category); | |
| 64 | + } | |
| 65 | + if (f.subCategory) { | |
| 66 | + where.push("sub_category = ?"); | |
| 67 | + args.push(f.subCategory); | |
| 68 | + } | |
| 69 | + if (f.status) { | |
| 70 | + where.push("status = ?"); | |
| 71 | + args.push(f.status); | |
| 72 | + } | |
| 73 | + if (f.priority) { | |
| 74 | + where.push("priority = ?"); | |
| 75 | + args.push(f.priority); | |
| 76 | + } | |
| 77 | + if (f.site) { | |
| 78 | + where.push("site_concerned = ?"); | |
| 79 | + args.push(f.site); | |
| 80 | + } | |
| 81 | + if (f.kaId) { | |
| 82 | + where.push("ka_id = ?"); | |
| 83 | + args.push(f.kaId); | |
| 84 | + } | |
| 85 | + if (f.assigned === "moi") { | |
| 86 | + where.push("assigned_to = ?"); | |
| 87 | + args.push(s.id); | |
| 88 | + } else if (f.assigned === "personne") { | |
| 89 | + where.push("assigned_to IS NULL"); | |
| 90 | + } else if (f.assigned && /^\d+$/.test(f.assigned)) { | |
| 91 | + where.push("assigned_to = ?"); | |
| 92 | + args.push(Number(f.assigned)); | |
| 93 | + } | |
| 94 | + if (f.q) { | |
| 95 | + // recherche globale : référence, identité, organisation, sujet, message | |
| 96 | + where.push(`(reference LIKE ? OR first_name LIKE ? OR last_name LIKE ? | |
| 97 | + OR email LIKE ? OR organization LIKE ? OR subject LIKE ? OR message LIKE ?)`); | |
| 98 | + const like = `%${f.q}%`; | |
| 99 | + args.push(like, like, like, like, like, like, like); | |
| 100 | + } | |
| 101 | + | |
| 102 | + const whereSql = where.join(" AND "); | |
| 103 | + const total = ( | |
| 104 | + commsDb | |
| 105 | + .prepare(`SELECT COUNT(*) AS c FROM contact_submissions WHERE ${whereSql}`) | |
| 106 | + .get(...args) as { c: number } | |
| 107 | + ).c; | |
| 108 | + const page = Math.max(1, f.page ?? 1); | |
| 109 | + const rows = commsDb | |
| 110 | + .prepare( | |
| 111 | + `SELECT cs.*, a.display_name AS assignee | |
| 112 | + FROM contact_submissions cs | |
| 113 | + LEFT JOIN admin_users a ON a.id = cs.assigned_to | |
| 114 | + WHERE ${whereSql} | |
| 115 | + ORDER BY | |
| 116 | + CASE cs.priority WHEN 'urgente' THEN 0 WHEN 'haute' THEN 1 | |
| 117 | + WHEN 'normale' THEN 2 ELSE 3 END, | |
| 118 | + cs.created_at DESC | |
| 119 | + LIMIT ? OFFSET ?`, | |
| 120 | + ) | |
| 121 | + .all(...args, PAGE_SIZE, (page - 1) * PAGE_SIZE) as (SubmissionRow & { | |
| 122 | + assignee: string | null; | |
| 123 | + })[]; | |
| 124 | + return { rows, total }; | |
| 125 | +} | |
| 126 | + | |
| 127 | +/** La demande est-elle visible pour cet admin ? (contrôle unitaire, anti-IDOR) */ | |
| 128 | +export function visibleTo(s: AdminSession, sub: SubmissionRow): boolean { | |
| 129 | + const rbac = rbacWhere(s); | |
| 130 | + if (rbac.sql === "1=1") return true; | |
| 131 | + const row = commsDb | |
| 132 | + .prepare( | |
| 133 | + `SELECT 1 AS ok FROM contact_submissions WHERE id = ? AND ${rbac.sql}`, | |
| 134 | + ) | |
| 135 | + .get(sub.id, ...rbac.args); | |
| 136 | + return !!row; | |
| 137 | +} | |
| 138 | + | |
| 139 | +/* ---------- dashboard ---------- */ | |
| 140 | + | |
| 141 | +export type DashboardStats = { | |
| 142 | + today: number; | |
| 143 | + unread: number; | |
| 144 | + open: number; | |
| 145 | + urgent: number; | |
| 146 | + investors: number; | |
| 147 | + jobs: number; | |
| 148 | + legal: number; | |
| 149 | + security: number; | |
| 150 | + resolvedWeek: number; | |
| 151 | + avgFirstResponseH: number | null; | |
| 152 | +}; | |
| 153 | + | |
| 154 | +export function dashboardStats(s: AdminSession): DashboardStats { | |
| 155 | + const rbac = rbacWhere(s); | |
| 156 | + const base = `FROM contact_submissions WHERE ${rbac.sql} AND is_spam = 0 AND is_archived = 0`; | |
| 157 | + const one = (sql: string, extra: (string | number)[] = []): number => | |
| 158 | + ( | |
| 159 | + commsDb.prepare(`SELECT COUNT(*) AS c ${base} AND ${sql}`).get( | |
| 160 | + ...rbac.args, | |
| 161 | + ...extra, | |
| 162 | + ) as { c: number } | |
| 163 | + ).c; | |
| 164 | + const catCount = (cat: string, extra = "resolved_at IS NULL"): number => | |
| 165 | + canViewCategory(s, cat) ? one(`category = ? AND ${extra}`, [cat]) : 0; | |
| 166 | + | |
| 167 | + const avg = commsDb | |
| 168 | + .prepare( | |
| 169 | + `SELECT AVG((julianday(first_response_at) - julianday(created_at)) * 24) AS h | |
| 170 | + ${base} AND first_response_at IS NOT NULL | |
| 171 | + AND created_at > datetime('now', '-30 days')`, | |
| 172 | + ) | |
| 173 | + .get(...rbac.args) as { h: number | null }; | |
| 174 | + | |
| 175 | + return { | |
| 176 | + today: one("date(created_at) = date('now')"), | |
| 177 | + unread: one("is_read = 0"), | |
| 178 | + open: one("resolved_at IS NULL"), | |
| 179 | + urgent: one("priority IN ('urgente','haute') AND resolved_at IS NULL"), | |
| 180 | + investors: catCount("investisseurs"), | |
| 181 | + jobs: catCount("carrieres", "date(created_at) > date('now', '-30 days')"), | |
| 182 | + legal: catCount("legal"), | |
| 183 | + security: catCount("securite"), | |
| 184 | + resolvedWeek: one("resolved_at IS NOT NULL AND resolved_at > datetime('now', '-7 days')"), | |
| 185 | + avgFirstResponseH: avg.h != null ? Math.round(avg.h * 10) / 10 : null, | |
| 186 | + }; | |
| 187 | +} | |
| 188 | + | |
| 189 | +export function recentSubmissions( | |
| 190 | + s: AdminSession, | |
| 191 | + limit = 8, | |
| 192 | +): (SubmissionRow & { assignee: string | null })[] { | |
| 193 | + const rbac = rbacWhere(s); | |
| 194 | + return commsDb | |
| 195 | + .prepare( | |
| 196 | + `SELECT cs.*, a.display_name AS assignee | |
| 197 | + FROM contact_submissions cs | |
| 198 | + LEFT JOIN admin_users a ON a.id = cs.assigned_to | |
| 199 | + WHERE ${rbac.sql} AND cs.is_spam = 0 AND cs.is_archived = 0 | |
| 200 | + ORDER BY cs.created_at DESC LIMIT ?`, | |
| 201 | + ) | |
| 202 | + .all(...rbac.args, limit) as (SubmissionRow & { assignee: string | null })[]; | |
| 203 | +} | |
| 204 | + | |
| 205 | +export function unreadCount(s: AdminSession): number { | |
| 206 | + const rbac = rbacWhere(s); | |
| 207 | + return ( | |
| 208 | + commsDb | |
| 209 | + .prepare( | |
| 210 | + `SELECT COUNT(*) AS c FROM contact_submissions | |
| 211 | + WHERE ${rbac.sql} AND is_read = 0 AND is_spam = 0 AND is_archived = 0`, | |
| 212 | + ) | |
| 213 | + .get(...rbac.args) as { c: number } | |
| 214 | + ).c; | |
| 215 | +} | |
added
src/lib/comms/rate-limit.ts
+52 −0
@@ -0,0 +1,52 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// KA Communication Hub — anti-abus du formulaire public. | |
| 3 | +// Privacy-by-design : l'IP n'est JAMAIS écrite en base — uniquement une | |
| 4 | +// empreinte SHA-256 salée, en mémoire, avec fenêtre glissante. Redémarrage | |
| 5 | +// du processus = compteurs remis à zéro (acceptable pour ce volume). | |
| 6 | +import crypto from "crypto"; | |
| 7 | + | |
| 8 | +const salt = | |
| 9 | + process.env.KA_AUTH_SECRET ?? "ka-dev-salt"; | |
| 10 | + | |
| 11 | +type Window = { stamps: number[] }; | |
| 12 | +const buckets = new Map<string, Window>(); | |
| 13 | + | |
| 14 | +const SHORT_WINDOW_MS = 10 * 60 * 1000; // 5 soumissions / 10 min | |
| 15 | +const SHORT_MAX = 5; | |
| 16 | +const LONG_WINDOW_MS = 60 * 60 * 1000; // 15 soumissions / heure | |
| 17 | +const LONG_MAX = 15; | |
| 18 | + | |
| 19 | +export function ipFingerprint(req: Request): string { | |
| 20 | + const fwd = req.headers.get("x-forwarded-for") ?? ""; | |
| 21 | + const ip = fwd.split(",")[0]?.trim() || "local"; | |
| 22 | + return crypto.createHash("sha256").update(salt + ip).digest("hex").slice(0, 24); | |
| 23 | +} | |
| 24 | + | |
| 25 | +/** true = la soumission passe ; false = trop de soumissions, 429. */ | |
| 26 | +export function allowSubmission(fp: string): boolean { | |
| 27 | + const now = Date.now(); | |
| 28 | + const w = buckets.get(fp) ?? { stamps: [] }; | |
| 29 | + w.stamps = w.stamps.filter((t) => now - t < LONG_WINDOW_MS); | |
| 30 | + const recent = w.stamps.filter((t) => now - t < SHORT_WINDOW_MS); | |
| 31 | + if (recent.length >= SHORT_MAX || w.stamps.length >= LONG_MAX) return false; | |
| 32 | + w.stamps.push(now); | |
| 33 | + buckets.set(fp, w); | |
| 34 | + // ménage opportuniste — évite la croissance infinie de la Map | |
| 35 | + if (buckets.size > 5000) | |
| 36 | + for (const [k, v] of buckets) | |
| 37 | + if (!v.stamps.some((t) => now - t < LONG_WINDOW_MS)) buckets.delete(k); | |
| 38 | + return true; | |
| 39 | +} | |
| 40 | + | |
| 41 | +/** | |
| 42 | + * Honeypot + temps minimal de remplissage. | |
| 43 | + * `hp` est un champ invisible (les humains le laissent vide) ; `ts` est | |
| 44 | + * l'horodatage d'affichage du formulaire (un humain met > 3 s à remplir). | |
| 45 | + */ | |
| 46 | +export function looksLikeSpam(hp: string | null, ts: string | null): boolean { | |
| 47 | + if (hp) return true; | |
| 48 | + const t = Number(ts); | |
| 49 | + if (!Number.isFinite(t)) return true; | |
| 50 | + const elapsed = Date.now() - t; | |
| 51 | + return elapsed < 3000 || elapsed > 24 * 3600 * 1000; | |
| 52 | +} | |
added
src/lib/comms/receipt-email.ts
+117 −0
@@ -0,0 +1,117 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// KA Communication Hub — courriels du centre de communication (Resend). | |
| 3 | +// Deux gabarits : l'accusé de réception (avec la référence publique) et la | |
| 4 | +// réponse d'un admin depuis le panel. Même identité « éditorial sharp » que | |
| 5 | +// les courriels KA ID (src/lib/email.ts) — la DB reste la source de vérité, | |
| 6 | +// le courriel n'est qu'un canal. | |
| 7 | +import { sendEmail } from "@/lib/email"; | |
| 8 | + | |
| 9 | +const INK = "#141814"; | |
| 10 | +const INK_2 = "#4d5551"; | |
| 11 | +const INK_3 = "#8b928c"; | |
| 12 | +const PAPER = "#f5f3ee"; | |
| 13 | +const LIME = "#d9f26b"; | |
| 14 | +const GREEN = "#1c5c41"; | |
| 15 | +const F_DISPLAY = "'Space Grotesk',Arial,'Helvetica Neue',sans-serif"; | |
| 16 | +const F_BODY = "'Inter',Helvetica,Arial,sans-serif"; | |
| 17 | +const F_MONO = "'JetBrains Mono','SFMono-Regular',Menlo,Consolas,'Courier New',monospace"; | |
| 18 | + | |
| 19 | +function shell(opts: { kicker: string; title: string; preheader: string; inner: string }): string { | |
| 20 | + return `<!doctype html> | |
| 21 | +<html lang="fr"> | |
| 22 | +<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${opts.title.replace(/<[^>]+>/g, " ")}</title></head> | |
| 23 | +<body style="margin:0;padding:0;background:${PAPER};"> | |
| 24 | + <div style="display:none;max-height:0;overflow:hidden;">${opts.preheader}</div> | |
| 25 | + <table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:${PAPER};"> | |
| 26 | + <tr><td align="center" style="padding:40px 16px 36px;"> | |
| 27 | + <table role="presentation" cellpadding="0" cellspacing="0" style="width:560px;max-width:100%;"> | |
| 28 | + <tr><td style="padding:0 2px 16px;"> | |
| 29 | + <table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr> | |
| 30 | + <td style="font-family:${F_DISPLAY};font-weight:700;font-size:22px;letter-spacing:-0.03em;color:${INK};white-space:nowrap;">Groupe <span style="display:inline-block;background:${LIME};color:${INK};padding:0 7px 3px;border-radius:6px;">Ka</span></td> | |
| 31 | + <td align="right" style="font-family:${F_MONO};font-size:10px;font-weight:700;letter-spacing:0.16em;text-transform:uppercase;color:${INK_3};">${opts.kicker}</td> | |
| 32 | + </tr></table> | |
| 33 | + </td></tr> | |
| 34 | + <tr><td style="background:#ffffff;border:2px solid ${INK};border-right-width:8px;border-bottom-width:8px;border-radius:12px;padding:30px 28px 24px;"> | |
| 35 | + <table role="presentation" width="100%" cellpadding="0" cellspacing="0"> | |
| 36 | + <tr><td style="font-family:${F_MONO};font-size:11px;font-weight:700;letter-spacing:0.14em;text-transform:uppercase;color:${GREEN};"><span style="display:inline-block;width:22px;height:2px;background:${GREEN};vertical-align:middle;"> </span> Centre de communication</td></tr> | |
| 37 | + <tr><td style="padding:12px 0 0;font-family:${F_DISPLAY};font-weight:700;font-size:27px;line-height:1.08;letter-spacing:-0.03em;color:${INK};">${opts.title}</td></tr> | |
| 38 | + <tr><td style="padding:14px 0 0;">${opts.inner}</td></tr> | |
| 39 | + </table> | |
| 40 | + </td></tr> | |
| 41 | + <tr><td style="padding:20px 2px 0;font-family:${F_MONO};font-size:9.5px;font-weight:700;line-height:1.9;letter-spacing:0.1em;text-transform:uppercase;color:${INK_3};"> | |
| 42 | + Un seul endroit pour nous joindre<br> | |
| 43 | + <a href="https://www.groupe-ka.com/contact" style="color:${GREEN};text-decoration:none;">www.groupe-ka.com/contact</a> | |
| 44 | + </td></tr> | |
| 45 | + </table> | |
| 46 | + </td></tr> | |
| 47 | + </table> | |
| 48 | +</body> | |
| 49 | +</html>`; | |
| 50 | +} | |
| 51 | + | |
| 52 | +function p(html: string): string { | |
| 53 | + return `<p style="margin:0 0 12px;font-family:${F_BODY};font-size:14.5px;line-height:1.65;color:${INK_2};">${html}</p>`; | |
| 54 | +} | |
| 55 | + | |
| 56 | +function refPanel(reference: string): string { | |
| 57 | + return `<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:18px 0 6px;"> | |
| 58 | + <tr><td align="center" style="background:${INK};border-radius:12px;padding:22px 18px 18px;"> | |
| 59 | + <p style="margin:0 0 10px;font-family:${F_MONO};font-size:10px;font-weight:700;letter-spacing:0.26em;text-transform:uppercase;color:rgba(217,242,107,0.65);">Votre numéro de demande</p> | |
| 60 | + <p style="margin:0;font-family:${F_MONO};font-weight:700;font-size:26px;line-height:1;letter-spacing:0.12em;color:${LIME};">${reference}</p> | |
| 61 | + </td></tr> | |
| 62 | + </table>`; | |
| 63 | +} | |
| 64 | + | |
| 65 | +function esc(s: string): string { | |
| 66 | + return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"); | |
| 67 | +} | |
| 68 | + | |
| 69 | +/** Accusé de réception — envoyé au demandeur dès la soumission enregistrée. */ | |
| 70 | +export async function sendReceipt(opts: { | |
| 71 | + to: string; | |
| 72 | + name: string; | |
| 73 | + reference: string; | |
| 74 | + categoryTitle: string; | |
| 75 | +}): Promise<void> { | |
| 76 | + const inner = ` | |
| 77 | + ${p(`Bonjour <strong style="color:${INK};">${esc(opts.name)}</strong>,`)} | |
| 78 | + ${p(`votre demande <strong style="color:${INK};">${esc(opts.categoryTitle)}</strong> est bien reçue et enregistrée dans notre centre de communication. Elle sera lue par l'équipe concernée — de vraies personnes, comme promis.`)} | |
| 79 | + ${refPanel(opts.reference)} | |
| 80 | + ${p(`Conservez ce numéro : il identifie votre demande dans tous nos échanges.`)}`; | |
| 81 | + await sendEmail({ | |
| 82 | + to: opts.to, | |
| 83 | + subject: `${opts.reference} — votre demande est reçue · Groupe KA`, | |
| 84 | + html: shell({ | |
| 85 | + kicker: "Accusé de réception", | |
| 86 | + title: "Votre demande est<br>entre bonnes mains", | |
| 87 | + preheader: `Demande ${opts.reference} enregistrée — l'équipe concernée vous répondra.`, | |
| 88 | + inner, | |
| 89 | + }), | |
| 90 | + text: `Bonjour ${opts.name},\n\nVotre demande « ${opts.categoryTitle} » est bien reçue.\nNuméro de demande : ${opts.reference}\n\nConservez ce numéro : il identifie votre demande dans tous nos échanges.\n\nGroupe Ka — www.groupe-ka.com/contact`, | |
| 91 | + }); | |
| 92 | +} | |
| 93 | + | |
| 94 | +/** Réponse d'un admin depuis le panel — la conversation continue par courriel. */ | |
| 95 | +export async function sendAdminReply(opts: { | |
| 96 | + to: string; | |
| 97 | + name: string; | |
| 98 | + reference: string; | |
| 99 | + body: string; | |
| 100 | +}): Promise<void> { | |
| 101 | + const inner = ` | |
| 102 | + ${p(`Bonjour <strong style="color:${INK};">${esc(opts.name)}</strong>,`)} | |
| 103 | + ${p(`voici la réponse de l'équipe Groupe KA à votre demande <strong style="color:${INK};">${esc(opts.reference)}</strong> :`)} | |
| 104 | + <div style="margin:14px 0;padding:16px 18px;border-left:3px solid ${GREEN};background:#faf9f5;border-radius:0 8px 8px 0;font-family:${F_BODY};font-size:14.5px;line-height:1.7;color:${INK};white-space:pre-wrap;">${esc(opts.body)}</div> | |
| 105 | + ${p(`Pour poursuivre l'échange, répondez simplement à ce courriel en conservant le numéro <strong style="color:${INK};">${esc(opts.reference)}</strong> dans l'objet.`)}`; | |
| 106 | + await sendEmail({ | |
| 107 | + to: opts.to, | |
| 108 | + subject: `${opts.reference} — réponse de Groupe KA`, | |
| 109 | + html: shell({ | |
| 110 | + kicker: "Réponse à votre demande", | |
| 111 | + title: "L'équipe vous répond", | |
| 112 | + preheader: `Réponse à votre demande ${opts.reference}.`, | |
| 113 | + inner, | |
| 114 | + }), | |
| 115 | + text: `Bonjour ${opts.name},\n\nRéponse de Groupe KA à votre demande ${opts.reference} :\n\n${opts.body}\n\nPour poursuivre l'échange, répondez à ce courriel en conservant le numéro ${opts.reference} dans l'objet.\n\nGroupe Ka — www.groupe-ka.com/contact`, | |
| 116 | + }); | |
| 117 | +} | |
| 118 | ||