Comptes adherents, boite de reception admin, edition profil

Comptes de connexion adherent
- addMember cree desormais un compte users (role member, lie a la fiche)
  avec un mot de passe temporaire genere
- Le mdp temporaire s'affiche sur la fiche admin (/backend/adherents/[id])
  jusqu'a la 1ere connexion ; bouton "Reinitialiser le mot de passe"
- 1ere connexion : changement de mot de passe force (must_change_password)
  via /backend/changer-mot-de-passe, redirection geree dans authorized()
- users += must_change_password, temp_password (migration 0003)

Boite de reception admin (consultation)
- Page /backend/demandes : demandes d'adhesion + messages de contact
  avec statuts (approuvee/rejetee, lu/archive)
- Lien sidebar + badge (nouveaux), carte tableau de bord cliquable

Espace adherent
- L'adherent peut editer sa propre fiche (updateOwnProfile) : infos,
  description, horaires, tags, image d'entete + logo
- Statut/categorie/mise a l'honneur restent reserves au staff

Promotions : moderation deja en place, inchangee.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Opus 4.8 committed 2026-06-27 09:47:05 +02:00
1 parent b4d94f2115
commit 119ad4665a
16 files changed
+1146 -25

No files matched your search

+149 -2
View File
@@ -8,7 +8,9 @@ import { db } from "@/db";
import {
activityLog,
categories,
contactMessages,
members,
membershipRequests,
promotions,
users,
} from "@/db/schema";
@@ -40,6 +42,16 @@ async function logActivity(message: string, dot = "#2C6FB3") {
await db.insert(activityLog).values({ message, dot });
}
// Mot de passe temporaire lisible (sans caractères ambigus).
function generateTempPassword(): string {
const alphabet = "ABCDEFGHJKMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789";
let out = "";
for (let i = 0; i < 10; i++) {
out += alphabet[Math.floor(Math.random() * alphabet.length)];
}
return out;
}
async function requireRole(): Promise<{ role: AppRole; memberId: number | null; name: string }> {
const session = await auth();
if (!session?.user) throw new Error("Non authentifié");
@@ -108,12 +120,35 @@ export async function addMember(formData: FormData) {
const name = String(formData.get("name") ?? "").trim();
if (!name) return;
const email = String(formData.get("email") ?? "").trim();
const email = String(formData.get("email") ?? "").trim().toLowerCase();
if (!email) throw new Error("L'e-mail est requis pour créer le compte de l'adhérent.");
const categoryId = formData.get("categoryId") ? Number(formData.get("categoryId")) : null;
const city = String(formData.get("city") ?? "").trim() || null;
const status = (String(formData.get("status") ?? "pending") as "active" | "pending");
await db.insert(members).values({ name, email, categoryId, city, status });
// Un seul compte par e-mail.
const existing = await db.select({ id: users.id }).from(users).where(eq(users.email, email));
if (existing.length > 0) {
throw new Error("Un compte existe déjà avec cet e-mail.");
}
const [newMember] = await db
.insert(members)
.values({ name, email, categoryId, city, status })
.returning({ id: members.id });
// Compte de connexion adhérent avec mot de passe temporaire à changer.
const tempPassword = generateTempPassword();
await db.insert(users).values({
name,
email,
role: "member",
memberId: newMember.id,
passwordHash: await bcrypt.hash(tempPassword, 10),
tempPassword,
mustChangePassword: true,
});
await logActivity(`Nouvel adhérent ajouté : <strong>${name}</strong>`, "#2C6FB3");
revalidatePath("/backend/adherents");
@@ -121,6 +156,31 @@ export async function addMember(formData: FormData) {
revalidatePath("/");
}
// Réinitialise le mot de passe d'un adhérent : nouveau mot de passe temporaire
// visible par l'admin jusqu'à la prochaine connexion de l'adhérent.
export async function resetMemberPassword(formData: FormData) {
const { role } = await requireRole();
if (!can(role, "manageMembers")) throw new Error("Accès refusé");
const memberId = Number(formData.get("memberId"));
if (!memberId) return;
const [u] = await db.select().from(users).where(eq(users.memberId, memberId));
if (!u) throw new Error("Aucun compte de connexion lié à cet adhérent.");
const tempPassword = generateTempPassword();
await db
.update(users)
.set({
passwordHash: await bcrypt.hash(tempPassword, 10),
tempPassword,
mustChangePassword: true,
})
.where(eq(users.id, u.id));
revalidatePath(`/backend/adherents/${memberId}`);
}
export async function updateMember(formData: FormData) {
const { role } = await requireRole();
if (!can(role, "manageMembers")) throw new Error("Accès refusé");
@@ -267,6 +327,93 @@ export async function deleteCategory(formData: FormData) {
revalidatePath("/annuaire");
}
// ---- Self password change (forced at first login) ----
export async function changeOwnPassword(formData: FormData) {
const session = await auth();
if (!session?.user) throw new Error("Non authentifié");
const userId = Number(session.user.id);
const password = String(formData.get("password") ?? "");
const confirm = String(formData.get("confirm") ?? "");
if (password.length < 8) throw new Error("Le mot de passe doit faire au moins 8 caractères.");
if (password !== confirm) throw new Error("Les deux mots de passe ne correspondent pas.");
await db
.update(users)
.set({
passwordHash: await bcrypt.hash(password, 10),
tempPassword: null,
mustChangePassword: false,
})
.where(eq(users.id, userId));
// Force une nouvelle session (jeton sans le drapeau « mot de passe à changer »).
await signOut({ redirectTo: "/login" });
}
// ---- Member: edit own profile ----
export async function updateOwnProfile(formData: FormData) {
const session = await auth();
if (!session?.user) throw new Error("Non authentifié");
const memberId = session.user.memberId;
if (!memberId) throw new Error("Aucune fiche adhérent liée à votre compte.");
const name = String(formData.get("name") ?? "").trim();
if (!name) return;
await db
.update(members)
.set({
name,
description: String(formData.get("description") ?? "").trim() || null,
address: String(formData.get("address") ?? "").trim() || null,
postalCode: String(formData.get("postalCode") ?? "").trim() || null,
city: String(formData.get("city") ?? "").trim() || null,
phone: String(formData.get("phone") ?? "").trim() || null,
website: String(formData.get("website") ?? "").trim() || null,
hours: String(formData.get("hours") ?? "").trim() || null,
tags: String(formData.get("tags") ?? "").trim() || null,
coverUrl: String(formData.get("coverUrl") ?? "").trim() || null,
logoUrl: String(formData.get("logoUrl") ?? "").trim() || null,
})
// Sécurité : on ne touche que SA propre fiche, jamais statut/catégorie/mise à l'honneur.
.where(eq(members.id, memberId));
revalidatePath("/backend/espace");
revalidatePath(`/adherents/${memberId}`);
revalidatePath("/annuaire");
revalidatePath("/");
}
// ---- Inbox: membership requests + contact messages ----
export async function setRequestStatus(formData: FormData) {
const { role } = await requireRole();
if (!can(role, "manageMembers")) throw new Error("Accès refusé");
const id = Number(formData.get("id"));
const status = String(formData.get("status") ?? "");
if (!id || !["new", "approved", "rejected"].includes(status)) return;
await db
.update(membershipRequests)
.set({ status: status as "new" | "approved" | "rejected" })
.where(eq(membershipRequests.id, id));
revalidatePath("/backend/demandes");
revalidatePath("/backend");
}
export async function setContactStatus(formData: FormData) {
const { role } = await requireRole();
if (!can(role, "manageMembers")) throw new Error("Accès refusé");
const id = Number(formData.get("id"));
const status = String(formData.get("status") ?? "");
if (!id || !["new", "read", "archived"].includes(status)) return;
await db
.update(contactMessages)
.set({ status: status as "new" | "read" | "archived" })
.where(eq(contactMessages.id, id));
revalidatePath("/backend/demandes");
revalidatePath("/backend");
}
// ---- Sign out ----
export async function doSignOut() {
await signOut({ redirectTo: "/" });