mirror of
https://github.com/R0m1k3/PleinR.git
synced 2026-10-11 17:27:54 +02:00
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:
1 parent
b4d94f2115
commit
119ad4665a
16 files changed
+1146
-25
No files matched your search
@@ -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);
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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 & 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
@@ -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: "/" });
|
||||
|
||||
@@ -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'affiche
|
||||
sur la fiche de l'adhérent jusqu'à 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'adhérent
|
||||
</button>
|
||||
|
||||
@@ -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'adhérent. Il disparaît dès sa première connexion (changement obligatoire).
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ fontSize: 13, color: "#1f8a5b", fontWeight: 600 }}>
|
||||
✓ L'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>
|
||||
);
|
||||
}
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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;
|
||||
},
|
||||
|
||||
@@ -34,6 +34,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
memberId: user.memberId,
|
||||
mustChangePassword: user.mustChangePassword,
|
||||
};
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
Vendored
+3
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user