Files
PleinR/src/app/backend/adherents/page.tsx
T
Claude 3b17d7ff42 Durcissement (lot A) : seed sûr en production, mots de passe temporaires affichés une seule fois
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
2026-09-04 09:15:45 +00:00

141 lines
5.6 KiB
TypeScript

import Link from "next/link";
import { redirect } from "next/navigation";
import { asc, eq, ilike } from "drizzle-orm";
import { getSession } from "@/lib/session";
import { db } from "@/db";
import { categories, members, users } from "@/db/schema";
import { can } from "@/lib/rbac";
import { AddMemberPanel } from "./AddMemberPanel";
import { BackfillAccountsForm } from "./BackfillAccountsForm";
export const dynamic = "force-dynamic";
function initialsOf(name: string) {
return name.split(/\s+/).map((w) => w[0]).join("").slice(0, 2).toUpperCase();
}
export default async function AdherentsPage({
searchParams,
}: {
searchParams: Promise<{ q?: string }>;
}) {
const session = await getSession();
if (!can(session?.user.role, "manageMembers")) {
redirect("/backend");
}
const { q } = await searchParams;
const search = (q ?? "").trim();
const cats = await db
.select({ id: categories.id, label: categories.label })
.from(categories)
.orderBy(asc(categories.sort));
const rows = await db
.select({
id: members.id,
name: members.name,
email: members.email,
city: members.city,
status: members.status,
categoryLabel: categories.label,
})
.from(members)
.leftJoin(categories, eq(members.categoryId, categories.id))
.where(search ? ilike(members.name, `%${search}%`) : undefined)
.orderBy(asc(members.name));
// Adhérents sans compte de connexion (créés avant l'arrivée des comptes).
const accountRows = await db
.select({ email: users.email, memberId: users.memberId })
.from(users);
const linkedMemberIds = new Set(accountRows.map((u) => u.memberId).filter((x): x is number => x != null));
const takenEmails = new Set(accountRows.map((u) => u.email.toLowerCase()));
const allForCount = await db.select({ id: members.id, email: members.email }).from(members);
const missingAccounts = allForCount.filter((m) => {
if (linkedMemberIds.has(m.id)) return false;
const e = (m.email ?? "").trim().toLowerCase();
return !!e && !takenEmails.has(e);
}).length;
return (
<div style={{ display: "flex", flexDirection: "column", gap: 18 }}>
{missingAccounts > 0 && <BackfillAccountsForm missingAccounts={missingAccounts} />}
<AddMemberPanel categories={cats} />
<div style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 16, overflow: "hidden" }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "18px 22px", borderBottom: "1px solid #f0e8d6", gap: 12, flexWrap: "wrap" }}>
<form style={{ display: "flex", alignItems: "center", gap: 10, background: "#faf7ef", border: "1px solid #e6dcc6", borderRadius: 10, padding: "8px 14px" }}>
<span style={{ width: 9, height: 9, border: "2px solid #9a8d72", borderRadius: "50%" }} />
<input
name="q"
defaultValue={search}
placeholder="Rechercher un adhérent…"
style={{ border: "none", outline: "none", background: "transparent", fontSize: 14, width: 240, color: "#3c3322" }}
/>
</form>
</div>
<div
className="member-row member-head"
style={{
padding: "13px 22px",
background: "#faf7ef",
borderBottom: "1px solid #f0e8d6",
fontSize: 12,
letterSpacing: "0.05em",
textTransform: "uppercase",
color: "#9a8d72",
fontWeight: 700,
}}
>
<div>Adhérent</div>
<div>Catégorie</div>
<div>Commune</div>
<div>Statut</div>
<div style={{ textAlign: "right" }}>Action</div>
</div>
{rows.length === 0 && (
<div style={{ padding: "22px", fontSize: 14, color: "#a99c82" }}>Aucun adhérent trouvé.</div>
)}
{rows.map((m) => {
const active = m.status === "active";
return (
<div key={m.id} className="member-row" style={{ padding: "15px 22px", borderBottom: "1px solid #f4eede" }}>
<div className="member-row__identity">
<span className="font-display" style={{ width: 38, height: 38, borderRadius: 10, background: "#f0e8d6", display: "flex", alignItems: "center", justifyContent: "center", fontWeight: 800, color: "#9a8d72", fontSize: 14, flexShrink: 0 }}>
{initialsOf(m.name)}
</span>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 14.5, fontWeight: 700, color: "#26201a" }}>{m.name}</div>
<div className="member-row__mail" style={{ fontSize: 12, color: "#a99c82" }}>{m.email}</div>
</div>
</div>
<div className="member-row__meta" style={{ fontSize: 13.5, color: "#6c6150" }}>
{m.categoryLabel ?? "—"}
</div>
<div className="member-row__meta" style={{ fontSize: 13.5, color: "#6c6150" }}>
{m.city ?? "—"}
</div>
<div>
<span style={{ display: "inline-block", fontSize: 11.5, fontWeight: 700, padding: "4px 11px", borderRadius: 999, background: active ? "#e6f4ec" : "#fbeede", color: active ? "#1f8a5b" : "#9a6638" }}>
{active ? "Actif" : "En attente"}
</span>
</div>
<div className="member-row__action">
<Link href={`/backend/adherents/${m.id}`} style={{ textDecoration: "none", color: "#2C6FB3", fontWeight: 700, fontSize: 13 }}>
Modifier
</Link>
</div>
</div>
);
})}
</div>
</div>
);
}