mirror of
https://github.com/R0m1k3/PleinR.git
synced 2026-10-11 17:27:54 +02:00
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:
3 files changed
+70
-19
No files matched your search
@@ -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) => {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in new issue
Block a user