From 56ca6d8559ab325d74fcc6818d36e6a1342a740c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 09:23:38 +0000 Subject: [PATCH] =?UTF-8?q?Mot=20de=20passe=20temporaire=20=C3=A0=20la=20c?= =?UTF-8?q?r=C3=A9ation=20d'un=20membre,=20visible=20admin,=20changement?= =?UTF-8?q?=20forc=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Nouveau membre créé avec email : un mot de passe temporaire aléatoire (~69 bits, sans caractères ambigus) est généré et haché ; il est retourné à l'admin et reste lisible en clair (users.temp_password) tant que le membre ne l'a pas changé - Colonnes users.temp_password / must_change_password (migration ALTER TABLE IF NOT EXISTS, appliquée au démarrage par le bootstrap) - POST /api/admin/members/:id/reset-access : régénère l'accès (création s'il n'existait pas, ou réinitialisation si le membre a perdu son mot de passe) ; login/me exposent mustChangePassword ; change-password efface systématiquement le mot de passe temporaire et lève le blocage - Frontend : colonne « Accès » dans la liste des membres (mot de passe visible + copier + réinitialiser, ou « Défini », ou « Créer l'accès »), modal de confirmation après création/réinitialisation, écran de changement de mot de passe obligatoire avant tout accès au portail ou au back-office Vérifié : cycle complet (création → mot de passe visible côté admin → connexion → changement forcé → effacement automatique → réinitialisation admin en cas de perte) via 21 tests e2e dédiés + parcours navigateur ; 34 tests existants toujours au vert. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Fkg15RCxNgUys4ru73He2o --- README.md | 17 +++- server/src/memberAccess.js | 45 +++++++++ server/src/passwords.js | 23 +++++ server/src/routes/admin.js | 42 +++++++- server/src/routes/auth.js | 21 +++- server/src/sql/schema.sql | 21 ++-- web/src/App.jsx | 3 +- web/src/components/admin/AdminTabs.jsx | 135 ++++++++++++++++++++++++- web/src/lib/AuthContext.jsx | 10 +- web/src/pages/Espace.jsx | 66 ++++++++++++ 10 files changed, 362 insertions(+), 21 deletions(-) create mode 100644 server/src/memberAccess.js create mode 100644 server/src/passwords.js diff --git a/README.md b/README.md index 8d0ea95..e44d1f5 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,18 @@ s'ils n'en ont pas déjà un : Changez le mot de passe admin après la première connexion (Espace membre/admin → formulaire « Mot de passe », endpoint `POST /api/auth/change-password`). +### Mots de passe temporaires + +Quand un admin crée un nouveau membre (avec email) ou réinitialise son accès, un mot de +passe temporaire aléatoire (~69 bits d'entropie, sans caractères ambigus) est généré et +haché avec bcrypt comme n'importe quel mot de passe. Une copie en clair est conservée +en base (colonne `users.temp_password`) **uniquement le temps que le membre ne l'ait pas +changé** — elle est automatiquement effacée dès son premier changement de mot de passe +(forcé ou volontaire), et n'est jamais exposée par une route publique ou membre, seulement +par les routes admin (`GET /api/admin/members`, `POST /api/admin/members`, +`POST /api/admin/members/:id/reset-access`). À la connexion avec un mot de passe temporaire, +l'utilisateur est bloqué sur un écran de changement obligatoire avant d'accéder à son espace. + ## Fonctionnalités **Site public** @@ -96,7 +108,10 @@ formulaire « Mot de passe », endpoint `POST /api/auth/change-password`). **Espace admin** (rôle `admin`) - Tableau de bord : indicateurs temps réel, dernières inscriptions, prochaines rencontres. -- Membres : création, édition, validation/suspension par saison (1er sept. → 31 août). +- Membres : création, édition, validation/suspension par saison (1er sept. → 31 août). La création + d'un membre avec email génère automatiquement un mot de passe temporaire, affiché à l'admin et + copiable ; il reste visible dans la liste tant que le membre ne l'a pas changé. L'admin peut aussi + réinitialiser l'accès à tout moment (membre ayant perdu son mot de passe). - Rencontres : création, édition, suppression ; liste des inscrits avec impression et export Excel. - Inscriptions : modification, confirmation, annulation avec confirmation. diff --git a/server/src/memberAccess.js b/server/src/memberAccess.js new file mode 100644 index 0000000..ba2dcb0 --- /dev/null +++ b/server/src/memberAccess.js @@ -0,0 +1,45 @@ +import bcrypt from 'bcryptjs'; +import { query } from './db.js'; +import { generateTempPassword } from './passwords.js'; + +export class AccessError extends Error { + constructor(status, message) { + super(message); + this.status = status; + } +} + +// Creates the member's login if it doesn't exist yet, or resets it if it +// does — same operation either way, used for "create member" and for +// "member lost their password". Returns the generated temporary password +// so the admin can relay it; it also stays readable via GET /members until +// the member changes it (temp_password / must_change_password columns). +export async function ensureMemberAccess(memberId, email) { + if (!email) { + throw new AccessError(400, "Cette entreprise n'a pas d'adresse email — ajoutez-en une d'abord."); + } + const tempPassword = generateTempPassword(); + const hash = await bcrypt.hash(tempPassword, 12); + try { + const existing = await query('SELECT id FROM users WHERE member_id = $1', [memberId]); + if (existing.rowCount > 0) { + await query( + `UPDATE users SET email=$1, password_hash=$2, temp_password=$3, must_change_password=true + WHERE member_id=$4`, + [email, hash, tempPassword, memberId] + ); + } else { + await query( + `INSERT INTO users (email, password_hash, role, member_id, temp_password, must_change_password) + VALUES ($1, $2, 'member', $3, $4, true)`, + [email, hash, memberId, tempPassword] + ); + } + } catch (err) { + if (err.code === '23505') { + throw new AccessError(409, 'Cet email est déjà utilisé par un autre compte.'); + } + throw err; + } + return tempPassword; +} diff --git a/server/src/passwords.js b/server/src/passwords.js new file mode 100644 index 0000000..fcf4d6b --- /dev/null +++ b/server/src/passwords.js @@ -0,0 +1,23 @@ +import crypto from 'node:crypto'; + +// Ambiguous characters (0/O, 1/l/I) excluded for readability when the admin +// relays this password to a member by phone or in person. +const LETTERS = 'ABCDEFGHJKMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz'; +const DIGITS = '23456789'; +const ALPHABET = LETTERS + DIGITS; + +const randomChar = (set) => set[crypto.randomBytes(1)[0] % set.length]; + +// ~69 bits of entropy, guaranteed to contain at least one letter and one +// digit (satisfies the app's own password policy even though this value +// bypasses it — it is written straight to a bcrypt hash). +export function generateTempPassword(length = 12) { + const chars = Array.from({ length }, () => randomChar(ALPHABET)); + chars[0] = randomChar(LETTERS); + chars[1] = randomChar(DIGITS); + for (let i = chars.length - 1; i > 0; i--) { + const j = crypto.randomBytes(1)[0] % (i + 1); + [chars[i], chars[j]] = [chars[j], chars[i]]; + } + return chars.join(''); +} diff --git a/server/src/routes/admin.js b/server/src/routes/admin.js index 77c67d8..a4a4b53 100644 --- a/server/src/routes/admin.js +++ b/server/src/routes/admin.js @@ -11,6 +11,7 @@ import { idParam, } from '../schemas.js'; import { imageUpload, saveImage, deleteImage } from '../uploads.js'; +import { ensureMemberAccess, AccessError } from '../memberAccess.js'; export const adminRouter = Router(); @@ -19,8 +20,13 @@ adminRouter.use(requireAuth('admin')); const MEMBER_SQL = ` SELECT m.id, m.nom, m.secteur, m.categorie_id, c.name AS categorie, m.dirigeant, m.adhesion, m.email, m.tel, m.site, m.presentation, m.valide, - m.logo_path, m.photo_path - FROM members m LEFT JOIN categories c ON c.id = m.categorie_id`; + m.logo_path, m.photo_path, + (u.id IS NOT NULL) AS has_login, + COALESCE(u.must_change_password, false) AS must_change_password, + CASE WHEN u.must_change_password THEN u.temp_password ELSE NULL END AS temp_password + FROM members m + LEFT JOIN categories c ON c.id = m.categorie_id + LEFT JOIN users u ON u.member_id = m.id`; const RENC_SQL = ` SELECT r.id, r.titre, r.date_renc, r.heure, r.lieu, r.description, r.places, @@ -73,7 +79,22 @@ adminRouter.post('/members', validate(adminMemberSchema), async (req, res, next) [d.nom, d.secteur, d.categorie_id ?? null, d.dirigeant, new Date().getFullYear(), d.email || null, d.tel, d.site, d.presentation, d.valide ?? true] ); - res.status(201).json({ id: result.rows[0].id }); + const id = result.rows[0].id; + // A login account (with a temporary password) is only created when an + // email is provided — it's the login identifier. + let tempPassword = null; + let accessError = null; + if (d.email) { + try { + tempPassword = await ensureMemberAccess(id, d.email); + } catch (err) { + if (!(err instanceof AccessError)) throw err; + // Member created, but the email already belongs to another login — + // surface it without failing the whole creation. + accessError = err.message; + } + } + res.status(201).json({ id, tempPassword, accessError }); } catch (err) { next(err); } @@ -109,6 +130,21 @@ adminRouter.post('/members/:id/toggle-valide', validate(idParam, 'params'), asyn } }); +// Creates the member's login if it doesn't have one yet, or resets its +// password (e.g. the member lost it) — either way returns a fresh +// temporary password that must be changed at next login. +adminRouter.post('/members/:id/reset-access', validate(idParam, 'params'), async (req, res, next) => { + try { + const member = await query('SELECT email FROM members WHERE id = $1', [req.params.id]); + if (member.rowCount === 0) return res.status(404).json({ error: 'Membre introuvable' }); + const tempPassword = await ensureMemberAccess(req.params.id, member.rows[0].email); + res.json({ tempPassword }); + } catch (err) { + if (err instanceof AccessError) return res.status(err.status).json({ error: err.message }); + next(err); + } +}); + // ---------- Rencontres ---------- adminRouter.get('/rencontres', async (_req, res, next) => { try { diff --git a/server/src/routes/auth.js b/server/src/routes/auth.js index 5fa6601..a28cff0 100644 --- a/server/src/routes/auth.js +++ b/server/src/routes/auth.js @@ -8,13 +8,19 @@ import { loginSchema, changePasswordSchema } from '../schemas.js'; export const authRouter = Router(); -const publicUser = (u) => ({ id: u.id, email: u.email, role: u.role, memberId: u.member_id }); +const publicUser = (u) => ({ + id: u.id, + email: u.email, + role: u.role, + memberId: u.member_id, + mustChangePassword: u.must_change_password, +}); authRouter.post('/login', loginLimiter, validate(loginSchema), async (req, res, next) => { try { const { email, password } = req.data; const result = await query( - 'SELECT id, email, password_hash, role, member_id FROM users WHERE email = $1', + 'SELECT id, email, password_hash, role, member_id, must_change_password FROM users WHERE email = $1', [email] ); // Always run a bcrypt comparison to keep timing uniform @@ -40,7 +46,7 @@ authRouter.get('/me', async (req, res, next) => { try { if (!req.user) return res.json({ user: null }); const result = await query( - 'SELECT id, email, role, member_id FROM users WHERE id = $1', + 'SELECT id, email, role, member_id, must_change_password FROM users WHERE id = $1', [req.user.sub] ); if (result.rowCount === 0) return res.json({ user: null }); @@ -62,7 +68,14 @@ authRouter.post( const ok = await bcrypt.compare(currentPassword, result.rows[0].password_hash); if (!ok) return res.status(401).json({ error: 'Mot de passe actuel incorrect.' }); const hash = await bcrypt.hash(newPassword, 12); - await query('UPDATE users SET password_hash = $1 WHERE id = $2', [hash, req.user.sub]); + // Changing the password (self-service or forced first login) always + // clears the temp password shown to the admin and lifts the forced- + // change requirement. + await query( + `UPDATE users SET password_hash = $1, temp_password = NULL, must_change_password = false + WHERE id = $2`, + [hash, req.user.sub] + ); res.json({ ok: true }); } catch (err) { next(err); diff --git a/server/src/sql/schema.sql b/server/src/sql/schema.sql index bcf5b6b..7d88f26 100644 --- a/server/src/sql/schema.sql +++ b/server/src/sql/schema.sql @@ -25,13 +25,22 @@ CREATE TABLE IF NOT EXISTS members ( ); CREATE TABLE IF NOT EXISTS users ( - id SERIAL PRIMARY KEY, - email CITEXT NOT NULL UNIQUE CHECK (char_length(email) <= 254), - password_hash TEXT NOT NULL, - role TEXT NOT NULL CHECK (role IN ('member', 'admin')), - member_id INTEGER UNIQUE REFERENCES members(id) ON DELETE CASCADE, - created_at TIMESTAMPTZ NOT NULL DEFAULT now() + id SERIAL PRIMARY KEY, + email CITEXT NOT NULL UNIQUE CHECK (char_length(email) <= 254), + password_hash TEXT NOT NULL, + role TEXT NOT NULL CHECK (role IN ('member', 'admin')), + member_id INTEGER UNIQUE REFERENCES members(id) ON DELETE CASCADE, + -- Temporary password shown to the admin (create / reset access), kept + -- readable only until the user changes it — cleared automatically at + -- that point. NULL once a real password has been chosen by the user. + temp_password TEXT, + must_change_password BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); +-- ALTER ... IF NOT EXISTS heals databases that already had this table +-- before these columns were introduced. +ALTER TABLE users ADD COLUMN IF NOT EXISTS temp_password TEXT; +ALTER TABLE users ADD COLUMN IF NOT EXISTS must_change_password BOOLEAN NOT NULL DEFAULT false; CREATE TABLE IF NOT EXISTS rencontres ( id SERIAL PRIMARY KEY, diff --git a/web/src/App.jsx b/web/src/App.jsx index 19d1bbc..b077413 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -14,9 +14,10 @@ export default function App() { const { user } = useAuth(); // The admin backend (shown inside /espace-membre once an admin logs in) // uses its own full-height sidebar layout, without the public header/footer. + // A forced password change is still shown inside the normal site layout. const isAdminBackend = location.pathname.startsWith('/admin') || - (location.pathname === '/espace-membre' && user?.role === 'admin'); + (location.pathname === '/espace-membre' && user?.role === 'admin' && !user?.mustChangePassword); useEffect(() => { window.scrollTo(0, 0); diff --git a/web/src/components/admin/AdminTabs.jsx b/web/src/components/admin/AdminTabs.jsx index 458f7c1..4472aa3 100644 --- a/web/src/components/admin/AdminTabs.jsx +++ b/web/src/components/admin/AdminTabs.jsx @@ -26,9 +26,13 @@ function MemberFormModal({ member, categories, onClose, onSaved }) { site: form.site, presentation: form.presentation, }; try { - if (member) await api.put(`/api/admin/members/${member.id}`, body); - else await api.post('/api/admin/members', body); - onSaved(); + if (member) { + await api.put(`/api/admin/members/${member.id}`, body); + onSaved(); + } else { + const result = await api.post('/api/admin/members', body); + onSaved({ ...result, email: body.email }); + } } catch (err) { setError(err.message); } @@ -84,10 +88,101 @@ function MemberFormModal({ member, categories, onClose, onSaved }) { ); } +// Shows a just-generated temporary password so the admin can relay it to +// the member (copy button). It also stays readable in the members table +// below until the member changes it. +function CredentialsModal({ email, tempPassword, onClose }) { + const [copied, setCopied] = useState(false); + const copy = async () => { + try { + await navigator.clipboard.writeText(tempPassword); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } catch { + /* clipboard unavailable (non-HTTPS, older browser) — password stays selectable */ + } + }; + return ( + +
+

+ Communiquez ces identifiants à {email}. Ce mot de passe devra être + changé dès la première connexion. +

+
+ {tempPassword} + +
+

+ Ce mot de passe reste visible dans la liste des membres tant qu'il n'a pas été changé. +

+ +
+
+ ); +} + +function AccessCell({ member, onGenerate }) { + const [copied, setCopied] = useState(false); + const copy = async (text) => { + try { + await navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } catch { + /* clipboard unavailable */ + } + }; + + if (!member.email) { + return ( + + — + + ); + } + if (!member.has_login) { + return ( + + ); + } + if (member.must_change_password && member.temp_password) { + return ( +
+ + {member.temp_password} + + + +
+ ); + } + return ( +
+ Défini + +
+ ); +} + export function MembersTab() { const [members, setMembers] = useState([]); const [categories, setCategories] = useState([]); const [modal, setModal] = useState(null); // null | 'new' | member + const [credentials, setCredentials] = useState(null); // { email, tempPassword } + const [accessError, setAccessError] = useState(''); const season = seasonLabel(); const reload = () => @@ -107,6 +202,17 @@ export function MembersTab() { reload(); }; + const generateAccess = async (m) => { + setAccessError(''); + try { + const d = await api.post(`/api/admin/members/${m.id}/reset-access`); + setCredentials({ email: m.email, tempPassword: d.tempPassword }); + reload(); + } catch (err) { + setAccessError(err.message); + } + }; + const actifs = members.filter((m) => m.valide).length; return ( @@ -129,11 +235,12 @@ export function MembersTab() { être inscrit aux rencontres. La saison court du 1er septembre au 31 août. + {accessError &&

{accessError}

}
- + @@ -147,6 +254,9 @@ export function MembersTab() { {m.valide ? `Validé ${season}` : 'Non validé'} +
EntrepriseSecteurDirigeantSaisonEntrepriseSecteurDirigeantSaisonAccès
+ + + + + + + ); +} + function Portal() { const { logout } = useAuth(); const [member, setMember] = useState(null); @@ -275,6 +340,7 @@ export default function Espace() { if (loading) return
; if (!user) return
; + if (user.mustChangePassword) return
; if (user.role === 'admin') return ; return
; }