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

+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE "users" ADD COLUMN "must_change_password" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "users" ADD COLUMN "temp_password" varchar(60);
+605
View File
@@ -0,0 +1,605 @@
{
"id": "1bcd4ce1-1820-4acb-afe5-5f336715f25a",
"prevId": "0a329bb6-174e-4b49-b244-fe5be0324a99",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.activity_log": {
"name": "activity_log",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"dot": {
"name": "dot",
"type": "varchar(16)",
"primaryKey": false,
"notNull": true,
"default": "'#2C6FB3'"
},
"message": {
"name": "message",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.categories": {
"name": "categories",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"slug": {
"name": "slug",
"type": "varchar(80)",
"primaryKey": false,
"notNull": true
},
"label": {
"name": "label",
"type": "varchar(120)",
"primaryKey": false,
"notNull": true
},
"accent": {
"name": "accent",
"type": "varchar(16)",
"primaryKey": false,
"notNull": true,
"default": "'#E0A63C'"
},
"tint": {
"name": "tint",
"type": "varchar(16)",
"primaryKey": false,
"notNull": true,
"default": "'#f6efdc'"
},
"sort": {
"name": "sort",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"categories_slug_unique": {
"name": "categories_slug_unique",
"nullsNotDistinct": false,
"columns": [
"slug"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.contact_messages": {
"name": "contact_messages",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "varchar(200)",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "varchar(200)",
"primaryKey": false,
"notNull": true
},
"subject": {
"name": "subject",
"type": "varchar(200)",
"primaryKey": false,
"notNull": false
},
"message": {
"name": "message",
"type": "text",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "contact_status",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'new'"
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.members": {
"name": "members",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "varchar(200)",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "varchar(200)",
"primaryKey": false,
"notNull": true
},
"category_id": {
"name": "category_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"city": {
"name": "city",
"type": "varchar(120)",
"primaryKey": false,
"notNull": false
},
"address": {
"name": "address",
"type": "varchar(240)",
"primaryKey": false,
"notNull": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"status": {
"name": "status",
"type": "member_status",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'pending'"
},
"highlighted": {
"name": "highlighted",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"logo_url": {
"name": "logo_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"cover_url": {
"name": "cover_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"phone": {
"name": "phone",
"type": "varchar(40)",
"primaryKey": false,
"notNull": false
},
"website": {
"name": "website",
"type": "varchar(200)",
"primaryKey": false,
"notNull": false
},
"postal_code": {
"name": "postal_code",
"type": "varchar(20)",
"primaryKey": false,
"notNull": false
},
"member_since": {
"name": "member_since",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"hours": {
"name": "hours",
"type": "text",
"primaryKey": false,
"notNull": false
},
"tags": {
"name": "tags",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"members_category_id_categories_id_fk": {
"name": "members_category_id_categories_id_fk",
"tableFrom": "members",
"tableTo": "categories",
"columnsFrom": [
"category_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.membership_requests": {
"name": "membership_requests",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "varchar(200)",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "varchar(200)",
"primaryKey": false,
"notNull": false
},
"message": {
"name": "message",
"type": "text",
"primaryKey": false,
"notNull": false
},
"status": {
"name": "status",
"type": "request_status",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'new'"
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.promotions": {
"name": "promotions",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"title": {
"name": "title",
"type": "varchar(200)",
"primaryKey": false,
"notNull": true
},
"text": {
"name": "text",
"type": "text",
"primaryKey": false,
"notNull": false
},
"category": {
"name": "category",
"type": "varchar(120)",
"primaryKey": false,
"notNull": false
},
"badge": {
"name": "badge",
"type": "varchar(40)",
"primaryKey": false,
"notNull": false
},
"image_url": {
"name": "image_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"member_id": {
"name": "member_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"status": {
"name": "status",
"type": "promo_status",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'pending'"
},
"valid_until": {
"name": "valid_until",
"type": "varchar(120)",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"promotions_member_id_members_id_fk": {
"name": "promotions_member_id_members_id_fk",
"tableFrom": "promotions",
"tableTo": "members",
"columnsFrom": [
"member_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.users": {
"name": "users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"email": {
"name": "email",
"type": "varchar(200)",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "varchar(200)",
"primaryKey": false,
"notNull": true
},
"password_hash": {
"name": "password_hash",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"role": {
"name": "role",
"type": "role",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'member'"
},
"member_id": {
"name": "member_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"must_change_password": {
"name": "must_change_password",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"temp_password": {
"name": "temp_password",
"type": "varchar(60)",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"users_email_idx": {
"name": "users_email_idx",
"columns": [
{
"expression": "email",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"users_member_id_members_id_fk": {
"name": "users_member_id_members_id_fk",
"tableFrom": "users",
"tableTo": "members",
"columnsFrom": [
"member_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {
"public.contact_status": {
"name": "contact_status",
"schema": "public",
"values": [
"new",
"read",
"archived"
]
},
"public.member_status": {
"name": "member_status",
"schema": "public",
"values": [
"active",
"pending"
]
},
"public.promo_status": {
"name": "promo_status",
"schema": "public",
"values": [
"pending",
"live",
"expired",
"rejected"
]
},
"public.request_status": {
"name": "request_status",
"schema": "public",
"values": [
"new",
"approved",
"rejected"
]
},
"public.role": {
"name": "role",
"schema": "public",
"values": [
"admin",
"moderator",
"editor",
"member"
]
}
},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
+7
View File
@@ -22,6 +22,13 @@
"when": 1782544443365,
"tag": "0002_puzzling_kabuki",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1782545608919,
"tag": "0003_outstanding_jimmy_woo",
"breakpoints": true
}
]
}
+24
View File
@@ -10,6 +10,7 @@ import { doSignOut } from "./actions";
const TITLES: Record<string, [string, string]> = {
"/backend": ["Tableau de bord", "Vue d'ensemble de l'association"],
"/backend/adherents": ["Adhérents", "Gérer les commerçants et entreprises"],
"/backend/demandes": ["Demandes & messages", "Demandes d'adhésion et messages de contact"],
"/backend/promotions": [
"Modération des promotions",
"Validez les offres soumises par les adhérents",
@@ -17,6 +18,7 @@ const TITLES: Record<string, [string, string]> = {
"/backend/administrateurs": ["Administrateurs", "Gérer les accès à l'administration"],
"/backend/categories": ["Catégories", "Gérer les métiers de l'annuaire"],
"/backend/espace": ["Mon espace adhérent", "Publiez et suivez vos promotions"],
"/backend/changer-mot-de-passe": ["Mot de passe", "Sécurisez votre compte"],
};
function initialsOf(name: string) {
@@ -47,10 +49,12 @@ function NavLink({
export function BackendShell({
user,
pendingCount,
inboxCount = 0,
children,
}: {
user: { name: string; role: AppRole };
pendingCount: number;
inboxCount?: number;
children: ReactNode;
}) {
const pathname = usePathname();
@@ -99,6 +103,26 @@ export function BackendShell({
{dotRound}Adhérents
</NavLink>
)}
{can(user.role, "manageMembers") && (
<NavLink href="/backend/demandes" active={pathname === "/backend/demandes"}>
{dot}Demandes &amp; messages
{inboxCount > 0 && (
<span
style={{
marginLeft: "auto",
background: "#E0A63C",
color: "#33291D",
fontSize: 11,
fontWeight: 800,
borderRadius: 999,
padding: "1px 8px",
}}
>
{inboxCount}
</span>
)}
</NavLink>
)}
{can(user.role, "moderatePromos") && (
<NavLink href="/backend/promotions" active={pathname === "/backend/promotions"}>
{dotDiamond}Promotions
+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: "/" });
+7 -3
View File
@@ -36,8 +36,8 @@ export function AddMemberPanel({
<input name="name" required placeholder="ex : Au Bon Pain" className="field" />
</div>
<div>
<label className="field-label">E-mail</label>
<input name="email" type="email" placeholder="contact@exemple.fr" className="field" />
<label className="field-label">E-mail (identifiant de connexion)</label>
<input name="email" type="email" required placeholder="contact@exemple.fr" className="field" />
</div>
<div>
<label className="field-label">Catégorie</label>
@@ -62,10 +62,14 @@ export function AddMemberPanel({
</select>
</div>
</div>
<div style={{ marginTop: 14, fontSize: 12.5, color: "#9a8d72" }}>
Un compte de connexion est créé automatiquement. Le mot de passe temporaire s&apos;affiche
sur la fiche de l&apos;adhérent jusqu&apos;à sa première connexion.
</div>
<button
type="submit"
className="font-display"
style={{ marginTop: 18, border: "none", background: "#9a6638", color: "#fff", fontWeight: 700, fontSize: 14.5, padding: "12px 22px", borderRadius: 11, cursor: "pointer" }}
style={{ marginTop: 14, border: "none", background: "#9a6638", color: "#fff", fontWeight: 700, fontSize: 14.5, padding: "12px 22px", borderRadius: 11, cursor: "pointer" }}
>
Enregistrer l&apos;adhérent
</button>
+53 -2
View File
@@ -3,10 +3,10 @@ import { notFound, redirect } from "next/navigation";
import { asc, eq } from "drizzle-orm";
import { auth } from "@/auth";
import { db } from "@/db";
import { categories, members } from "@/db/schema";
import { categories, members, users } from "@/db/schema";
import { can } from "@/lib/rbac";
import { ImageField } from "@/components/ImageField";
import { deleteMember, updateMember } from "../../actions";
import { deleteMember, resetMemberPassword, updateMember } from "../../actions";
export const dynamic = "force-dynamic";
@@ -32,6 +32,11 @@ export default async function EditMemberPage({
.from(categories)
.orderBy(asc(categories.sort));
const [account] = await db
.select({ email: users.email, tempPassword: users.tempPassword, mustChange: users.mustChangePassword })
.from(users)
.where(eq(users.memberId, memberId));
async function handleDelete() {
"use server";
const fd = new FormData();
@@ -46,6 +51,14 @@ export default async function EditMemberPage({
redirect("/backend/adherents");
}
async function handleReset() {
"use server";
const fd = new FormData();
fd.set("memberId", String(memberId));
await resetMemberPassword(fd);
redirect(`/backend/adherents/${memberId}`);
}
return (
<div style={{ maxWidth: 720 }}>
<Link href="/backend/adherents" style={{ textDecoration: "none", color: "#6f6450", fontSize: 13.5, fontWeight: 600 }}>
@@ -147,6 +160,44 @@ export default async function EditMemberPage({
</div>
</form>
<div style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 16, padding: 22, marginTop: 16 }}>
<h3 className="font-display" style={{ fontWeight: 700, fontSize: 16, margin: "0 0 12px", color: "#26201a" }}>
Compte de connexion
</h3>
{account ? (
<>
<div style={{ fontSize: 13.5, color: "#5a5040", marginBottom: 10 }}>
Identifiant : <strong>{account.email}</strong>
</div>
{account.tempPassword ? (
<div style={{ background: "#fbeede", border: "1px solid #ecd8b8", borderRadius: 10, padding: "11px 14px", fontSize: 13.5, color: "#9a6638" }}>
Mot de passe temporaire :{" "}
<strong style={{ fontFamily: "monospace", fontSize: 15 }}>{account.tempPassword}</strong>
<div style={{ fontSize: 12, marginTop: 4 }}>
Communiquez-le à l&apos;adhérent. Il disparaît dès sa première connexion (changement obligatoire).
</div>
</div>
) : (
<div style={{ fontSize: 13, color: "#1f8a5b", fontWeight: 600 }}>
✓ L&apos;adhérent a défini son propre mot de passe.
</div>
)}
<form action={handleReset} style={{ marginTop: 14 }}>
<button
type="submit"
style={{ border: "1px solid #d8cdb4", background: "#fff", color: "#6f6450", fontWeight: 600, fontSize: 13, padding: "9px 15px", borderRadius: 9, cursor: "pointer" }}
>
Réinitialiser le mot de passe
</button>
</form>
</>
) : (
<div style={{ fontSize: 13.5, color: "#a99c82" }}>
Aucun compte de connexion lié à cet adhérent.
</div>
)}
</div>
<form action={handleDelete} style={{ marginTop: 16 }}>
<button
type="submit"
@@ -0,0 +1,44 @@
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { changeOwnPassword } from "../actions";
export const dynamic = "force-dynamic";
export default async function ChangePasswordPage() {
const session = await auth();
if (!session?.user) redirect("/login");
const forced = session.user.mustChangePassword;
return (
<div style={{ maxWidth: 460 }}>
<div style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 16, padding: 26 }}>
<h3 className="font-display" style={{ fontWeight: 800, fontSize: 20, margin: "0 0 8px", color: "#26201a" }}>
Changer mon mot de passe
</h3>
{forced && (
<div style={{ background: "#fbeede", border: "1px solid #ecd8b8", color: "#9a6638", borderRadius: 10, padding: "11px 14px", fontSize: 13.5, marginBottom: 16 }}>
Première connexion : pour des raisons de sécurité, choisissez un nouveau mot de passe
avant de continuer.
</div>
)}
<form action={changeOwnPassword}>
<label className="field-label">Nouveau mot de passe (8 caractères min.)</label>
<input name="password" type="password" required minLength={8} className="field" style={{ marginBottom: 14 }} autoComplete="new-password" />
<label className="field-label">Confirmer le mot de passe</label>
<input name="confirm" type="password" required minLength={8} className="field" style={{ marginBottom: 18 }} autoComplete="new-password" />
<button
type="submit"
className="font-display"
style={{ width: "100%", border: "none", background: "#13324F", color: "#fff", fontWeight: 700, fontSize: 14.5, padding: 13, borderRadius: 11, cursor: "pointer" }}
>
Enregistrer le nouveau mot de passe
</button>
</form>
</div>
</div>
);
}
+134
View File
@@ -0,0 +1,134 @@
import { redirect } from "next/navigation";
import { desc } from "drizzle-orm";
import { auth } from "@/auth";
import { db } from "@/db";
import { contactMessages, membershipRequests } from "@/db/schema";
import { can } from "@/lib/rbac";
import { setContactStatus, setRequestStatus } from "../actions";
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 auth();
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&apos;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 }}>
<form action={setRequestStatus}>
<input type="hidden" name="id" value={r.id} />
<input type="hidden" name="status" value="approved" />
<ActionBtn color="#1f8a5b">Approuver</ActionBtn>
</form>
<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>
);
}
+73 -7
View File
@@ -1,7 +1,9 @@
import { asc, desc, eq } from "drizzle-orm";
import { auth } from "@/auth";
import { db } from "@/db";
import { categories, members, promotions } from "@/db/schema";
import { categories, members, promotions, type Member } from "@/db/schema";
import { ImageField } from "@/components/ImageField";
import { updateOwnProfile } from "../actions";
import { MemberSpaceForm } from "./MemberSpaceForm";
export const dynamic = "force-dynamic";
@@ -24,17 +26,18 @@ export default async function EspacePage() {
let memberName = fallbackName;
let subtitle = "Espace adhérent";
let profile: Member | null = null;
let myPromos: { id: number; title: string; status: string; createdAt: Date; imageUrl: string | null }[] = [];
if (memberId) {
const [m] = await db
.select({ name: members.name, city: members.city, categoryLabel: categories.label })
.from(members)
.leftJoin(categories, eq(members.categoryId, categories.id))
.where(eq(members.id, memberId));
const [m] = await db.select().from(members).where(eq(members.id, memberId));
if (m) {
profile = m;
memberName = m.name;
subtitle = ["Espace adhérent", m.categoryLabel, m.city].filter(Boolean).join(" · ");
const [cat] = m.categoryId
? await db.select({ label: categories.label }).from(categories).where(eq(categories.id, m.categoryId))
: [];
subtitle = ["Espace adhérent", cat?.label, m.city].filter(Boolean).join(" · ");
}
myPromos = await db
.select({
@@ -80,6 +83,69 @@ export default async function EspacePage() {
</div>
</div>
{profile && (
<form action={updateOwnProfile} style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 16, padding: 24, marginBottom: 28 }}>
<h3 className="font-display" style={{ fontWeight: 700, fontSize: 18, margin: "0 0 18px", color: "#26201a" }}>
Mon profil
</h3>
<div className="grid grid-2" style={{ gap: 16 }}>
<div>
<label className="field-label">Nom</label>
<input name="name" required defaultValue={profile.name} className="field" />
</div>
<div>
<label className="field-label">Commune</label>
<input name="city" defaultValue={profile.city ?? ""} className="field" />
</div>
<div>
<label className="field-label">Adresse</label>
<input name="address" defaultValue={profile.address ?? ""} className="field" />
</div>
<div>
<label className="field-label">Code postal</label>
<input name="postalCode" defaultValue={profile.postalCode ?? ""} className="field" />
</div>
<div>
<label className="field-label">Téléphone</label>
<input name="phone" defaultValue={profile.phone ?? ""} className="field" />
</div>
<div>
<label className="field-label">Site web</label>
<input name="website" defaultValue={profile.website ?? ""} className="field" placeholder="https://..." />
</div>
</div>
<div className="grid grid-2" style={{ gap: 16, marginTop: 16 }}>
<ImageField name="coverUrl" label="Image d'entête (bannière)" defaultValue={profile.coverUrl ?? ""} height={130} />
<ImageField name="logoUrl" label="Logo" defaultValue={profile.logoUrl ?? ""} height={130} />
</div>
<div style={{ marginTop: 16 }}>
<label className="field-label">Description (une ligne vide sépare les paragraphes)</label>
<textarea name="description" rows={4} defaultValue={profile.description ?? ""} className="field" style={{ resize: "vertical" }} />
</div>
<div style={{ marginTop: 16 }}>
<label className="field-label">Tags / spécialités (séparés par des virgules)</label>
<input name="tags" defaultValue={profile.tags ?? ""} className="field" />
</div>
<div style={{ marginTop: 16 }}>
<label className="field-label">Horaires (une ligne par jour : « Jour|plage »)</label>
<textarea name="hours" rows={4} defaultValue={profile.hours ?? ""} className="field" style={{ resize: "vertical" }} placeholder={"Mardi – Vendredi|7h – 19h30\nSamedi|7h – 19h"} />
</div>
<button
type="submit"
className="font-display"
style={{ marginTop: 20, border: "none", background: "#13324F", color: "#fff", fontWeight: 700, fontSize: 14.5, padding: "13px 24px", borderRadius: 11, cursor: "pointer" }}
>
Enregistrer mon profil
</button>
</form>
)}
<MemberSpaceForm memberName={memberName} categories={categoryLabels} />
<div style={{ marginTop: 28 }}>
+15 -6
View File
@@ -2,7 +2,8 @@ import { redirect } from "next/navigation";
import { eq } from "drizzle-orm";
import { auth } from "@/auth";
import { db } from "@/db";
import { promotions } from "@/db/schema";
import { contactMessages, membershipRequests, promotions } from "@/db/schema";
import { isStaff } from "@/lib/rbac";
import { BackendShell } from "./BackendShell";
export const dynamic = "force-dynamic";
@@ -17,15 +18,23 @@ export default async function BackendLayout({
redirect("/login");
}
const pendingRows = await db
.select({ id: promotions.id })
.from(promotions)
.where(eq(promotions.status, "pending"));
let pendingCount = 0;
let inboxCount = 0;
if (isStaff(session.user.role)) {
const [pendingRows, newReqs, newMsgs] = await Promise.all([
db.select({ id: promotions.id }).from(promotions).where(eq(promotions.status, "pending")),
db.select({ id: membershipRequests.id }).from(membershipRequests).where(eq(membershipRequests.status, "new")),
db.select({ id: contactMessages.id }).from(contactMessages).where(eq(contactMessages.status, "new")),
]);
pendingCount = pendingRows.length;
inboxCount = newReqs.length + newMsgs.length;
}
return (
<BackendShell
user={{ name: session.user.name ?? "Adhérent", role: session.user.role }}
pendingCount={pendingRows.length}
pendingCount={pendingCount}
inboxCount={inboxCount}
>
{children}
</BackendShell>
+12 -3
View File
@@ -3,7 +3,7 @@ import { redirect } from "next/navigation";
import { desc, eq } from "drizzle-orm";
import { auth } from "@/auth";
import { db } from "@/db";
import { activityLog, members, membershipRequests, promotions } from "@/db/schema";
import { activityLog, contactMessages, members, membershipRequests, promotions } from "@/db/schema";
import { isStaff } from "@/lib/rbac";
export const dynamic = "force-dynamic";
@@ -38,11 +38,12 @@ export default async function DashboardPage() {
redirect("/backend/espace");
}
const [activeMembers, livePromos, pendingPromos, requests, activity] = await Promise.all([
const [activeMembers, livePromos, pendingPromos, requests, newMessages, activity] = await Promise.all([
db.select({ id: members.id }).from(members).where(eq(members.status, "active")),
db.select({ id: promotions.id }).from(promotions).where(eq(promotions.status, "live")),
db.select({ id: promotions.id }).from(promotions).where(eq(promotions.status, "pending")),
db.select({ id: membershipRequests.id }).from(membershipRequests).where(eq(membershipRequests.status, "new")),
db.select({ id: contactMessages.id }).from(contactMessages).where(eq(contactMessages.status, "new")),
db.select().from(activityLog).orderBy(desc(activityLog.createdAt)).limit(6),
]);
@@ -63,7 +64,15 @@ export default async function DashboardPage() {
<StatCard label="Adhérents actifs" value={activeMembers.length} hint="sur le réseau" valueColor="#13324F" hintColor="#1f8a5b" />
<StatCard label="Promotions en ligne" value={livePromos.length} hint="visibles sur le site" />
<StatCard label="À modérer" value={pendingCount} hint="en attente de validation" valueColor="#9a6638" hintColor="#9a6638" />
<StatCard label="Demandes d'adhésion" value={requests.length} hint="à traiter" valueColor="#13324F" hintColor="#9a6638" />
<Link href="/backend/demandes" style={{ textDecoration: "none" }}>
<StatCard
label="Demandes & messages"
value={requests.length + newMessages.length}
hint={`${requests.length} adhésion · ${newMessages.length} contact`}
valueColor="#13324F"
hintColor="#9a6638"
/>
</Link>
</div>
<div className="grid dash-split">
+15 -2
View File
@@ -1,4 +1,7 @@
import type { NextAuthConfig } from "next-auth";
import { NextResponse } from "next/server";
const CHANGE_PW_PATH = "/backend/changer-mot-de-passe";
/**
* Edge-safe Auth.js configuration (no database / bcrypt imports here).
@@ -18,6 +21,8 @@ export const authConfig = {
token.uid = (user as { id?: string }).id ?? token.sub;
token.role = (user as { role?: string }).role;
token.memberId = (user as { memberId?: number | null }).memberId ?? null;
token.mustChangePassword =
(user as { mustChangePassword?: boolean }).mustChangePassword ?? false;
}
return token;
},
@@ -30,14 +35,22 @@ export const authConfig = {
| "editor"
| "member";
session.user.memberId = (token.memberId as number | null) ?? null;
session.user.mustChangePassword = Boolean(token.mustChangePassword);
}
return session;
},
authorized({ auth, request }) {
const isLoggedIn = !!auth?.user;
const { pathname } = request.nextUrl;
const { pathname, origin } = request.nextUrl;
if (pathname.startsWith("/backend")) {
return isLoggedIn;
if (!isLoggedIn) return false;
const mustChange = Boolean(
(auth?.user as { mustChangePassword?: boolean } | undefined)?.mustChangePassword,
);
if (mustChange && pathname !== CHANGE_PW_PATH) {
return NextResponse.redirect(new URL(CHANGE_PW_PATH, origin));
}
return true;
}
return true;
},
+1
View File
@@ -34,6 +34,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
email: user.email,
role: user.role,
memberId: user.memberId,
mustChangePassword: user.mustChangePassword,
};
},
}),
+2
View File
@@ -73,6 +73,8 @@ export const users = pgTable(
passwordHash: varchar("password_hash", { length: 255 }).notNull(),
role: roleEnum("role").notNull().default("member"),
memberId: integer("member_id").references(() => members.id),
mustChangePassword: boolean("must_change_password").notNull().default(false),
tempPassword: varchar("temp_password", { length: 60 }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
},
(t) => ({
+3
View File
@@ -8,12 +8,14 @@ declare module "next-auth" {
id: string;
role: AppRole;
memberId: number | null;
mustChangePassword: boolean;
} & DefaultSession["user"];
}
interface User {
role: AppRole;
memberId: number | null;
mustChangePassword?: boolean;
}
}
@@ -22,5 +24,6 @@ declare module "next-auth/jwt" {
uid?: string;
role?: AppRole;
memberId?: number | null;
mustChangePassword?: boolean;
}
}