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(); }