mirror of
https://github.com/R0m1k3/PleinR.git
synced 2026-10-20 13:48:08 +02:00
Audit de sécurité complet de l'application, puis correctifs du lot A validés : - Seed : plus aucun compte de démonstration (mot de passe « changeme123 ») sans SEED_DEMO=true. L'administrateur initial reçoit un mot de passe aléatoire affiché une fois dans les journaux (ou SEED_ADMIN_PASSWORD) et doit le changer à la première connexion. docker-compose ne fournit plus de mot de passe par défaut. - Mots de passe temporaires : la colonne users.temp_password (en clair) est supprimée (migration 0011). Création d'adhérent, réinitialisation, rattrapage des comptes manquants, approbation de demande et invitation staff renvoient les identifiants, affichés une seule fois par le composant OneTimeCredentials, sans redirection. L'invitation staff, qui ne communiquait jamais le mot de passe, redevient utilisable et impose le changement à la première connexion. - Sessions JWT limitées à 7 jours (30 auparavant). - En-têtes : Strict-Transport-Security ajouté, X-Powered-By supprimé. - docker-compose : port Postgres publié sur 127.0.0.1 uniquement. - Image Docker sur node:22 (Node 20 en fin de vie). - Dépendances : next 15.5.25, pg 8.23, nanoid 3.3.18 (avis GHSA-2v37-7h3g-55p8). Vérifié en local : tests, typage, build de production, migration + seed sur un Postgres 16, et parcours navigateur complet (première connexion, changement forcé, création / réinitialisation / invitation avec affichage unique, cookie de session à 7 jours). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RAQsCp4nnZbwexCg7NDBHE
132 lines
6.1 KiB
TypeScript
132 lines
6.1 KiB
TypeScript
import { redirect } from "next/navigation";
|
|
import { desc } from "drizzle-orm";
|
|
import { getSession } from "@/lib/session";
|
|
import { db } from "@/db";
|
|
import { contactMessages, membershipRequests } from "@/db/schema";
|
|
import { can } from "@/lib/rbac";
|
|
import { setContactStatus, setRequestStatus } from "../actions";
|
|
import { ApproveRequestForm } from "./ApproveRequestForm";
|
|
|
|
export const dynamic = "force-dynamic";
|
|
|
|
const REQ_STATUS: Record<string, { label: string; bg: string; color: string }> = {
|
|
new: { label: "Nouvelle", bg: "#fbeede", color: "#9a6638" },
|
|
approved: { label: "Approuvée", bg: "#e6f4ec", color: "#1f8a5b" },
|
|
rejected: { label: "Rejetée", bg: "#fbe9e6", color: "#d8472b" },
|
|
};
|
|
const MSG_STATUS: Record<string, { label: string; bg: string; color: string }> = {
|
|
new: { label: "Nouveau", bg: "#fbeede", color: "#9a6638" },
|
|
read: { label: "Lu", bg: "#e7eef6", color: "#2C6FB3" },
|
|
archived: { label: "Archivé", bg: "#f1efe7", color: "#a99c82" },
|
|
};
|
|
|
|
function fmt(d: Date) {
|
|
return new Date(d).toLocaleDateString("fr-FR", { day: "numeric", month: "long", year: "numeric" });
|
|
}
|
|
|
|
function StatusPill({ map, status }: { map: typeof REQ_STATUS; status: string }) {
|
|
const s = map[status] ?? map.new;
|
|
return (
|
|
<span style={{ display: "inline-block", fontSize: 11.5, fontWeight: 700, padding: "4px 11px", borderRadius: 999, background: s.bg, color: s.color, whiteSpace: "nowrap" }}>
|
|
{s.label}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function ActionBtn({ children, color }: { children: React.ReactNode; color: string }) {
|
|
return (
|
|
<button type="submit" style={{ border: "1px solid #e3dac4", background: "#fff", color, fontWeight: 600, fontSize: 12.5, padding: "6px 12px", borderRadius: 8, cursor: "pointer" }}>
|
|
{children}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
export default async function DemandesPage() {
|
|
const session = await getSession();
|
|
if (!can(session?.user.role, "manageMembers")) {
|
|
redirect("/backend");
|
|
}
|
|
|
|
const [requests, messages] = await Promise.all([
|
|
db.select().from(membershipRequests).orderBy(desc(membershipRequests.createdAt)),
|
|
db.select().from(contactMessages).orderBy(desc(contactMessages.createdAt)),
|
|
]);
|
|
|
|
return (
|
|
<div style={{ display: "flex", flexDirection: "column", gap: 26 }}>
|
|
{/* ---- Demandes d'adhésion ---- */}
|
|
<section style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 16, overflow: "hidden" }}>
|
|
<div className="font-display" style={{ padding: "16px 22px", borderBottom: "1px solid #f0e8d6", fontWeight: 700, fontSize: 16, color: "#26201a" }}>
|
|
Demandes d'adhésion ({requests.length})
|
|
</div>
|
|
{requests.length === 0 && (
|
|
<div style={{ padding: "18px 22px", fontSize: 13.5, color: "#a99c82" }}>Aucune demande.</div>
|
|
)}
|
|
{requests.map((r) => (
|
|
<div key={r.id} style={{ padding: "15px 22px", borderBottom: "1px solid #f4eede" }}>
|
|
<div style={{ display: "flex", alignItems: "center", gap: 12, flexWrap: "wrap" }}>
|
|
<div style={{ flex: 1, minWidth: 200 }}>
|
|
<div style={{ fontSize: 14.5, fontWeight: 700, color: "#26201a" }}>{r.name}</div>
|
|
<div style={{ fontSize: 12.5, color: "#a99c82" }}>
|
|
{[r.email, fmt(r.createdAt)].filter(Boolean).join(" · ")}
|
|
</div>
|
|
</div>
|
|
<StatusPill map={REQ_STATUS} status={r.status} />
|
|
<div style={{ display: "flex", gap: 8 }}>
|
|
<ApproveRequestForm requestId={r.id} />
|
|
<form action={setRequestStatus}>
|
|
<input type="hidden" name="id" value={r.id} />
|
|
<input type="hidden" name="status" value="rejected" />
|
|
<ActionBtn color="#d8472b">Rejeter</ActionBtn>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
{r.message && (
|
|
<p style={{ margin: "10px 0 0", fontSize: 13.5, color: "#5a5040", whiteSpace: "pre-wrap" }}>{r.message}</p>
|
|
)}
|
|
</div>
|
|
))}
|
|
</section>
|
|
|
|
{/* ---- Messages de contact ---- */}
|
|
<section style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 16, overflow: "hidden" }}>
|
|
<div className="font-display" style={{ padding: "16px 22px", borderBottom: "1px solid #f0e8d6", fontWeight: 700, fontSize: 16, color: "#26201a" }}>
|
|
Messages de contact ({messages.length})
|
|
</div>
|
|
{messages.length === 0 && (
|
|
<div style={{ padding: "18px 22px", fontSize: 13.5, color: "#a99c82" }}>Aucun message.</div>
|
|
)}
|
|
{messages.map((m) => (
|
|
<div key={m.id} style={{ padding: "15px 22px", borderBottom: "1px solid #f4eede" }}>
|
|
<div style={{ display: "flex", alignItems: "center", gap: 12, flexWrap: "wrap" }}>
|
|
<div style={{ flex: 1, minWidth: 200 }}>
|
|
<div style={{ fontSize: 14.5, fontWeight: 700, color: "#26201a" }}>
|
|
{m.name}
|
|
{m.subject ? <span style={{ fontWeight: 500, color: "#6c6150" }}> — {m.subject}</span> : null}
|
|
</div>
|
|
<div style={{ fontSize: 12.5, color: "#a99c82" }}>
|
|
<a href={`mailto:${m.email}`} style={{ color: "#9a6638" }}>{m.email}</a> · {fmt(m.createdAt)}
|
|
</div>
|
|
</div>
|
|
<StatusPill map={MSG_STATUS} status={m.status} />
|
|
<div style={{ display: "flex", gap: 8 }}>
|
|
<form action={setContactStatus}>
|
|
<input type="hidden" name="id" value={m.id} />
|
|
<input type="hidden" name="status" value="read" />
|
|
<ActionBtn color="#2C6FB3">Marquer lu</ActionBtn>
|
|
</form>
|
|
<form action={setContactStatus}>
|
|
<input type="hidden" name="id" value={m.id} />
|
|
<input type="hidden" name="status" value="archived" />
|
|
<ActionBtn color="#a99c82">Archiver</ActionBtn>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
<p style={{ margin: "10px 0 0", fontSize: 13.5, color: "#5a5040", whiteSpace: "pre-wrap" }}>{m.message}</p>
|
|
</div>
|
|
))}
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|