Durcissement (lot A) : seed sûr en production, mots de passe temporaires affichés une seule fois

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RAQsCp4nnZbwexCg7NDBHE
This commit is contained in:
Claude committed 2026-09-04 09:15:45 +00:00
1 parent dd7bbfb777
commit 3b17d7ff42
24 files changed
+1855 -192

No files matched your search

+6 -1
View File
@@ -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
+11
View File
@@ -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
+3 -3
View File
@@ -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 \
+8 -3
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
ALTER TABLE "users" DROP COLUMN "temp_password";
File diff suppressed because it is too large. Load diff
+7
View File
@@ -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
}
]
}
+8
View File
@@ -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).
+52 -52
View File
@@ -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": {
+2 -2
View File
@@ -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"
+40 -25
View File
@@ -405,7 +405,19 @@ export async function publishPromo(formData: FormData) {
}
// ---- Members CRUD ----
export async function addMember(formData: FormData): Promise<number | undefined> {
/**
* 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<CreatedMemberAccount | undefined> {
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<number | undefined>
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<number | undefined>
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<IssuedCredentials | undefined> {
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<IssuedCredentials | undefined> {
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(`<strong>${name}</strong> 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<number | undefined> {
// de connexion. Renvoie l'id du nouvel adhérent et ses identifiants, à
// afficher une seule fois.
export async function approveMembershipRequest(formData: FormData): Promise<CreatedMemberAccount | undefined> {
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<numb
role: "member",
memberId: newMember.id,
passwordHash: await bcrypt.hash(tempPassword, 10),
tempPassword,
mustChangePassword: true,
});
@@ -1148,7 +1163,7 @@ export async function approveMembershipRequest(formData: FormData): Promise<numb
revalidatePath("/backend/adherents");
revalidatePath("/backend");
revalidatePath("/");
return newMember.id;
return { memberId: newMember.id, email, tempPassword };
}
// ---- Inbox: membership requests + contact messages ----
+18 -5
View File
@@ -1,9 +1,11 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { BASSIN_POMPEY_COMMUNES } from "@/lib/communes";
import { addMember } from "../actions";
import { OneTimeCredentials } from "@/components/OneTimeCredentials";
import { addMember, type CreatedMemberAccount } from "../actions";
export function AddMemberPanel({
categories,
@@ -11,10 +13,19 @@ export function AddMemberPanel({
categories: { id: number; label: string }[];
}) {
const [open, setOpen] = useState(false);
const [created, setCreated] = useState<CreatedMemberAccount | null>(null);
const router = useRouter();
return (
<div style={{ display: "flex", flexDirection: "column", gap: 0 }}>
{created && (
<div style={{ display: "flex", flexDirection: "column", gap: 8, marginBottom: 14 }}>
<OneTimeCredentials items={[created]} title="Adhérent créé — identifiants à transmettre" />
<Link href={`/backend/adherents/${created.memberId}`} style={{ color: "#2C6FB3", fontWeight: 700, fontSize: 13, textDecoration: "none" }}>
Ouvrir la fiche adhérent →
</Link>
</div>
)}
<div style={{ display: "flex", justifyContent: "flex-end" }}>
<button
onClick={() => setOpen((o) => !o)}
@@ -28,10 +39,12 @@ export function AddMemberPanel({
{open && (
<form
action={async (fd) => {
const id = await addMember(fd);
const result = await addMember(fd);
setOpen(false);
// Redirige vers la fiche : le login + mot de passe temporaire y sont affichés.
if (id) router.push(`/backend/adherents/${id}`);
// On reste sur la page : le mot de passe temporaire n'est affiché
// qu'ici, une seule fois, et n'est conservé nulle part.
setCreated(result ?? null);
router.refresh();
}}
style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 16, padding: 22, marginTop: 14 }}
>
@@ -76,7 +89,7 @@ export function AddMemberPanel({
</div>
<div style={{ marginTop: 14, fontSize: 12.5, color: "#9a8d72" }}>
Un compte de connexion est créé automatiquement. Le mot de passe temporaire s&apos;affiche
sur la fiche de l&apos;adhérent jusqu&apos;à sa première connexion.
une seule fois, juste après l&apos;enregistrement : notez-le avant de quitter la page.
</div>
<button
type="submit"
@@ -0,0 +1,49 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { OneTimeCredentials, type IssuedCredentialsItem } from "@/components/OneTimeCredentials";
import { createMissingMemberAccounts } from "../actions";
export function BackfillAccountsForm({ missingAccounts }: { missingAccounts: number }) {
const [issued, setIssued] = useState<IssuedCredentialsItem[] | null>(null);
const [pending, setPending] = useState(false);
const router = useRouter();
if (issued) {
return (
<OneTimeCredentials
items={issued.map((i) => ({ label: i.label, email: i.email, tempPassword: i.tempPassword }))}
title={`${issued.length} compte(s) créé(s) — identifiants à transmettre`}
/>
);
}
return (
<form
action={async () => {
setPending(true);
try {
const created = await createMissingMemberAccounts();
setIssued(created.map((c) => ({ label: c.name, email: c.email, tempPassword: c.tempPassword })));
router.refresh();
} finally {
setPending(false);
}
}}
style={{ background: "#fbeede", border: "1px solid #ecd8b8", borderRadius: 12, padding: "14px 18px", display: "flex", alignItems: "center", justifyContent: "space-between", gap: 14, flexWrap: "wrap" }}
>
<div style={{ fontSize: 13.5, color: "#9a6638" }}>
<strong>{missingAccounts}</strong> adhérent(s) avec un e-mail n&apos;ont pas encore de compte de connexion.
</div>
<button
type="submit"
disabled={pending}
className="font-display"
style={{ border: "none", background: "#9a6638", color: "#fff", fontWeight: 700, fontSize: 13.5, padding: "10px 18px", borderRadius: 10, cursor: "pointer", whiteSpace: "nowrap" }}
>
{pending ? "Création…" : "Créer les comptes manquants"}
</button>
</form>
);
}
@@ -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<IssuedCredentials | null>(null);
const [pending, setPending] = useState(false);
return (
<div style={{ marginTop: 14, display: "flex", flexDirection: "column", gap: 12 }}>
{issued && <OneTimeCredentials items={[issued]} title="Nouveau mot de passe temporaire" />}
<form
action={async (fd) => {
setPending(true);
try {
const result = await resetMemberPassword(fd);
setIssued(result ?? null);
} finally {
setPending(false);
}
}}
>
<input type="hidden" name="memberId" value={memberId} />
<button
type="submit"
disabled={pending}
style={{ border: "1px solid #d8cdb4", background: "#fff", color: "#6f6450", fontWeight: 600, fontSize: 13, padding: "9px 15px", borderRadius: 9, cursor: "pointer" }}
>
{pending ? "Réinitialisation…" : "Réinitialiser le mot de passe"}
</button>
</form>
</div>
);
}
+7 -22
View File
@@ -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 (
<div style={{ maxWidth: 720 }}>
<Link href="/backend/adherents" style={{ textDecoration: "none", color: "#6f6450", fontSize: 13.5, fontWeight: 600 }}>
@@ -172,12 +165,11 @@ export default async function EditMemberPage({
<div style={{ fontSize: 13.5, color: "#5a5040", marginBottom: 10 }}>
Identifiant : <strong>{account.email}</strong>
</div>
{account.tempPassword ? (
{account.mustChange ? (
<div style={{ background: "#fbeede", border: "1px solid #ecd8b8", borderRadius: 10, padding: "11px 14px", fontSize: 13.5, color: "#9a6638" }}>
Mot de passe temporaire :{" "}
<strong style={{ fontFamily: "monospace", fontSize: 15 }}>{account.tempPassword}</strong>
En attente de première connexion : l&apos;adhérent devra changer son mot de passe temporaire.
<div style={{ fontSize: 12, marginTop: 4 }}>
Communiquez-le à l&apos;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.
</div>
</div>
) : (
@@ -185,14 +177,7 @@ export default async function EditMemberPage({
✓ L&apos;adhérent a défini son propre mot de passe.
</div>
)}
<form action={handleReset} style={{ marginTop: 14 }}>
<button
type="submit"
style={{ border: "1px solid #d8cdb4", background: "#fff", color: "#6f6450", fontWeight: 600, fontSize: 13, padding: "9px 15px", borderRadius: 9, cursor: "pointer" }}
>
Réinitialiser le mot de passe
</button>
</form>
<ResetPasswordButton memberId={memberId} />
</>
) : (
<div style={{ fontSize: 13.5, color: "#a99c82" }}>
+2 -24
View File
@@ -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 (
<div style={{ display: "flex", flexDirection: "column", gap: 18 }}>
{missingAccounts > 0 && (
<form
action={handleBackfill}
style={{ background: "#fbeede", border: "1px solid #ecd8b8", borderRadius: 12, padding: "14px 18px", display: "flex", alignItems: "center", justifyContent: "space-between", gap: 14, flexWrap: "wrap" }}
>
<div style={{ fontSize: 13.5, color: "#9a6638" }}>
<strong>{missingAccounts}</strong> adhérent(s) avec un e-mail n&apos;ont pas encore de compte de connexion.
</div>
<button
type="submit"
className="font-display"
style={{ border: "none", background: "#9a6638", color: "#fff", fontWeight: 700, fontSize: 13.5, padding: "10px 18px", borderRadius: 10, cursor: "pointer", whiteSpace: "nowrap" }}
>
Créer les comptes manquants
</button>
</form>
)}
{missingAccounts > 0 && <BackfillAccountsForm missingAccounts={missingAccounts} />}
<AddMemberPanel categories={cats} />
@@ -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<IssuedCredentials | null>(null);
const [error, setError] = useState<string | null>(null);
const [pending, setPending] = useState(false);
const router = useRouter();
return (
<div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
{issued && <OneTimeCredentials items={[issued]} title="Compte créé — identifiants à transmettre" />}
{error && (
<div role="alert" style={{ background: "#fdecea", border: "1px solid #f1c4bd", borderRadius: 10, padding: "10px 14px", fontSize: 13, color: "#a3372e" }}>
{error}
</div>
)}
<form
action={async (fd) => {
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);
}
}}
>
<label className="field-label">Nom complet</label>
<input name="name" required placeholder="ex : Julie Bernard" className="field" style={{ marginBottom: 14 }} />
<label className="field-label">E-mail</label>
<input name="email" type="email" required placeholder="prenom@plein-r.fr" className="field" style={{ marginBottom: 14 }} />
<label className="field-label">Rôle</label>
<select name="role" defaultValue="Administrateur" className="field" style={{ marginBottom: 18 }}>
<option>Administrateur</option>
<option>Modérateur</option>
<option>Éditeur</option>
</select>
<button
type="submit"
disabled={pending}
className="font-display"
style={{ width: "100%", border: "none", background: "#13324F", color: "#fff", fontWeight: 700, fontSize: 14.5, padding: 13, borderRadius: 11, cursor: "pointer" }}
>
{pending ? "Création…" : "Créer le compte"}
</button>
</form>
<p style={{ fontSize: 11.5, color: "#a99c82", margin: 0, lineHeight: 1.5 }}>
Un mot de passe temporaire est généré et affiché une seule fois ici. L&apos;invité devra le
changer à sa première connexion.
</p>
</div>
);
}
+3 -27
View File
@@ -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() {
<h3 className="font-display" style={{ fontWeight: 700, fontSize: 16, margin: "0 0 16px", color: "#26201a" }}>
Inviter un administrateur
</h3>
<form action={inviteAdmin}>
<label className="field-label">Nom complet</label>
<input name="name" required placeholder="ex : Julie Bernard" className="field" style={{ marginBottom: 14 }} />
<label className="field-label">E-mail</label>
<input name="email" type="email" required placeholder="prenom@plein-r.fr" className="field" style={{ marginBottom: 14 }} />
<label className="field-label">Rôle</label>
<select name="role" defaultValue="Administrateur" className="field" style={{ marginBottom: 18 }}>
<option>Administrateur</option>
<option>Modérateur</option>
<option>Éditeur</option>
</select>
<button
type="submit"
className="font-display"
style={{ width: "100%", border: "none", background: "#13324F", color: "#fff", fontWeight: 700, fontSize: 14.5, padding: 13, borderRadius: 11, cursor: "pointer" }}
>
Envoyer l&apos;invitation
</button>
</form>
<p style={{ fontSize: 11.5, color: "#a99c82", marginTop: 12, lineHeight: 1.5 }}>
Un mot de passe temporaire est généré. L&apos;invité pourra le réinitialiser à la première
connexion.
</p>
<InviteAdminForm />
</div>
</div>
);
@@ -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<CreatedMemberAccount | null>(null);
const [pending, setPending] = useState(false);
const router = useRouter();
if (created) {
return (
<div style={{ display: "flex", flexDirection: "column", gap: 8, minWidth: 260 }}>
<OneTimeCredentials items={[created]} title="Adhérent créé — identifiants à transmettre" />
<Link href={`/backend/adherents/${created.memberId}`} style={{ color: "#2C6FB3", fontWeight: 700, fontSize: 13, textDecoration: "none" }}>
Ouvrir la fiche adhérent →
</Link>
</div>
);
}
return (
<form
action={async (fd) => {
setPending(true);
try {
const result = await approveMembershipRequest(fd);
if (result) {
setCreated(result);
router.refresh();
}
} finally {
setPending(false);
}
}}
>
<input type="hidden" name="id" value={requestId} />
<button
type="submit"
disabled={pending}
style={{ border: "1px solid #1f8a5b", color: "#1f8a5b", background: "#fff", fontWeight: 700, fontSize: 12.5, padding: "7px 12px", borderRadius: 8, cursor: "pointer", whiteSpace: "nowrap" }}
>
{pending ? "Création…" : "Approuver & créer"}
</button>
</form>
);
}
+3 -13
View File
@@ -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 (
<div style={{ display: "flex", flexDirection: "column", gap: 26 }}>
{/* ---- Demandes d'adhésion ---- */}
@@ -80,10 +73,7 @@ export default async function DemandesPage() {
</div>
<StatusPill map={REQ_STATUS} status={r.status} />
<div style={{ display: "flex", gap: 8 }}>
<form action={handleApprove}>
<input type="hidden" name="id" value={r.id} />
<ActionBtn color="#1f8a5b">Approuver &amp; créer</ActionBtn>
</form>
<ApproveRequestForm requestId={r.id} />
<form action={setRequestStatus}>
<input type="hidden" name="id" value={r.id} />
<input type="hidden" name="status" value="rejected" />
+4
View File
@@ -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: {
+49
View File
@@ -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 (
<div
role="status"
style={{ background: "#fbeede", border: "1px solid #ecd8b8", borderRadius: 12, padding: "14px 18px", fontSize: 13.5, color: "#9a6638" }}
>
<div className="font-display" style={{ fontWeight: 700, fontSize: 14.5, marginBottom: 8 }}>
{title}
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{items.map((item) => (
<div key={item.email} style={{ display: "flex", flexWrap: "wrap", gap: "4px 14px", alignItems: "baseline" }}>
{item.label && <span style={{ fontWeight: 700, color: "#6f4b23" }}>{item.label}</span>}
<span>
Identifiant : <strong>{item.email}</strong>
</span>
<span>
Mot de passe temporaire :{" "}
<strong style={{ fontFamily: "monospace", fontSize: 15, userSelect: "all" }}>{item.tempPassword}</strong>
</span>
</div>
))}
</div>
<div style={{ fontSize: 12, marginTop: 10, lineHeight: 1.5 }}>
Notez-le maintenant : il n&apos;est affiché qu&apos;une seule fois et n&apos;est conservé nulle part.
Un changement de mot de passe sera exigé à la première connexion.
</div>
</div>
);
}
+2 -1
View File
@@ -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),
+52 -14
View File
@@ -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") },
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 seedUsers) {
const existing = await db.select().from(users).where(eq(users.email, u.email));
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(u.password, 10),
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();
}