From 3b17d7ff4238d18f685410ca57cdde1fe6cf4875 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 09:15:45 +0000 Subject: [PATCH 1/2] =?UTF-8?q?Durcissement=20(lot=20A)=20:=20seed=20s?= =?UTF-8?q?=C3=BBr=20en=20production,=20mots=20de=20passe=20temporaires=20?= =?UTF-8?q?affich=C3=A9s=20une=20seule=20fois?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit de sécurité complet de l'application, puis correctifs du lot A validés : - Seed : plus aucun compte de démonstration (mot de passe « changeme123 ») sans SEED_DEMO=true. L'administrateur initial reçoit un mot de passe aléatoire affiché une fois dans les journaux (ou SEED_ADMIN_PASSWORD) et doit le changer à la première connexion. docker-compose ne fournit plus de mot de passe par défaut. - Mots de passe temporaires : la colonne users.temp_password (en clair) est supprimée (migration 0011). Création d'adhérent, réinitialisation, rattrapage des comptes manquants, approbation de demande et invitation staff renvoient les identifiants, affichés une seule fois par le composant OneTimeCredentials, sans redirection. L'invitation staff, qui ne communiquait jamais le mot de passe, redevient utilisable et impose le changement à la première connexion. - Sessions JWT limitées à 7 jours (30 auparavant). - En-têtes : Strict-Transport-Security ajouté, X-Powered-By supprimé. - docker-compose : port Postgres publié sur 127.0.0.1 uniquement. - Image Docker sur node:22 (Node 20 en fin de vie). - Dépendances : next 15.5.25, pg 8.23, nanoid 3.3.18 (avis GHSA-2v37-7h3g-55p8). Vérifié en local : tests, typage, build de production, migration + seed sur un Postgres 16, et parcours navigateur complet (première connexion, changement forcé, création / réinitialisation / invitation avec affichage unique, cookie de session à 7 jours). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01RAQsCp4nnZbwexCg7NDBHE --- .env.example | 7 +- CLAUDE.md | 11 + Dockerfile | 6 +- docker-compose.yml | 11 +- drizzle/0011_drop_temp_password.sql | 1 + drizzle/meta/0011_snapshot.json | 1371 +++++++++++++++++ drizzle/meta/_journal.json | 7 + next.config.mjs | 8 + package-lock.json | 104 +- package.json | 4 +- src/app/backend/actions.ts | 65 +- src/app/backend/adherents/AddMemberPanel.tsx | 23 +- .../adherents/BackfillAccountsForm.tsx | 49 + .../backend/adherents/ResetPasswordButton.tsx | 36 + src/app/backend/adherents/[id]/page.tsx | 29 +- src/app/backend/adherents/page.tsx | 26 +- .../administrateurs/InviteAdminForm.tsx | 66 + src/app/backend/administrateurs/page.tsx | 30 +- .../backend/demandes/ApproveRequestForm.tsx | 55 + src/app/backend/demandes/page.tsx | 16 +- src/auth.config.ts | 4 + src/components/OneTimeCredentials.tsx | 49 + src/db/schema.ts | 3 +- src/db/seed.ts | 82 +- 24 files changed, 1863 insertions(+), 200 deletions(-) create mode 100644 drizzle/0011_drop_temp_password.sql create mode 100644 drizzle/meta/0011_snapshot.json create mode 100644 src/app/backend/adherents/BackfillAccountsForm.tsx create mode 100644 src/app/backend/adherents/ResetPasswordButton.tsx create mode 100644 src/app/backend/administrateurs/InviteAdminForm.tsx create mode 100644 src/app/backend/demandes/ApproveRequestForm.tsx create mode 100644 src/components/OneTimeCredentials.tsx diff --git a/.env.example b/.env.example index 6597547..b9e6072 100644 --- a/.env.example +++ b/.env.example @@ -23,8 +23,13 @@ AUTH_URL= # ---- First admin user (created by the seed script) ---- SEED_ADMIN_EMAIL=admin@plein-r.fr -SEED_ADMIN_PASSWORD=changeme123 +# Laissez vide : un mot de passe aléatoire est généré au premier démarrage, +# affiché UNE FOIS dans les journaux, et doit être changé à la première connexion. +SEED_ADMIN_PASSWORD= SEED_ADMIN_NAME=Administrateur Plein R +# Comptes et contenus de démonstration (mots de passe connus de tous) : +# uniquement pour un poste de développement, jamais en production. +SEED_DEMO=false # ---- App ---- NODE_ENV=production diff --git a/CLAUDE.md b/CLAUDE.md index 7f81bc9..397585c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -97,6 +97,17 @@ site sans traitement supplémentaire. - La CSP à nonce est posée par `src/middleware.ts`. Elle impose un rendu dynamique : `export const dynamic = "force-dynamic"` est dans `app/layout.tsx`, un HTML pré-généré ne pouvant pas porter de nonce. +- Les mots de passe temporaires (création d'adhérent, réinitialisation, + invitation staff) ne sont **jamais stockés** : l'action les renvoie et le + composant `OneTimeCredentials` les affiche une seule fois, sans redirection. + `users.must_change_password` seul persiste. +- Le seed ne crée en production que l'administrateur initial, avec un mot de + passe aléatoire affiché une fois dans les journaux (ou `SEED_ADMIN_PASSWORD`) + et un changement obligatoire à la première connexion. Les comptes de démo + (`changeme123`) exigent `SEED_DEMO=true`. +- Sessions JWT limitées à 7 jours (`auth.config.ts`), HSTS et suppression de + `X-Powered-By` dans `next.config.mjs`. Le port Postgres de `docker-compose` + n'est publié que sur `127.0.0.1`. - `npm test` verrouille ces protections (`tests/security.test.ts`). ## Roles diff --git a/Dockerfile b/Dockerfile index 2b0985c..d55e99c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,13 +1,13 @@ # syntax=docker/dockerfile:1 # ---- deps: install all dependencies ---- -FROM node:20-alpine AS deps +FROM node:22-alpine AS deps WORKDIR /app COPY package.json package-lock.json* ./ RUN npm ci # ---- builder: build Next.js (standalone) + bundle db scripts ---- -FROM node:20-alpine AS builder +FROM node:22-alpine AS builder WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . @@ -16,7 +16,7 @@ RUN npm run build RUN npm run build:scripts # ---- runner: minimal production image ---- -FROM node:20-alpine AS runner +FROM node:22-alpine AS runner WORKDIR /app ENV NODE_ENV=production \ NEXT_TELEMETRY_DISABLED=1 \ diff --git a/docker-compose.yml b/docker-compose.yml index 8dfe724..1c1cc0c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,8 +16,9 @@ services: volumes: - pgdata:/var/lib/postgresql/data ports: - # Host port rarely used (container stays on 5432). Change the left side if needed. - - "54329:5432" + # Lié à 127.0.0.1 : la base ne doit jamais être joignable depuis le réseau. + # Retirez le préfixe uniquement pour un accès distant volontaire (et protégé). + - "127.0.0.1:54329:5432" healthcheck: test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-pleinr} -d ${POSTGRES_DB:-pleinr}"] interval: 5s @@ -61,7 +62,11 @@ services: LINKEDIN_API_VERSION: ${LINKEDIN_API_VERSION:-} SEED_ON_START: ${SEED_ON_START:-true} SEED_ADMIN_EMAIL: ${SEED_ADMIN_EMAIL:-admin@plein-r.fr} - SEED_ADMIN_PASSWORD: ${SEED_ADMIN_PASSWORD:-changeme123} + # Vide = un mot de passe aléatoire est généré au premier démarrage et + # affiché une seule fois dans les journaux du conteneur. + SEED_ADMIN_PASSWORD: ${SEED_ADMIN_PASSWORD:-} + # Comptes de démonstration (mot de passe connu) : jamais en production. + SEED_DEMO: ${SEED_DEMO:-false} SEED_ADMIN_NAME: ${SEED_ADMIN_NAME:-Administrateur Plein R} ports: # Host port rarely used (container stays on 3000). App reachable at http://HOST:8413 diff --git a/drizzle/0011_drop_temp_password.sql b/drizzle/0011_drop_temp_password.sql new file mode 100644 index 0000000..27693c1 --- /dev/null +++ b/drizzle/0011_drop_temp_password.sql @@ -0,0 +1 @@ +ALTER TABLE "users" DROP COLUMN "temp_password"; \ No newline at end of file diff --git a/drizzle/meta/0011_snapshot.json b/drizzle/meta/0011_snapshot.json new file mode 100644 index 0000000..dc8fade --- /dev/null +++ b/drizzle/meta/0011_snapshot.json @@ -0,0 +1,1371 @@ +{ + "id": "af4fa895-0708-4071-9426-b2f04bf62dff", + "prevId": "040c8440-be22-445f-9cc8-295b8c01f422", + "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.image_consents": { + "name": "image_consents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "member_id": { + "name": "member_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "decision": { + "name": "decision", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "signatory_name": { + "name": "signatory_name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "signature_png": { + "name": "signature_png", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "consent_version": { + "name": "consent_version", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true + }, + "ip": { + "name": "ip", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "image_consents_member_id_members_id_fk": { + "name": "image_consents_member_id_members_id_fk", + "tableFrom": "image_consents", + "tableTo": "members", + "columnsFrom": [ + "member_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.meeting_registrations": { + "name": "meeting_registrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "meeting_id": { + "name": "meeting_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "member_id": { + "name": "member_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "attendee_name": { + "name": "attendee_name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "attendee_company": { + "name": "attendee_company", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "attendee_email": { + "name": "attendee_email", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "attendee_phone": { + "name": "attendee_phone", + "type": "varchar(40)", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "image_consent": { + "name": "image_consent", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "meeting_registrations_meeting_idx": { + "name": "meeting_registrations_meeting_idx", + "columns": [ + { + "expression": "meeting_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "meeting_registrations_member_meeting_idx": { + "name": "meeting_registrations_member_meeting_idx", + "columns": [ + { + "expression": "member_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "meeting_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "meeting_registrations_meeting_id_meetings_id_fk": { + "name": "meeting_registrations_meeting_id_meetings_id_fk", + "tableFrom": "meeting_registrations", + "tableTo": "meetings", + "columnsFrom": [ + "meeting_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "meeting_registrations_member_id_members_id_fk": { + "name": "meeting_registrations_member_id_members_id_fk", + "tableFrom": "meeting_registrations", + "tableTo": "members", + "columnsFrom": [ + "member_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.meetings": { + "name": "meetings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "starts_at": { + "name": "starts_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "location": { + "name": "location", + "type": "varchar(240)", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "capacity": { + "name": "capacity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 30 + }, + "participants_per_account": { + "name": "participants_per_account", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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.past_meeting_photos": { + "name": "past_meeting_photos", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "past_meeting_id": { + "name": "past_meeting_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "caption": { + "name": "caption", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "past_meeting_photos_past_meeting_id_past_meetings_id_fk": { + "name": "past_meeting_photos_past_meeting_id_past_meetings_id_fk", + "tableFrom": "past_meeting_photos", + "tableTo": "past_meetings", + "columnsFrom": [ + "past_meeting_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.past_meetings": { + "name": "past_meetings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "event_date": { + "name": "event_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "location": { + "name": "location", + "type": "varchar(240)", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "participants": { + "name": "participants", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "meeting_id": { + "name": "meeting_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "past_meetings_meeting_id_meetings_id_fk": { + "name": "past_meetings_meeting_id_meetings_id_fk", + "tableFrom": "past_meetings", + "tableTo": "meetings", + "columnsFrom": [ + "meeting_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "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 + }, + "share_facebook": { + "name": "share_facebook", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "share_linkedin": { + "name": "share_linkedin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "suspended_by": { + "name": "suspended_by", + "type": "promo_suspended_by", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "suspended_by_id": { + "name": "suspended_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "suspended_at": { + "name": "suspended_at", + "type": "timestamp with time zone", + "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" + }, + "promotions_suspended_by_id_users_id_fk": { + "name": "promotions_suspended_by_id_users_id_fk", + "tableFrom": "promotions", + "tableTo": "users", + "columnsFrom": [ + "suspended_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.site_settings": { + "name": "site_settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "varchar(120)", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.social_accounts": { + "name": "social_accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "network": { + "name": "network", + "type": "social_network", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "app_id": { + "name": "app_id", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "app_secret": { + "name": "app_secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "target_id": { + "name": "target_id", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "target_name": { + "name": "target_name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "connected_by_id": { + "name": "connected_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "connected_at": { + "name": "connected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "social_accounts_connected_by_id_users_id_fk": { + "name": "social_accounts_connected_by_id_users_id_fk", + "tableFrom": "social_accounts", + "tableTo": "users", + "columnsFrom": [ + "connected_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "social_accounts_network_unique": { + "name": "social_accounts_network_unique", + "nullsNotDistinct": false, + "columns": [ + "network" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.social_posts": { + "name": "social_posts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "promotion_id": { + "name": "promotion_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "network": { + "name": "network", + "type": "social_network", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "social_post_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "posted_by_id": { + "name": "posted_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "social_posts_promotion_idx": { + "name": "social_posts_promotion_idx", + "columns": [ + { + "expression": "promotion_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "social_posts_promotion_id_promotions_id_fk": { + "name": "social_posts_promotion_id_promotions_id_fk", + "tableFrom": "social_posts", + "tableTo": "promotions", + "columnsFrom": [ + "promotion_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "social_posts_posted_by_id_users_id_fk": { + "name": "social_posts_posted_by_id_users_id_fk", + "tableFrom": "social_posts", + "tableTo": "users", + "columnsFrom": [ + "posted_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "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 + }, + "session_version": { + "name": "session_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "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", + "suspended" + ] + }, + "public.promo_suspended_by": { + "name": "promo_suspended_by", + "schema": "public", + "values": [ + "member", + "staff" + ] + }, + "public.request_status": { + "name": "request_status", + "schema": "public", + "values": [ + "new", + "approved", + "rejected" + ] + }, + "public.role": { + "name": "role", + "schema": "public", + "values": [ + "admin", + "moderator", + "editor", + "member" + ] + }, + "public.social_network": { + "name": "social_network", + "schema": "public", + "values": [ + "facebook", + "linkedin" + ] + }, + "public.social_post_status": { + "name": "social_post_status", + "schema": "public", + "values": [ + "posted", + "failed" + ] + } + }, + "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 c313bb2..6a3687d 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -78,6 +78,13 @@ "when": 1784972681534, "tag": "0010_woozy_luminals", "breakpoints": true + }, + { + "idx": 11, + "version": "7", + "when": 1788512273181, + "tag": "0011_drop_temp_password", + "breakpoints": true } ] } \ No newline at end of file diff --git a/next.config.mjs b/next.config.mjs index 2db0190..eba100c 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -2,6 +2,8 @@ const nextConfig = { output: "standalone", reactStrictMode: true, + // Pas d'en-tête X-Powered-By : inutile de renseigner un attaquant sur la pile. + poweredByHeader: false, experimental: { // Les images d'entête/logo sont envoyées en data-URL via server action. serverActions: { bodySizeLimit: "4mb" }, @@ -11,6 +13,12 @@ const nextConfig = { { source: "/:path*", headers: [ + // HSTS : une fois le site vu en HTTPS, le navigateur refuse le HTTP + // clair pendant un an. Sans effet tant que le site est servi en HTTP. + { + key: "Strict-Transport-Security", + value: "max-age=31536000; includeSubDomains", + }, // Empêche l'interprétation d'une réponse selon un type deviné. { key: "X-Content-Type-Options", value: "nosniff" }, // Le site ne doit pas être encadré par un tiers (clickjacking). diff --git a/package-lock.json b/package-lock.json index d8be7cd..8fa3a9e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,9 +11,9 @@ "bcryptjs": "^2.4.3", "dotenv": "^16.4.7", "drizzle-orm": "^0.45.2", - "next": "^15.5.21", + "next": "^15.5.25", "next-auth": "^5.0.0-beta.32", - "pg": "^8.13.1", + "pg": "^8.23.0", "react": "19.0.0", "react-dom": "19.0.0", "zod": "^3.24.1" @@ -1044,15 +1044,15 @@ } }, "node_modules/@next/env": { - "version": "15.5.21", - "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.21.tgz", - "integrity": "sha512-hjJI/GfrjWHgNguRIBzItjRRu0m3Nrz17GhxsjuHfjIvg9hyg3239REd2dpI+bpMTFuVrVprHzEQ19m++cDtbw==", + "version": "15.5.25", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.25.tgz", + "integrity": "sha512-42h1lLr07vl4gawALP1hsgRZjHB1xYa58JfUfHwr0f7jG/zhPakh5GHkADHXOC9ZxUvlQFOPIrp7s6qX4DezPQ==", "license": "MIT" }, "node_modules/@next/swc-darwin-arm64": { - "version": "15.5.21", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.21.tgz", - "integrity": "sha512-ZfjqPEdi6TRC/fWx7UDbwb1fbVgyh2uD5tVTRKIDZDlYM+UNuE/LafDG2fwuAoZilADpABh46OY/F5qf9JjqLQ==", + "version": "15.5.25", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.25.tgz", + "integrity": "sha512-w+RR0v/QuApnWEjRGm1z6gcObKwGMb5YPA7V3bzBEVSBpMFUXprer0tS27UxjUcEnqbhL7Zuzohej79B6rYmBg==", "cpu": [ "arm64" ], @@ -1066,9 +1066,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "15.5.21", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.21.tgz", - "integrity": "sha512-TlCf1NpxgQLzTrexuev75xwmNCJMd1/qkJpTVP1GRRcih93hlIBn1P72hkh8T0gnRFr6BmWksQtbyG3jT6jnww==", + "version": "15.5.25", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.25.tgz", + "integrity": "sha512-QiGGBUSakt8S1H4Lt9Ehsh6Ja87axiBnQQgysOObvCbI7iUfJnRGntF1P64S4/ijuHFnSB8KLsEddkY3nN26uw==", "cpu": [ "x64" ], @@ -1082,9 +1082,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "15.5.21", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.21.tgz", - "integrity": "sha512-LXRsq1p+HvHSi7ygwNcSEEcK0zuo5jS75ZlqFHtOH+LF7qntXAJVJxah+1Pi/GyBm7EpkwU7m4EgbvIKrMqm9A==", + "version": "15.5.25", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.25.tgz", + "integrity": "sha512-ehLos/66zo0d/mJCU5u96a/VDcr01aaUrX0o/i16UdInxz8qPTCDSxGtjk/Lps1sIr1RJFdiX3hxc0fxJo+cPA==", "cpu": [ "arm64" ], @@ -1098,9 +1098,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "15.5.21", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.21.tgz", - "integrity": "sha512-hyGixhFxpDKjqoev6l4KlcRBlt9AXWrGhDZwmwg49sMJM5tnKQPSi+SEj9+e5n+l/bthRGZUdh59GKIs6lQPRw==", + "version": "15.5.25", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.25.tgz", + "integrity": "sha512-ZVMrqLiJ7DiChgmbkQwFtdhAnUkSH/4p7tB29QY+giATb0Q/XGHNRSKAb/B8XGDHRUaA67NepOW5W8u3ZRJBAA==", "cpu": [ "arm64" ], @@ -1114,9 +1114,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "15.5.21", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.21.tgz", - "integrity": "sha512-qfE+YfOba6S2+13e8qn1/UozDVNZ2clBlrs8UtDoax4s8ediu6sq93z66OEHUYlb69Tffh5JTNkgtsAKiSuugg==", + "version": "15.5.25", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.25.tgz", + "integrity": "sha512-UOewtDGkTMJTiODrEdeLZ50yGb59xCZSriNpXkfPMxRRgwDkGc7i8mLWqV5076wEdb+Ca/XN7MhJyM3CupyNyQ==", "cpu": [ "x64" ], @@ -1130,9 +1130,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "15.5.21", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.21.tgz", - "integrity": "sha512-BXLGG+EvIwp/Rrgl6HY8sqvD6BOUOIRz8/naDbeLNX7mlA5H2XRcL6MW/0IGnJISfj5BA9gNhFyJj5yOoiIDJQ==", + "version": "15.5.25", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.25.tgz", + "integrity": "sha512-UBHwA8AhkCZgtRfU1aJpunuAJe/6gZv6jDESQe4p5MjTb5V0YEeJBWCdNqx15Vj3x+5jmauRfeMJSjfQj9HGFQ==", "cpu": [ "x64" ], @@ -1146,9 +1146,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "15.5.21", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.21.tgz", - "integrity": "sha512-tNGNOlT0Wn7E4IMsSnufjXN/l2L2/AGdLLpa2vzS89SYCBuihgLn3ngLsIrvndAnWo9nAkus+4gZHTI/Ijx9HA==", + "version": "15.5.25", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.25.tgz", + "integrity": "sha512-QcFcPRr16djk5IqK5+e8O80eZfgWzIvVBXfitIq0tQ/uc+eyfdoZ0NmKc0cnbIJyfVwREapKuG97YcxWA9gcpA==", "cpu": [ "arm64" ], @@ -1162,9 +1162,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "15.5.21", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.21.tgz", - "integrity": "sha512-DmIdWmC9p4rdNIiQqo8ap0+Cnj6kKtTZnuSCxoYydSc8sgpDgAg9wFhxplunak9imLV0pTvc5WVCOHwm5eHLtQ==", + "version": "15.5.25", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.25.tgz", + "integrity": "sha512-zREeykps3ndWr9egJgvJKqVkkDuaw6Zrrg23cYBos0ygydFkAWYU4+PaPVwXzP1eAYQJe53ShSK45iDM529BOg==", "cpu": [ "x64" ], @@ -1615,9 +1615,9 @@ "peer": true }, "node_modules/nanoid": { - "version": "3.3.16", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", - "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "funding": [ { "type": "github", @@ -1633,12 +1633,12 @@ } }, "node_modules/next": { - "version": "15.5.21", - "resolved": "https://registry.npmjs.org/next/-/next-15.5.21.tgz", - "integrity": "sha512-/TsdBtkWLhkl+NVL3Uqws2UphNd6IPzOtzSk1fHaf+0P7GQKLZDUytyhns/Ykbzdy9+YRjwG7ONvrHaaTDdFqQ==", + "version": "15.5.25", + "resolved": "https://registry.npmjs.org/next/-/next-15.5.25.tgz", + "integrity": "sha512-OMWNulIIqKM2ykvC2qMjIt0IoavB4UB2SCs4iXJ6z6847FvyH8jBmBWcvrF5iuhTu8Przh20Fo/aoszIdqx4PA==", "license": "MIT", "dependencies": { - "@next/env": "15.5.21", + "@next/env": "15.5.25", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", @@ -1651,15 +1651,15 @@ "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "15.5.21", - "@next/swc-darwin-x64": "15.5.21", - "@next/swc-linux-arm64-gnu": "15.5.21", - "@next/swc-linux-arm64-musl": "15.5.21", - "@next/swc-linux-x64-gnu": "15.5.21", - "@next/swc-linux-x64-musl": "15.5.21", - "@next/swc-win32-arm64-msvc": "15.5.21", - "@next/swc-win32-x64-msvc": "15.5.21", - "sharp": "^0.34.3" + "@next/swc-darwin-arm64": "15.5.25", + "@next/swc-darwin-x64": "15.5.25", + "@next/swc-linux-arm64-gnu": "15.5.25", + "@next/swc-linux-arm64-musl": "15.5.25", + "@next/swc-linux-x64-gnu": "15.5.25", + "@next/swc-linux-x64-musl": "15.5.25", + "@next/swc-win32-arm64-msvc": "15.5.25", + "@next/swc-win32-x64-msvc": "15.5.25", + "sharp": "^0.34.3 || ^0.35.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", @@ -1721,14 +1721,14 @@ } }, "node_modules/pg": { - "version": "8.22.0", - "resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz", - "integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==", + "version": "8.23.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz", + "integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==", "license": "MIT", "dependencies": { "pg-connection-string": "^2.14.0", "pg-pool": "^3.14.0", - "pg-protocol": "^1.15.0", + "pg-protocol": "^1.16.0", "pg-types": "2.2.0", "pgpass": "1.0.5" }, @@ -1779,9 +1779,9 @@ } }, "node_modules/pg-protocol": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz", - "integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz", + "integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==", "license": "MIT" }, "node_modules/pg-types": { diff --git a/package.json b/package.json index 5b23b0a..4963247 100644 --- a/package.json +++ b/package.json @@ -17,9 +17,9 @@ "bcryptjs": "^2.4.3", "dotenv": "^16.4.7", "drizzle-orm": "^0.45.2", - "next": "^15.5.21", + "next": "^15.5.25", "next-auth": "^5.0.0-beta.32", - "pg": "^8.13.1", + "pg": "^8.23.0", "react": "19.0.0", "react-dom": "19.0.0", "zod": "^3.24.1" diff --git a/src/app/backend/actions.ts b/src/app/backend/actions.ts index 54f73c3..5222754 100644 --- a/src/app/backend/actions.ts +++ b/src/app/backend/actions.ts @@ -405,7 +405,19 @@ export async function publishPromo(formData: FormData) { } // ---- Members CRUD ---- -export async function addMember(formData: FormData): Promise { +/** + * Identifiants d'un compte qui vient d'être créé ou réinitialisé. + * + * Le mot de passe temporaire n'est **jamais stocké** : il n'existe qu'en + * mémoire le temps de la réponse et n'est montré qu'une seule fois, à l'écran + * qui a déclenché l'action. Un export de la base ne peut donc plus révéler de + * mot de passe utilisable. + */ +export type IssuedCredentials = { email: string; tempPassword: string }; + +export type CreatedMemberAccount = IssuedCredentials & { memberId: number }; + +export async function addMember(formData: FormData): Promise { const { role } = await requireRole(); if (!can(role, "manageMembers")) throw new Error("Accès refusé"); @@ -436,7 +448,6 @@ export async function addMember(formData: FormData): Promise role: "member", memberId: newMember.id, passwordHash: await bcrypt.hash(tempPassword, 10), - tempPassword, mustChangePassword: true, }); @@ -445,13 +456,14 @@ export async function addMember(formData: FormData): Promise revalidatePath("/backend/adherents"); revalidatePath("/backend"); revalidatePath("/"); - return newMember.id; + return { memberId: newMember.id, email, tempPassword }; } // Crée des comptes de connexion pour les adhérents existants qui n'en ont pas // encore (ceux ajoutés avant l'arrivée des comptes adhérent). Chaque compte -// reçoit un mot de passe temporaire à changer à la première connexion. -export async function createMissingMemberAccounts() { +// reçoit un mot de passe temporaire à changer à la première connexion. Les +// identifiants sont renvoyés pour un affichage unique : rien n'est conservé. +export async function createMissingMemberAccounts(): Promise<(IssuedCredentials & { name: string })[]> { const { role } = await requireRole(); if (!can(role, "manageMembers")) throw new Error("Accès refusé"); @@ -465,7 +477,7 @@ export async function createMissingMemberAccounts() { const takenEmails = new Set(allUsers.map((u) => u.email.toLowerCase())); const linkedMemberIds = new Set(allUsers.map((u) => u.memberId).filter((x): x is number => x != null)); - let created = 0; + const created: (IssuedCredentials & { name: string })[] = []; for (const m of allMembers) { if (linkedMemberIds.has(m.id)) continue; const email = (m.email ?? "").trim().toLowerCase(); @@ -478,27 +490,27 @@ export async function createMissingMemberAccounts() { role: "member", memberId: m.id, passwordHash: await bcrypt.hash(tempPassword, 10), - tempPassword, mustChangePassword: true, }); takenEmails.add(email); - created++; + created.push({ name: m.name, email, tempPassword }); } - if (created > 0) { - await logActivity(`${created} compte(s) adhérent créé(s) pour les fiches existantes`, "#2C6FB3"); + if (created.length > 0) { + await logActivity(`${created.length} compte(s) adhérent créé(s) pour les fiches existantes`, "#2C6FB3"); } revalidatePath("/backend/adherents"); + return created; } -// 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) { +// Réinitialise le mot de passe d'un adhérent : le nouveau mot de passe +// temporaire est renvoyé pour un affichage unique, puis oublié. +export async function resetMemberPassword(formData: FormData): Promise { const { role } = await requireRole(); if (!can(role, "manageMembers")) throw new Error("Accès refusé"); const memberId = Number(formData.get("memberId")); - if (!memberId) return; + if (!memberId) return undefined; 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."); @@ -508,7 +520,6 @@ export async function resetMemberPassword(formData: FormData) { .update(users) .set({ passwordHash: await bcrypt.hash(tempPassword, 10), - tempPassword, mustChangePassword: true, // Coupe les sessions ouvertes avec l'ancien mot de passe. sessionVersion: (u.sessionVersion ?? 0) + 1, @@ -516,6 +527,7 @@ export async function resetMemberPassword(formData: FormData) { .where(eq(users.id, u.id)); revalidatePath(`/backend/adherents/${memberId}`); + return { email: u.email, tempPassword }; } export async function updateMember(formData: FormData) { @@ -568,31 +580,35 @@ export async function deleteMember(formData: FormData) { } // ---- Admins ---- -export async function inviteAdmin(formData: FormData) { +export async function inviteAdmin(formData: FormData): Promise { const { role } = await requireRole(); if (!can(role, "manageAdmins")) throw new Error("Accès refusé"); const name = String(formData.get("name") ?? "").trim(); - if (!name) return; + if (!name) return undefined; const email = String(formData.get("email") ?? "").trim().toLowerCase(); - if (!email) return; + if (!email) return undefined; const roleLabel = String(formData.get("role") ?? "Administrateur"); const newRole: AppRole = LABEL_TO_ROLE[roleLabel] ?? "editor"; const existing = await db.select().from(users).where(eq(users.email, email)); - if (existing.length > 0) return; + if (existing.length > 0) throw new Error("Un compte existe déjà avec cet e-mail."); - // Temporary password — the invitee resets it on first login (out of scope here). + // Mot de passe temporaire montré une seule fois à l'inviteur, à changer à + // la première connexion. Auparavant il n'était ni conservé ni affiché : + // l'invité ne pouvait pas se connecter. const tempPassword = generateTempPassword(); await db.insert(users).values({ name, email, role: newRole, passwordHash: await bcrypt.hash(tempPassword, 10), + mustChangePassword: true, }); await logActivity(`${name} a été invité comme ${roleLabel}`, "#2C6FB3"); revalidatePath("/backend/administrateurs"); + return { email, tempPassword }; } export async function removeAdmin(formData: FormData) { @@ -1056,7 +1072,6 @@ export async function changeOwnPassword(formData: FormData) { .update(users) .set({ passwordHash: await bcrypt.hash(password, 10), - tempPassword: null, mustChangePassword: false, // Invalide toutes les autres sessions ouvertes sur ce compte. sessionVersion: (user!.sessionVersion ?? 0) + 1, @@ -1104,8 +1119,9 @@ export async function updateOwnProfile(formData: FormData) { } // Approuve une demande d'adhésion ET crée directement l'adhérent + son compte -// de connexion (mot de passe temporaire). Renvoie l'id du nouvel adhérent. -export async function approveMembershipRequest(formData: FormData): Promise { +// de connexion. Renvoie l'id du nouvel adhérent et ses identifiants, à +// afficher une seule fois. +export async function approveMembershipRequest(formData: FormData): Promise { const { role } = await requireRole(); if (!can(role, "manageMembers")) throw new Error("Accès refusé"); @@ -1137,7 +1153,6 @@ export async function approveMembershipRequest(formData: FormData): Promise(null); const router = useRouter(); return (
+ {created && ( +
+ + + Ouvrir la fiche adhérent → + +
+ )}
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. + une seule fois, juste après l'enregistrement : notez-le avant de quitter la page.
+ + ); +} diff --git a/src/app/backend/adherents/ResetPasswordButton.tsx b/src/app/backend/adherents/ResetPasswordButton.tsx new file mode 100644 index 0000000..faf41c9 --- /dev/null +++ b/src/app/backend/adherents/ResetPasswordButton.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { useState } from "react"; +import { OneTimeCredentials } from "@/components/OneTimeCredentials"; +import { resetMemberPassword, type IssuedCredentials } from "../actions"; + +export function ResetPasswordButton({ memberId }: { memberId: number }) { + const [issued, setIssued] = useState(null); + const [pending, setPending] = useState(false); + + return ( +
+ {issued && } +
{ + setPending(true); + try { + const result = await resetMemberPassword(fd); + setIssued(result ?? null); + } finally { + setPending(false); + } + }} + > + + +
+
+ ); +} diff --git a/src/app/backend/adherents/[id]/page.tsx b/src/app/backend/adherents/[id]/page.tsx index f5c2fab..b5e8ec4 100644 --- a/src/app/backend/adherents/[id]/page.tsx +++ b/src/app/backend/adherents/[id]/page.tsx @@ -8,7 +8,8 @@ import { can } from "@/lib/rbac"; import { ImageField } from "@/components/ImageField"; import { HoursEditor } from "@/components/HoursEditor"; import { communeOptions } from "@/lib/communes"; -import { deleteMember, resetMemberPassword, updateMember } from "../../actions"; +import { deleteMember, updateMember } from "../../actions"; +import { ResetPasswordButton } from "../ResetPasswordButton"; export const dynamic = "force-dynamic"; @@ -35,7 +36,7 @@ export default async function EditMemberPage({ .orderBy(asc(categories.sort)); const [account] = await db - .select({ email: users.email, tempPassword: users.tempPassword, mustChange: users.mustChangePassword }) + .select({ email: users.email, mustChange: users.mustChangePassword }) .from(users) .where(eq(users.memberId, memberId)); @@ -53,14 +54,6 @@ 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 (
@@ -172,12 +165,11 @@ export default async function EditMemberPage({
Identifiant : {account.email}
- {account.tempPassword ? ( + {account.mustChange ? (
- Mot de passe temporaire :{" "} - {account.tempPassword} + En attente de première connexion : l'adhérent devra changer son mot de passe temporaire.
- Communiquez-le à l'adhérent. Il disparaît dès sa première connexion (changement obligatoire). + Mot de passe égaré ? Réinitialisez-le : un nouveau vous sera montré une seule fois.
) : ( @@ -185,14 +177,7 @@ export default async function EditMemberPage({ ✓ L'adhérent a défini son propre mot de passe.
)} -
- -
+ ) : (
diff --git a/src/app/backend/adherents/page.tsx b/src/app/backend/adherents/page.tsx index bc82335..dc8adef 100644 --- a/src/app/backend/adherents/page.tsx +++ b/src/app/backend/adherents/page.tsx @@ -5,8 +5,8 @@ import { getSession } from "@/lib/session"; import { db } from "@/db"; import { categories, members, users } from "@/db/schema"; import { can } from "@/lib/rbac"; -import { createMissingMemberAccounts } from "../actions"; import { AddMemberPanel } from "./AddMemberPanel"; +import { BackfillAccountsForm } from "./BackfillAccountsForm"; export const dynamic = "force-dynamic"; @@ -59,31 +59,9 @@ export default async function AdherentsPage({ return !!e && !takenEmails.has(e); }).length; - async function handleBackfill() { - "use server"; - await createMissingMemberAccounts(); - redirect("/backend/adherents"); - } - return (
- {missingAccounts > 0 && ( -
-
- {missingAccounts} adhérent(s) avec un e-mail n'ont pas encore de compte de connexion. -
- -
- )} + {missingAccounts > 0 && } diff --git a/src/app/backend/administrateurs/InviteAdminForm.tsx b/src/app/backend/administrateurs/InviteAdminForm.tsx new file mode 100644 index 0000000..259bf7b --- /dev/null +++ b/src/app/backend/administrateurs/InviteAdminForm.tsx @@ -0,0 +1,66 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { OneTimeCredentials } from "@/components/OneTimeCredentials"; +import { inviteAdmin, type IssuedCredentials } from "../actions"; + +export function InviteAdminForm() { + const [issued, setIssued] = useState(null); + const [error, setError] = useState(null); + const [pending, setPending] = useState(false); + const router = useRouter(); + + return ( +
+ {issued && } + {error && ( +
+ {error} +
+ )} +
{ + setPending(true); + setError(null); + try { + const result = await inviteAdmin(fd); + setIssued(result ?? null); + router.refresh(); + } catch { + // Next masque le détail des erreurs serveur en production. + setError("Impossible de créer ce compte : vérifiez que l'e-mail n'est pas déjà utilisé."); + } finally { + setPending(false); + } + }} + > + + + + + + + + + + +
+

+ Un mot de passe temporaire est généré et affiché une seule fois ici. L'invité devra le + changer à sa première connexion. +

+
+ ); +} diff --git a/src/app/backend/administrateurs/page.tsx b/src/app/backend/administrateurs/page.tsx index 920181e..8599cea 100644 --- a/src/app/backend/administrateurs/page.tsx +++ b/src/app/backend/administrateurs/page.tsx @@ -4,7 +4,8 @@ import { getSession } from "@/lib/session"; import { db } from "@/db"; import { users } from "@/db/schema"; import { can, ROLE_LABELS, STAFF_ROLES } from "@/lib/rbac"; -import { inviteAdmin, removeAdmin } from "../actions"; +import { removeAdmin } from "../actions"; +import { InviteAdminForm } from "./InviteAdminForm"; export const dynamic = "force-dynamic"; @@ -70,32 +71,7 @@ export default async function AdminsPage() {

Inviter un administrateur

-
- - - - - - - - - - -
-

- Un mot de passe temporaire est généré. L'invité pourra le réinitialiser à la première - connexion. -

+
); diff --git a/src/app/backend/demandes/ApproveRequestForm.tsx b/src/app/backend/demandes/ApproveRequestForm.tsx new file mode 100644 index 0000000..5ece205 --- /dev/null +++ b/src/app/backend/demandes/ApproveRequestForm.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { OneTimeCredentials } from "@/components/OneTimeCredentials"; +import { approveMembershipRequest, type CreatedMemberAccount } from "../actions"; + +/** + * Approuve une demande et affiche une seule fois les identifiants du compte + * créé. On reste sur la page : une redirection ferait perdre le mot de passe, + * qui n'est conservé nulle part. + */ +export function ApproveRequestForm({ requestId }: { requestId: number }) { + const [created, setCreated] = useState(null); + const [pending, setPending] = useState(false); + const router = useRouter(); + + if (created) { + return ( +
+ + + Ouvrir la fiche adhérent → + +
+ ); + } + + return ( +
{ + setPending(true); + try { + const result = await approveMembershipRequest(fd); + if (result) { + setCreated(result); + router.refresh(); + } + } finally { + setPending(false); + } + }} + > + + +
+ ); +} diff --git a/src/app/backend/demandes/page.tsx b/src/app/backend/demandes/page.tsx index d2b335f..c1be110 100644 --- a/src/app/backend/demandes/page.tsx +++ b/src/app/backend/demandes/page.tsx @@ -4,7 +4,8 @@ import { getSession } from "@/lib/session"; import { db } from "@/db"; import { contactMessages, membershipRequests } from "@/db/schema"; import { can } from "@/lib/rbac"; -import { approveMembershipRequest, setContactStatus, setRequestStatus } from "../actions"; +import { setContactStatus, setRequestStatus } from "../actions"; +import { ApproveRequestForm } from "./ApproveRequestForm"; export const dynamic = "force-dynamic"; @@ -51,14 +52,6 @@ export default async function DemandesPage() { db.select().from(contactMessages).orderBy(desc(contactMessages.createdAt)), ]); - // Approuver = créer l'adhérent + son compte, puis aller sur sa fiche - // (login + mot de passe temporaire y sont affichés). - async function handleApprove(fd: FormData) { - "use server"; - const id = await approveMembershipRequest(fd); - redirect(id ? `/backend/adherents/${id}` : "/backend/demandes"); - } - return (
{/* ---- Demandes d'adhésion ---- */} @@ -80,10 +73,7 @@ export default async function DemandesPage() {
-
- - Approuver & créer -
+
diff --git a/src/auth.config.ts b/src/auth.config.ts index 0d5aa2a..fb7badc 100644 --- a/src/auth.config.ts +++ b/src/auth.config.ts @@ -13,6 +13,10 @@ export const authConfig = { }, session: { strategy: "jwt", + // 7 jours et non 30 : un jeton dérobé sur un poste partagé vaut d'autant + // moins longtemps. La révocation immédiate reste assurée par + // `users.session_version` (src/lib/session.ts). + maxAge: 7 * 24 * 60 * 60, }, trustHost: true, callbacks: { diff --git a/src/components/OneTimeCredentials.tsx b/src/components/OneTimeCredentials.tsx new file mode 100644 index 0000000..aa9f866 --- /dev/null +++ b/src/components/OneTimeCredentials.tsx @@ -0,0 +1,49 @@ +"use client"; + +/** + * Affichage unique d'identifiants fraîchement émis. + * + * Le mot de passe temporaire n'est stocké nulle part : il n'existe que dans + * l'état de ce composant, le temps que le staff le relève. Un rechargement de + * la page le fait disparaître définitivement (il reste possible de + * réinitialiser le mot de passe pour en obtenir un nouveau). + */ +export type IssuedCredentialsItem = { label?: string; email: string; tempPassword: string }; + +export function OneTimeCredentials({ + items, + title = "Identifiants à transmettre", +}: { + items: IssuedCredentialsItem[]; + title?: string; +}) { + if (items.length === 0) return null; + return ( +
+
+ {title} +
+
+ {items.map((item) => ( +
+ {item.label && {item.label}} + + Identifiant : {item.email} + + + Mot de passe temporaire :{" "} + {item.tempPassword} + +
+ ))} +
+
+ Notez-le maintenant : il n'est affiché qu'une seule fois et n'est conservé nulle part. + Un changement de mot de passe sera exigé à la première connexion. +
+
+ ); +} diff --git a/src/db/schema.ts b/src/db/schema.ts index cc73074..126bac8 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -80,8 +80,9 @@ export const users = pgTable( passwordHash: varchar("password_hash", { length: 255 }).notNull(), role: roleEnum("role").notNull().default("member"), memberId: integer("member_id").references(() => members.id), + // Le mot de passe temporaire n'est plus stocké (ni en clair ni chiffré) : + // il est montré une seule fois à la création / réinitialisation. mustChangePassword: boolean("must_change_password").notNull().default(false), - tempPassword: varchar("temp_password", { length: 60 }), // Incrémenté pour invalider les jetons déjà émis (changement ou // réinitialisation de mot de passe). sessionVersion: integer("session_version").notNull().default(0), diff --git a/src/db/seed.ts b/src/db/seed.ts index ec382f2..d5e630e 100644 --- a/src/db/seed.ts +++ b/src/db/seed.ts @@ -2,6 +2,7 @@ import "dotenv/config"; import { Pool } from "pg"; import { drizzle } from "drizzle-orm/node-postgres"; import bcrypt from "bcryptjs"; +import { randomInt } from "node:crypto"; import { eq } from "drizzle-orm"; import * as schema from "./schema"; @@ -14,6 +15,14 @@ const { activityLog, } = schema; +// Mot de passe initial lisible, tiré avec un aléa cryptographique. +function randomPassword(length = 16): string { + const alphabet = "ABCDEFGHJKMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789"; + let out = ""; + for (let i = 0; i < length; i++) out += alphabet[randomInt(alphabet.length)]; + return out; +} + async function main() { const connectionString = process.env.DATABASE_URL; if (!connectionString) throw new Error("DATABASE_URL is not set"); @@ -240,34 +249,63 @@ async function main() { for (const a of activityData) await db.insert(activityLog).values(a); } - // ---- Admin + sample staff/member users ---- - const adminEmail = process.env.SEED_ADMIN_EMAIL ?? "admin@plein-r.fr"; - const adminPassword = process.env.SEED_ADMIN_PASSWORD ?? "changeme123"; + // ---- Comptes de connexion ---- + // + // Le seed tourne à chaque démarrage du conteneur (SEED_ON_START). Il ne doit + // donc jamais créer en production de compte dont le mot de passe est connu + // de tous : les comptes de démonstration sont réservés à SEED_DEMO=true, et + // l'administrateur initial reçoit un mot de passe aléatoire (affiché une + // seule fois ici) à changer à la première connexion, sauf si + // SEED_ADMIN_PASSWORD est fourni explicitement. + const adminEmail = (process.env.SEED_ADMIN_EMAIL ?? "admin@plein-r.fr").trim().toLowerCase(); const adminName = process.env.SEED_ADMIN_NAME ?? "Administrateur Plein R"; + const providedAdminPassword = (process.env.SEED_ADMIN_PASSWORD ?? "").trim(); + const demo = (process.env.SEED_DEMO ?? "").trim().toLowerCase() === "true"; - const seedUsers = [ - { email: adminEmail, name: adminName, password: adminPassword, role: "admin" as const, memberId: null }, - { email: "claire@plein-r.fr", name: "Claire Martin", password: "changeme123", role: "admin" as const, memberId: null }, - { email: "thomas@plein-r.fr", name: "Thomas Petit", password: "changeme123", role: "moderator" as const, memberId: null }, - { email: "sophie@plein-r.fr", name: "Sophie Aubert", password: "changeme123", role: "editor" as const, memberId: null }, - { email: "contact@aubonpain.fr", name: "Au Bon Pain", password: "changeme123", role: "member" as const, memberId: memberId("Au Bon Pain") }, - ]; - - for (const u of seedUsers) { - const existing = await db.select().from(users).where(eq(users.email, u.email)); - if (existing.length === 0) { - await db.insert(users).values({ - email: u.email, - name: u.name, - passwordHash: await bcrypt.hash(u.password, 10), - role: u.role, - memberId: u.memberId, - }); + const [existingAdmin] = await db.select({ id: users.id }).from(users).where(eq(users.email, adminEmail)); + if (!existingAdmin) { + const generated = !providedAdminPassword; + const adminPassword = providedAdminPassword || randomPassword(); + await db.insert(users).values({ + email: adminEmail, + name: adminName, + passwordHash: await bcrypt.hash(adminPassword, 10), + role: "admin", + memberId: null, + mustChangePassword: true, + }); + if (generated) { + console.log(" Compte administrateur créé. Mot de passe initial (affiché une seule fois) :"); + console.log(` ${adminEmail} / ${adminPassword}`); + } else { + console.log(` Compte administrateur créé : ${adminEmail} (mot de passe fourni par SEED_ADMIN_PASSWORD).`); } + console.log(" Un changement de mot de passe sera exigé à la première connexion."); + } + + if (demo) { + const demoUsers = [ + { email: "claire@plein-r.fr", name: "Claire Martin", role: "admin" as const, memberId: null }, + { email: "thomas@plein-r.fr", name: "Thomas Petit", role: "moderator" as const, memberId: null }, + { email: "sophie@plein-r.fr", name: "Sophie Aubert", role: "editor" as const, memberId: null }, + { email: "contact@aubonpain.fr", name: "Au Bon Pain", role: "member" as const, memberId: memberId("Au Bon Pain") }, + ]; + for (const u of demoUsers) { + const existing = await db.select({ id: users.id }).from(users).where(eq(users.email, u.email)); + if (existing.length === 0) { + await db.insert(users).values({ + email: u.email, + name: u.name, + passwordHash: await bcrypt.hash("changeme123", 10), + role: u.role, + memberId: u.memberId, + }); + } + } + console.log(" Comptes de démonstration créés (SEED_DEMO=true) : mot de passe « changeme123 »."); } console.log("Seed complete."); - console.log(` Admin login: ${adminEmail} / ${adminPassword}`); await pool.end(); } From 741dd53b407e738a99e2b902d845e98e4c1cfb0d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 09:23:44 +0000 Subject: [PATCH 2/2] =?UTF-8?q?Base=20vide=20par=20d=C3=A9faut=20:=20donn?= =?UTF-8?q?=C3=A9es=20de=20d=C3=A9monstration=20isol=C3=A9es=20et=20purgea?= =?UTF-8?q?bles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le seed ne crée plus que le référentiel des catégories et le compte administrateur initial. Les adhérents, promotions, demandes d'adhésion, entrées du journal et comptes de démonstration sont déplacés dans src/db/demo-data.ts et ne sont insérés qu'avec SEED_DEMO=true. Nouveau script `npm run db:purge-demo` (bundlé en dist/purge-demo.cjs pour le conteneur) : retire exactement ce jeu de démonstration d'une base qui l'a reçu lors des démarrages précédents, sans toucher aux saisies de l'association ni aux catégories. Idempotent, en transaction ; un compte réel rattaché à une fiche de démo est détaché et non supprimé. Vérifié en local sur Postgres 16 : seed par défaut = 59 catégories + 1 admin et rien d'autre ; seed avec SEED_DEMO=true = jeu complet ; purge = retour à l'état vide ; seconde purge sans effet. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01RAQsCp4nnZbwexCg7NDBHE --- .env.example | 8 +- CLAUDE.md | 14 ++-- docker-compose.yml | 3 +- package.json | 3 +- src/db/demo-data.ts | 137 +++++++++++++++++++++++++++++++ src/db/purge-demo.ts | 110 +++++++++++++++++++++++++ src/db/seed.ts | 187 +++++++++++-------------------------------- 7 files changed, 313 insertions(+), 149 deletions(-) create mode 100644 src/db/demo-data.ts create mode 100644 src/db/purge-demo.ts diff --git a/.env.example b/.env.example index b9e6072..5103808 100644 --- a/.env.example +++ b/.env.example @@ -27,8 +27,12 @@ SEED_ADMIN_EMAIL=admin@plein-r.fr # affiché UNE FOIS dans les journaux, et doit être changé à la première connexion. SEED_ADMIN_PASSWORD= SEED_ADMIN_NAME=Administrateur Plein R -# Comptes et contenus de démonstration (mots de passe connus de tous) : -# uniquement pour un poste de développement, jamais en production. +# Données de démonstration (adhérents, promotions, demandes, journal et +# comptes au mot de passe connu de tous) : uniquement sur un poste de +# développement, jamais en production. Par défaut la base reste vide, avec le +# référentiel des catégories et le compte administrateur seulement. +# Pour retirer des données de démo déjà en base : npm run db:purge-demo +# (ou, dans le conteneur : node dist/purge-demo.cjs). SEED_DEMO=false # ---- App ---- diff --git a/CLAUDE.md b/CLAUDE.md index 397585c..0326480 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,7 +23,8 @@ npm run dev # dev server npm run build # production build npm run db:generate # regenerate SQL after editing src/db/schema.ts npm run db:migrate # apply migrations -npm run db:seed # seed demo data +npm run db:seed # catégories + admin initial ; données de démo si SEED_DEMO=true +npm run db:purge-demo # retire les données de démo d'une base qui les a reçues docker compose up --build # full stack ``` @@ -101,10 +102,13 @@ site sans traitement supplémentaire. invitation staff) ne sont **jamais stockés** : l'action les renvoie et le composant `OneTimeCredentials` les affiche une seule fois, sans redirection. `users.must_change_password` seul persiste. -- Le seed ne crée en production que l'administrateur initial, avec un mot de - passe aléatoire affiché une fois dans les journaux (ou `SEED_ADMIN_PASSWORD`) - et un changement obligatoire à la première connexion. Les comptes de démo - (`changeme123`) exigent `SEED_DEMO=true`. +- Le seed ne crée en production que le référentiel des catégories et + l'administrateur initial, avec un mot de passe aléatoire affiché une fois + dans les journaux (ou `SEED_ADMIN_PASSWORD`) et un changement obligatoire à + la première connexion. Toutes les données de démonstration (adhérents, + promotions, demandes, journal, comptes `changeme123`) vivent dans + `src/db/demo-data.ts`, exigent `SEED_DEMO=true`, et `npm run db:purge-demo` + les retire d'une base existante. - Sessions JWT limitées à 7 jours (`auth.config.ts`), HSTS et suppression de `X-Powered-By` dans `next.config.mjs`. Le port Postgres de `docker-compose` n'est publié que sur `127.0.0.1`. diff --git a/docker-compose.yml b/docker-compose.yml index 1c1cc0c..a59f4f8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -65,7 +65,8 @@ services: # Vide = un mot de passe aléatoire est généré au premier démarrage et # affiché une seule fois dans les journaux du conteneur. SEED_ADMIN_PASSWORD: ${SEED_ADMIN_PASSWORD:-} - # Comptes de démonstration (mot de passe connu) : jamais en production. + # Données de démonstration (adhérents, promotions, comptes au mot de + # passe connu) : jamais en production. Base vide par défaut. SEED_DEMO: ${SEED_DEMO:-false} SEED_ADMIN_NAME: ${SEED_ADMIN_NAME:-Administrateur Plein R} ports: diff --git a/package.json b/package.json index 4963247..d9056ea 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "db:generate": "drizzle-kit generate", "db:migrate": "tsx src/db/migrate.ts", "db:seed": "tsx src/db/seed.ts", - "build:scripts": "esbuild src/db/migrate.ts --bundle --platform=node --target=node20 --format=cjs --outfile=dist/migrate.cjs --external:pg-native && esbuild src/db/seed.ts --bundle --platform=node --target=node20 --format=cjs --outfile=dist/seed.cjs --external:pg-native", + "db:purge-demo": "tsx src/db/purge-demo.ts", + "build:scripts": "esbuild src/db/migrate.ts --bundle --platform=node --target=node20 --format=cjs --outfile=dist/migrate.cjs --external:pg-native && esbuild src/db/seed.ts --bundle --platform=node --target=node20 --format=cjs --outfile=dist/seed.cjs --external:pg-native && esbuild src/db/purge-demo.ts --bundle --platform=node --target=node20 --format=cjs --outfile=dist/purge-demo.cjs --external:pg-native", "test": "tsx --test tests/**/*.test.ts" }, "dependencies": { diff --git a/src/db/demo-data.ts b/src/db/demo-data.ts new file mode 100644 index 0000000..1861be8 --- /dev/null +++ b/src/db/demo-data.ts @@ -0,0 +1,137 @@ +/** + * Données de démonstration. + * + * Elles ne sont insérées que par `npm run db:seed` avec `SEED_DEMO=true`, et + * `npm run db:purge-demo` sait les retirer d'une base qui les a reçues. Tout + * ce qui identifie ces enregistrements (e-mails, titres, noms, messages) vit + * ici pour que les deux scripts restent alignés. + */ + +export const DEMO_PASSWORD = "changeme123"; + +export function demoMembers(catId: (slug: string) => number | null) { + return [ + { + name: "Au Bon Pain", + email: "contact@aubonpain.fr", + categoryId: catId("boulangerie"), + city: "Frouard", + address: "12 rue de la République", + postalCode: "54390", + phone: "03 83 49 00 00", + website: "https://www.aubonpain-frouard.fr", + memberSince: 2021, + tags: "Levain naturel, Produits locaux, Fait maison, Sans conservateur", + hours: "Mardi – Vendredi|7h – 19h30\nSamedi|7h – 19h\nDimanche|7h – 13h\nLundi|Fermé", + description: + "Boulangerie artisanale familiale installée au cœur de Frouard depuis trois générations. Nous travaillons chaque jour des farines locales et un levain naturel pour vous proposer pains, viennoiseries et pâtisseries faits maison.\nNotre équipe vous accueille du mardi au dimanche matin. Spécialités : pain au levain, baguette de tradition, kouglof et tartes aux fruits de saison.", + status: "active" as const, + highlighted: true, + }, + { + name: "Café des Arts", + email: "hello@cafedesarts.fr", + categoryId: catId("restauration"), + city: "Pompey", + address: "3 place Stanislas", + postalCode: "54340", + phone: "03 83 24 00 00", + memberSince: 2019, + tags: "Cuisine de saison, Brunch, Terrasse, Produits frais", + hours: "Lundi – Vendredi|9h – 18h\nSamedi|9h – 19h\nDimanche|10h – 15h", + description: "Cuisine de saison, brunch le dimanche, terrasse au cœur du bourg.", + status: "active" as const, + highlighted: true, + }, + { + name: "Atelier Émilie", + email: "emilie@atelier.fr", + categoryId: catId("mode-beaute"), + city: "Champigneulles", + address: "7 av. des Tilleuls", + description: "Salon de coiffure & soins, sur rendez-vous du mardi au samedi.", + status: "active" as const, + highlighted: true, + }, + { + name: "Garage Moderne", + email: "contact@garagemoderne.fr", + categoryId: catId("services"), + city: "Custines", + description: "Entretien et réparation toutes marques.", + status: "active" as const, + }, + { + name: "Boutique Indigo", + email: "indigo@boutique.fr", + categoryId: catId("mode-beaute"), + city: "Liverdun", + description: "Prêt-à-porter et accessoires.", + status: "pending" as const, + }, + { + name: "Fleurs & Sens", + email: "contact@fleursetsens.fr", + categoryId: catId("artisanat"), + city: "Pompey", + description: "Compositions florales, fleurs de saison cueillies localement.", + status: "active" as const, + }, + { + name: "La Cave Gourmande", + email: "bonjour@cavegourmande.fr", + categoryId: catId("alimentation"), + city: "Frouard", + description: "Fromages fermiers, vins et produits du terroir.", + status: "active" as const, + }, + ]; +} + +export function demoPromotions(memberId: (name: string) => number | null) { + return [ + { title: "Le 13e pain offert", text: "Sur présentation de votre carte adhérent, profitez d'une réduction sur nos pains au levain.", category: "Boulangerie", badge: "-20%", memberId: memberId("Au Bon Pain"), status: "live" as const, validUntil: "Valable jusqu'au 30 juin" }, + { title: "Formule midi du marché", text: "Entrée + plat + café à 15€ tous les midis de semaine. Produits frais et locaux.", category: "Restauration", badge: "Menu 15€", memberId: memberId("Café des Arts"), status: "live" as const, validUntil: "Du lundi au vendredi" }, + { title: "Soin offert dès 2 prestations", text: "Profitez de -30% sur le 2e soin réservé dans le mois. Sur rendez-vous.", category: "Beauté", badge: "-30%", memberId: memberId("Atelier Émilie"), status: "pending" as const, validUntil: "Jusqu'au 15 juillet" }, + { title: "Révision auto à prix réseau", text: "15€ de remise sur votre forfait révision pour tous les adhérents Plein R.", category: "Services", badge: "-15€", memberId: memberId("Garage Moderne"), status: "pending" as const, validUntil: "Toute l'année" }, + { title: "Bouquet du mois en promo", text: "-25% sur la composition florale du mois. Fleurs de saison, cueillies localement.", category: "Artisanat", badge: "-25%", memberId: memberId("Fleurs & Sens"), status: "live" as const, validUntil: "Jusqu'au 30 juin" }, + { title: "2 fromages achetés, 1 offert", text: "Sur une sélection de fromages fermiers. L'occasion de découvrir nos producteurs.", category: "Alimentation", badge: "2+1", memberId: memberId("La Cave Gourmande"), status: "live" as const, validUntil: "Ce week-end" }, + ]; +} + +export const demoRequests = [ + { name: "Boutique Indigo", email: "indigo@boutique.fr", message: "Souhaite rejoindre le réseau." }, + { name: "Pizzeria Bella", email: "contact@bella.fr", message: "Demande d'adhésion." }, + { name: "Coworking La Ruche", email: "hello@laruche.fr", message: "Espace de travail partagé." }, +]; + +export const demoActivity = [ + { dot: "#1f8a5b", message: "Atelier Émilie a publié une promotion « Soin offert dès 2 prestations »" }, + { dot: "#E0A63C", message: "Garage Moderne a soumis une promotion en attente de validation" }, + { dot: "#2C6FB3", message: "Nouvelle demande d'adhésion : Boutique Indigo" }, + { dot: "#9a6638", message: "Café des Arts a mis à jour sa fiche annuaire" }, +]; + +export function demoUsers(memberId: (name: string) => number | null) { + return [ + { email: "claire@plein-r.fr", name: "Claire Martin", role: "admin" as const, memberId: null }, + { email: "thomas@plein-r.fr", name: "Thomas Petit", role: "moderator" as const, memberId: null }, + { email: "sophie@plein-r.fr", name: "Sophie Aubert", role: "editor" as const, memberId: null }, + { email: "contact@aubonpain.fr", name: "Au Bon Pain", role: "member" as const, memberId: memberId("Au Bon Pain") }, + ]; +} + +/** E-mails des fiches adhérent de démonstration. */ +export function demoMemberEmails(): string[] { + return demoMembers(() => null).map((m) => m.email); +} + +/** Titres des promotions de démonstration. */ +export function demoPromotionTitles(): string[] { + return demoPromotions(() => null).map((p) => p.title); +} + +/** E-mails des comptes de démonstration. */ +export function demoUserEmails(): string[] { + return demoUsers(() => null).map((u) => u.email); +} diff --git a/src/db/purge-demo.ts b/src/db/purge-demo.ts new file mode 100644 index 0000000..e10d83c --- /dev/null +++ b/src/db/purge-demo.ts @@ -0,0 +1,110 @@ +import "dotenv/config"; +import { Pool } from "pg"; +import { drizzle } from "drizzle-orm/node-postgres"; +import { and, inArray, isNull } from "drizzle-orm"; +import * as schema from "./schema"; +import { + demoActivity, + demoMemberEmails, + demoPromotionTitles, + demoRequests, + demoUserEmails, +} from "./demo-data"; + +const { activityLog, imageConsents, meetingRegistrations, members, membershipRequests, promotions, users } = schema; + +/** + * Retire les données de démonstration d'une base qui les a reçues (anciens + * déploiements où le seed les insérait à chaque démarrage). + * + * Seuls les enregistrements du jeu de démonstration sont touchés, reconnus par + * leur e-mail, leur titre, leur nom ou leur message exacts. Tout ce qui a été + * saisi par l'association reste en place, y compris les catégories et le + * compte administrateur. + * + * npm run db:purge-demo (poste de développement) + * docker compose exec app node dist/purge-demo.cjs (conteneur) + */ +async function main() { + const connectionString = process.env.DATABASE_URL; + if (!connectionString) throw new Error("DATABASE_URL is not set"); + + const pool = new Pool({ connectionString, max: 1 }); + const db = drizzle(pool, { schema }); + + const memberEmails = demoMemberEmails(); + const promoTitles = demoPromotionTitles(); + const userEmails = demoUserEmails(); + const requestNames = demoRequests.map((r) => r.name); + const activityMessages = demoActivity.map((a) => a.message); + + const removed = await db.transaction(async (tx) => { + const demoMemberRows = await tx + .select({ id: members.id }) + .from(members) + .where(inArray(members.email, memberEmails)); + const memberIds = demoMemberRows.map((m) => m.id); + + // Comptes de démonstration (staff et adhérent). + const deletedUsers = await tx + .delete(users) + .where(inArray(users.email, userEmails)) + .returning({ id: users.id }); + + let deletedPromos: { id: number }[] = []; + let deletedMembers: { id: number }[] = []; + if (memberIds.length > 0) { + // Un compte réel rattaché par erreur à une fiche de démo est détaché, + // pas supprimé. + await tx.update(users).set({ memberId: null }).where(inArray(users.memberId, memberIds)); + await tx.delete(imageConsents).where(inArray(imageConsents.memberId, memberIds)); + await tx + .update(meetingRegistrations) + .set({ memberId: null }) + .where(inArray(meetingRegistrations.memberId, memberIds)); + // Les publications réseaux liées suivent (clé étrangère en cascade). + deletedPromos = await tx + .delete(promotions) + .where(inArray(promotions.memberId, memberIds)) + .returning({ id: promotions.id }); + deletedMembers = await tx + .delete(members) + .where(inArray(members.id, memberIds)) + .returning({ id: members.id }); + } + + // Promotions de démo sans fiche (seed antérieur, fiche déjà retirée). + const orphanPromos = await tx + .delete(promotions) + .where(and(inArray(promotions.title, promoTitles), isNull(promotions.memberId))) + .returning({ id: promotions.id }); + + const deletedRequests = await tx + .delete(membershipRequests) + .where(inArray(membershipRequests.name, requestNames)) + .returning({ id: membershipRequests.id }); + + const deletedActivity = await tx + .delete(activityLog) + .where(inArray(activityLog.message, activityMessages)) + .returning({ id: activityLog.id }); + + return { + comptes: deletedUsers.length, + adherents: deletedMembers.length, + promotions: deletedPromos.length + orphanPromos.length, + demandes: deletedRequests.length, + journal: deletedActivity.length, + }; + }); + + console.log("Données de démonstration retirées :"); + for (const [key, count] of Object.entries(removed)) console.log(` ${key} : ${count}`); + + await pool.end(); +} + +main().catch((err) => { + console.error("Purge failed:", err); + process.exit(1); +}); diff --git a/src/db/seed.ts b/src/db/seed.ts index d5e630e..78d5dcd 100644 --- a/src/db/seed.ts +++ b/src/db/seed.ts @@ -5,6 +5,14 @@ import bcrypt from "bcryptjs"; import { randomInt } from "node:crypto"; import { eq } from "drizzle-orm"; import * as schema from "./schema"; +import { + DEMO_PASSWORD, + demoActivity, + demoMembers, + demoPromotions, + demoRequests, + demoUsers, +} from "./demo-data"; const { categories, @@ -126,127 +134,49 @@ async function main() { const cats = await db.select().from(categories); const catId = (slug: string) => cats.find((c) => c.slug === slug)?.id ?? null; - // ---- Members (adhérents) ---- - const memberData = [ - { - name: "Au Bon Pain", - email: "contact@aubonpain.fr", - categoryId: catId("boulangerie"), - city: "Frouard", - address: "12 rue de la République", - postalCode: "54390", - phone: "03 83 49 00 00", - website: "https://www.aubonpain-frouard.fr", - memberSince: 2021, - tags: "Levain naturel, Produits locaux, Fait maison, Sans conservateur", - hours: "Mardi – Vendredi|7h – 19h30\nSamedi|7h – 19h\nDimanche|7h – 13h\nLundi|Fermé", - description: - "Boulangerie artisanale familiale installée au cœur de Frouard depuis trois générations. Nous travaillons chaque jour des farines locales et un levain naturel pour vous proposer pains, viennoiseries et pâtisseries faits maison.\nNotre équipe vous accueille du mardi au dimanche matin. Spécialités : pain au levain, baguette de tradition, kouglof et tartes aux fruits de saison.", - status: "active" as const, - highlighted: true, - }, - { - name: "Café des Arts", - email: "hello@cafedesarts.fr", - categoryId: catId("restauration"), - city: "Pompey", - address: "3 place Stanislas", - postalCode: "54340", - phone: "03 83 24 00 00", - memberSince: 2019, - tags: "Cuisine de saison, Brunch, Terrasse, Produits frais", - hours: "Lundi – Vendredi|9h – 18h\nSamedi|9h – 19h\nDimanche|10h – 15h", - description: "Cuisine de saison, brunch le dimanche, terrasse au cœur du bourg.", - status: "active" as const, - highlighted: true, - }, - { - name: "Atelier Émilie", - email: "emilie@atelier.fr", - categoryId: catId("mode-beaute"), - city: "Champigneulles", - address: "7 av. des Tilleuls", - description: "Salon de coiffure & soins, sur rendez-vous du mardi au samedi.", - status: "active" as const, - highlighted: true, - }, - { - name: "Garage Moderne", - email: "contact@garagemoderne.fr", - categoryId: catId("services"), - city: "Custines", - description: "Entretien et réparation toutes marques.", - status: "active" as const, - }, - { - name: "Boutique Indigo", - email: "indigo@boutique.fr", - categoryId: catId("mode-beaute"), - city: "Liverdun", - description: "Prêt-à-porter et accessoires.", - status: "pending" as const, - }, - { - name: "Fleurs & Sens", - email: "contact@fleursetsens.fr", - categoryId: catId("artisanat"), - city: "Pompey", - description: "Compositions florales, fleurs de saison cueillies localement.", - status: "active" as const, - }, - { - name: "La Cave Gourmande", - email: "bonjour@cavegourmande.fr", - categoryId: catId("alimentation"), - city: "Frouard", - description: "Fromages fermiers, vins et produits du terroir.", - status: "active" as const, - }, - ]; + // ---- Données de démonstration (SEED_DEMO=true uniquement) ---- + // + // Par défaut la base reste vide : seuls le référentiel des catégories et le + // compte administrateur initial sont créés. Les adhérents, promotions, + // demandes, entrées de journal et comptes de démonstration ne servent qu'à + // un poste de développement. + const demo = (process.env.SEED_DEMO ?? "").trim().toLowerCase() === "true"; + if (demo) { + for (const m of demoMembers(catId)) { + const existing = await db.select().from(members).where(eq(members.email, m.email)); + if (existing.length === 0) await db.insert(members).values(m); + } + const allMembers = await db.select().from(members); + const memberId = (name: string) => allMembers.find((m) => m.name === name)?.id ?? null; - for (const m of memberData) { - const existing = await db.select().from(members).where(eq(members.email, m.email)); - if (existing.length === 0) await db.insert(members).values(m); - } - const allMembers = await db.select().from(members); - const memberId = (name: string) => allMembers.find((m) => m.name === name)?.id ?? null; + for (const p of demoPromotions(memberId)) { + const existing = await db.select().from(promotions).where(eq(promotions.title, p.title)); + if (existing.length === 0) await db.insert(promotions).values(p); + } - // ---- Promotions ---- - const promoData = [ - { title: "Le 13e pain offert", text: "Sur présentation de votre carte adhérent, profitez d'une réduction sur nos pains au levain.", category: "Boulangerie", badge: "-20%", memberId: memberId("Au Bon Pain"), status: "live" as const, validUntil: "Valable jusqu'au 30 juin" }, - { title: "Formule midi du marché", text: "Entrée + plat + café à 15€ tous les midis de semaine. Produits frais et locaux.", category: "Restauration", badge: "Menu 15€", memberId: memberId("Café des Arts"), status: "live" as const, validUntil: "Du lundi au vendredi" }, - { title: "Soin offert dès 2 prestations", text: "Profitez de -30% sur le 2e soin réservé dans le mois. Sur rendez-vous.", category: "Beauté", badge: "-30%", memberId: memberId("Atelier Émilie"), status: "pending" as const, validUntil: "Jusqu'au 15 juillet" }, - { title: "Révision auto à prix réseau", text: "15€ de remise sur votre forfait révision pour tous les adhérents Plein R.", category: "Services", badge: "-15€", memberId: memberId("Garage Moderne"), status: "pending" as const, validUntil: "Toute l'année" }, - { title: "Bouquet du mois en promo", text: "-25% sur la composition florale du mois. Fleurs de saison, cueillies localement.", category: "Artisanat", badge: "-25%", memberId: memberId("Fleurs & Sens"), status: "live" as const, validUntil: "Jusqu'au 30 juin" }, - { title: "2 fromages achetés, 1 offert", text: "Sur une sélection de fromages fermiers. L'occasion de découvrir nos producteurs.", category: "Alimentation", badge: "2+1", memberId: memberId("La Cave Gourmande"), status: "live" as const, validUntil: "Ce week-end" }, - ]; + for (const r of demoRequests) { + const existing = await db.select().from(membershipRequests).where(eq(membershipRequests.name, r.name)); + if (existing.length === 0) await db.insert(membershipRequests).values(r); + } - for (const p of promoData) { - const existing = await db.select().from(promotions).where(eq(promotions.title, p.title)); - if (existing.length === 0) await db.insert(promotions).values(p); - } + const existingActivity = await db.select().from(activityLog); + if (existingActivity.length === 0) { + for (const a of demoActivity) await db.insert(activityLog).values(a); + } - // ---- Membership requests ---- - const requestData = [ - { name: "Boutique Indigo", email: "indigo@boutique.fr", message: "Souhaite rejoindre le réseau." }, - { name: "Pizzeria Bella", email: "contact@bella.fr", message: "Demande d'adhésion." }, - { name: "Coworking La Ruche", email: "hello@laruche.fr", message: "Espace de travail partagé." }, - ]; - for (const r of requestData) { - const existing = await db.select().from(membershipRequests).where(eq(membershipRequests.name, r.name)); - if (existing.length === 0) await db.insert(membershipRequests).values(r); - } - - // ---- Activity log ---- - const activityData = [ - { dot: "#1f8a5b", message: "Atelier Émilie a publié une promotion « Soin offert dès 2 prestations »" }, - { dot: "#E0A63C", message: "Garage Moderne a soumis une promotion en attente de validation" }, - { dot: "#2C6FB3", message: "Nouvelle demande d'adhésion : Boutique Indigo" }, - { dot: "#9a6638", message: "Café des Arts a mis à jour sa fiche annuaire" }, - ]; - const existingActivity = await db.select().from(activityLog); - if (existingActivity.length === 0) { - for (const a of activityData) await db.insert(activityLog).values(a); + for (const u of demoUsers(memberId)) { + const existing = await db.select({ id: users.id }).from(users).where(eq(users.email, u.email)); + if (existing.length === 0) { + await db.insert(users).values({ + email: u.email, + name: u.name, + passwordHash: await bcrypt.hash(DEMO_PASSWORD, 10), + role: u.role, + memberId: u.memberId, + }); + } + } + console.log(` Données de démonstration insérées (SEED_DEMO=true) ; comptes de démo : mot de passe « ${DEMO_PASSWORD} ».`); } // ---- Comptes de connexion ---- @@ -260,7 +190,6 @@ async function main() { const adminEmail = (process.env.SEED_ADMIN_EMAIL ?? "admin@plein-r.fr").trim().toLowerCase(); const adminName = process.env.SEED_ADMIN_NAME ?? "Administrateur Plein R"; const providedAdminPassword = (process.env.SEED_ADMIN_PASSWORD ?? "").trim(); - const demo = (process.env.SEED_DEMO ?? "").trim().toLowerCase() === "true"; const [existingAdmin] = await db.select({ id: users.id }).from(users).where(eq(users.email, adminEmail)); if (!existingAdmin) { @@ -283,28 +212,6 @@ async function main() { console.log(" Un changement de mot de passe sera exigé à la première connexion."); } - if (demo) { - const demoUsers = [ - { email: "claire@plein-r.fr", name: "Claire Martin", role: "admin" as const, memberId: null }, - { email: "thomas@plein-r.fr", name: "Thomas Petit", role: "moderator" as const, memberId: null }, - { email: "sophie@plein-r.fr", name: "Sophie Aubert", role: "editor" as const, memberId: null }, - { email: "contact@aubonpain.fr", name: "Au Bon Pain", role: "member" as const, memberId: memberId("Au Bon Pain") }, - ]; - for (const u of demoUsers) { - const existing = await db.select({ id: users.id }).from(users).where(eq(users.email, u.email)); - if (existing.length === 0) { - await db.insert(users).values({ - email: u.email, - name: u.name, - passwordHash: await bcrypt.hash("changeme123", 10), - role: u.role, - memberId: u.memberId, - }); - } - } - console.log(" Comptes de démonstration créés (SEED_DEMO=true) : mot de passe « changeme123 »."); - } - console.log("Seed complete."); await pool.end(); }