Demandes & messages : boutons d'action selon l'état de la ligne

Une demande approuvée gardait ses boutons « Approuver & créer » et
« Rejeter », un message lu son « Marquer lu », un message archivé son
« Archiver » : rien n'indiquait que l'action avait déjà été faite.

Chaque ligne ne propose plus que les transitions qui ont du sens :
- demande nouvelle → Approuver & créer / Rejeter
- demande rejetée  → Rouvrir
- demande approuvée → aucun bouton (le compte existe)
- message nouveau  → Marquer lu / Archiver
- message lu       → Archiver
- message archivé  → Désarchiver

Le masquage du bouton d'approbation vit dans `ApproveRequestForm` et non
dans la page : après l'approbation, `router.refresh()` repasse la demande
en « approuvée », et un test côté serveur démonterait le bloc
d'identifiants à usage unique avant qu'il ait été lu.

Deux gardes côté serveur couvrent l'onglet resté ouvert sur l'ancien
état : `approveMembershipRequest` refuse une demande déjà traitée, et
`setRequestStatus` ne touche plus une demande approuvée.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MrfrukBXnHtMwq5iT7DwAM
This commit is contained in:
Claude committed 2026-09-13 07:48:59 +00:00
1 parent e9b6fdfd91
commit dd16e05b47
3 files changed
+70 -19

No files matched your search

+15 -2
View File
@@ -4,7 +4,7 @@ import { randomInt } from "node:crypto";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { headers } from "next/headers"; import { headers } from "next/headers";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { and, eq, sql } from "drizzle-orm"; import { and, eq, ne, sql } from "drizzle-orm";
import bcrypt from "bcryptjs"; import bcrypt from "bcryptjs";
import { signOut } from "@/auth"; import { signOut } from "@/auth";
import { getSession } from "@/lib/session"; import { getSession } from "@/lib/session";
@@ -1133,6 +1133,17 @@ export async function approveMembershipRequest(
const [req] = await db.select().from(membershipRequests).where(eq(membershipRequests.id, id)); const [req] = await db.select().from(membershipRequests).where(eq(membershipRequests.id, id));
if (!req) throw new Error("Demande introuvable."); if (!req) throw new Error("Demande introuvable.");
// Le bouton disparaît dès qu'une demande est traitée, mais un onglet resté
// ouvert peut encore poster : on refuse ici plutôt que de créer un doublon.
if (req.status !== "new") {
return {
error:
req.status === "approved"
? "Cette demande a déjà été approuvée."
: "Cette demande a été rejetée : rouvrez-la avant de l'approuver.",
};
}
const email = (req.email ?? "").trim().toLowerCase(); const email = (req.email ?? "").trim().toLowerCase();
if (!email) { if (!email) {
return { return {
@@ -1178,10 +1189,12 @@ export async function setRequestStatus(formData: FormData) {
const id = Number(formData.get("id")); const id = Number(formData.get("id"));
const status = String(formData.get("status") ?? ""); const status = String(formData.get("status") ?? "");
if (!id || !["new", "approved", "rejected"].includes(status)) return; if (!id || !["new", "approved", "rejected"].includes(status)) return;
// Une demande approuvée a créé un compte : elle ne se rejoue pas depuis un
// onglet resté ouvert sur l'ancien état.
await db await db
.update(membershipRequests) .update(membershipRequests)
.set({ status: status as "new" | "approved" | "rejected" }) .set({ status: status as "new" | "approved" | "rejected" })
.where(eq(membershipRequests.id, id)); .where(and(eq(membershipRequests.id, id), ne(membershipRequests.status, "approved")));
revalidatePath("/backend/demandes"); revalidatePath("/backend/demandes");
revalidatePath("/backend"); revalidatePath("/backend");
} }
@@ -10,8 +10,19 @@ import { approveMembershipRequest, type CreatedMemberAccount } from "../actions"
* Approuve une demande et affiche une seule fois les identifiants du compte * Approuve une demande et affiche une seule fois les identifiants du compte
* créé. On reste sur la page : une redirection ferait perdre le mot de passe, * créé. On reste sur la page : une redirection ferait perdre le mot de passe,
* qui n'est conservé nulle part. * qui n'est conservé nulle part.
*
* Le bouton ne s'affiche que pour une demande encore à traiter, mais c'est ce
* composant — et non la page — qui en décide : après l'approbation,
* `router.refresh()` renvoie la demande en « approuvée », et un masquage côté
* serveur démonterait le bloc d'identifiants avant qu'il ait été lu.
*/ */
export function ApproveRequestForm({ requestId }: { requestId: number }) { export function ApproveRequestForm({
requestId,
status,
}: {
requestId: number;
status: string;
}) {
const [created, setCreated] = useState<CreatedMemberAccount | null>(null); const [created, setCreated] = useState<CreatedMemberAccount | null>(null);
const [pending, setPending] = useState(false); const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -28,6 +39,8 @@ export function ApproveRequestForm({ requestId }: { requestId: number }) {
); );
} }
if (status !== "new") return null;
return ( return (
<form <form
action={async (fd) => { action={async (fd) => {
+41 -16
View File
@@ -72,13 +72,25 @@ export default async function DemandesPage() {
</div> </div>
</div> </div>
<StatusPill map={REQ_STATUS} status={r.status} /> <StatusPill map={REQ_STATUS} status={r.status} />
{/* Une demande traitée ne garde que la sortie qui a du sens :
« Rejeter » disparaît une fois l'adhérent créé, et un rejet
reste réversible tant qu'aucun compte n'existe. */}
<div style={{ display: "flex", gap: 8 }}> <div style={{ display: "flex", gap: 8 }}>
<ApproveRequestForm requestId={r.id} /> <ApproveRequestForm requestId={r.id} status={r.status} />
<form action={setRequestStatus}> {r.status === "new" && (
<input type="hidden" name="id" value={r.id} /> <form action={setRequestStatus}>
<input type="hidden" name="status" value="rejected" /> <input type="hidden" name="id" value={r.id} />
<ActionBtn color="#d8472b">Rejeter</ActionBtn> <input type="hidden" name="status" value="rejected" />
</form> <ActionBtn color="#d8472b">Rejeter</ActionBtn>
</form>
)}
{r.status === "rejected" && (
<form action={setRequestStatus}>
<input type="hidden" name="id" value={r.id} />
<input type="hidden" name="status" value="new" />
<ActionBtn color="#6c6150">Rouvrir</ActionBtn>
</form>
)}
</div> </div>
</div> </div>
{r.message && ( {r.message && (
@@ -109,17 +121,30 @@ export default async function DemandesPage() {
</div> </div>
</div> </div>
<StatusPill map={MSG_STATUS} status={m.status} /> <StatusPill map={MSG_STATUS} status={m.status} />
{/* Chaque bouton fait avancer le message d'un cran : on ne
propose plus « Marquer lu » sur un message déjà lu ni
« Archiver » sur un message archivé. */}
<div style={{ display: "flex", gap: 8 }}> <div style={{ display: "flex", gap: 8 }}>
<form action={setContactStatus}> {m.status === "new" && (
<input type="hidden" name="id" value={m.id} /> <form action={setContactStatus}>
<input type="hidden" name="status" value="read" /> <input type="hidden" name="id" value={m.id} />
<ActionBtn color="#2C6FB3">Marquer lu</ActionBtn> <input type="hidden" name="status" value="read" />
</form> <ActionBtn color="#2C6FB3">Marquer lu</ActionBtn>
<form action={setContactStatus}> </form>
<input type="hidden" name="id" value={m.id} /> )}
<input type="hidden" name="status" value="archived" /> {m.status !== "archived" ? (
<ActionBtn color="#a99c82">Archiver</ActionBtn> <form action={setContactStatus}>
</form> <input type="hidden" name="id" value={m.id} />
<input type="hidden" name="status" value="archived" />
<ActionBtn color="#a99c82">Archiver</ActionBtn>
</form>
) : (
<form action={setContactStatus}>
<input type="hidden" name="id" value={m.id} />
<input type="hidden" name="status" value="read" />
<ActionBtn color="#6c6150">Désarchiver</ActionBtn>
</form>
)}
</div> </div>
</div> </div>
<p style={{ margin: "10px 0 0", fontSize: 13.5, color: "#5a5040", whiteSpace: "pre-wrap" }}>{m.message}</p> <p style={{ margin: "10px 0 0", fontSize: 13.5, color: "#5a5040", whiteSpace: "pre-wrap" }}>{m.message}</p>