diff --git a/drizzle/0003_outstanding_jimmy_woo.sql b/drizzle/0003_outstanding_jimmy_woo.sql new file mode 100644 index 0000000..44de6cc --- /dev/null +++ b/drizzle/0003_outstanding_jimmy_woo.sql @@ -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); \ No newline at end of file diff --git a/drizzle/meta/0003_snapshot.json b/drizzle/meta/0003_snapshot.json new file mode 100644 index 0000000..965c4db --- /dev/null +++ b/drizzle/meta/0003_snapshot.json @@ -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": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 7bb66d0..3e428f0 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -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 } ] } \ No newline at end of file diff --git a/src/app/backend/BackendShell.tsx b/src/app/backend/BackendShell.tsx index 5e3ef7a..dce5b9a 100644 --- a/src/app/backend/BackendShell.tsx +++ b/src/app/backend/BackendShell.tsx @@ -10,6 +10,7 @@ import { doSignOut } from "./actions"; const TITLES: Record = { "/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 = { "/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 )} + {can(user.role, "manageMembers") && ( + + {dot}Demandes & messages + {inboxCount > 0 && ( + + {inboxCount} + + )} + + )} {can(user.role, "moderatePromos") && ( {dotDiamond}Promotions diff --git a/src/app/backend/actions.ts b/src/app/backend/actions.ts index c811dbb..585ce64 100644 --- a/src/app/backend/actions.ts +++ b/src/app/backend/actions.ts @@ -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é : ${name}`, "#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: "/" }); diff --git a/src/app/backend/adherents/AddMemberPanel.tsx b/src/app/backend/adherents/AddMemberPanel.tsx index f73089b..f302376 100644 --- a/src/app/backend/adherents/AddMemberPanel.tsx +++ b/src/app/backend/adherents/AddMemberPanel.tsx @@ -36,8 +36,8 @@ export function AddMemberPanel({
- - + +
@@ -62,10 +62,14 @@ export function AddMemberPanel({
+
+ Un compte de connexion est créé automatiquement. Le mot de passe temporaire s'affiche + sur la fiche de l'adhérent jusqu'à sa première connexion. +
diff --git a/src/app/backend/adherents/[id]/page.tsx b/src/app/backend/adherents/[id]/page.tsx index 94297f2..3721dc8 100644 --- a/src/app/backend/adherents/[id]/page.tsx +++ b/src/app/backend/adherents/[id]/page.tsx @@ -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 (
@@ -147,6 +160,44 @@ export default async function EditMemberPage({
+
+

+ Compte de connexion +

+ {account ? ( + <> +
+ Identifiant : {account.email} +
+ {account.tempPassword ? ( +
+ Mot de passe temporaire :{" "} + {account.tempPassword} +
+ Communiquez-le à l'adhérent. Il disparaît dès sa première connexion (changement obligatoire). +
+
+ ) : ( +
+ ✓ L'adhérent a défini son propre mot de passe. +
+ )} +
+ +
+ + ) : ( +
+ Aucun compte de connexion lié à cet adhérent. +
+ )} +
+
+
+ + + ); +} diff --git a/src/app/backend/demandes/page.tsx b/src/app/backend/demandes/page.tsx new file mode 100644 index 0000000..1a01f24 --- /dev/null +++ b/src/app/backend/demandes/page.tsx @@ -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 = { + 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 = { + 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 ( + + {s.label} + + ); +} + +function ActionBtn({ children, color }: { children: React.ReactNode; color: string }) { + return ( + + ); +} + +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 ( +
+ {/* ---- Demandes d'adhésion ---- */} +
+
+ Demandes d'adhésion ({requests.length}) +
+ {requests.length === 0 && ( +
Aucune demande.
+ )} + {requests.map((r) => ( +
+
+
+
{r.name}
+
+ {[r.email, fmt(r.createdAt)].filter(Boolean).join(" · ")} +
+
+ +
+
+ + + Approuver +
+
+ + + Rejeter +
+
+
+ {r.message && ( +

{r.message}

+ )} +
+ ))} +
+ + {/* ---- Messages de contact ---- */} +
+
+ Messages de contact ({messages.length}) +
+ {messages.length === 0 && ( +
Aucun message.
+ )} + {messages.map((m) => ( +
+
+
+
+ {m.name} + {m.subject ? — {m.subject} : null} +
+
+ {m.email} · {fmt(m.createdAt)} +
+
+ +
+
+ + + Marquer lu +
+
+ + + Archiver +
+
+
+

{m.message}

+
+ ))} +
+
+ ); +} diff --git a/src/app/backend/espace/page.tsx b/src/app/backend/espace/page.tsx index c980f4f..4b7982b 100644 --- a/src/app/backend/espace/page.tsx +++ b/src/app/backend/espace/page.tsx @@ -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() { + {profile && ( +
+

+ Mon profil +

+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ +