{request.nom}
+ {status.label} +{request.fonction || 'Fonction non précisée'} · {request.entreprise}
+diff --git a/README.md b/README.md index 500d1d5..964e207 100644 --- a/README.md +++ b/README.md @@ -115,7 +115,7 @@ l'utilisateur est bloqué sur un écran de changement obligatoire avant d'accéd **Rôles du back-office** - **Administrateur** : accès complet — tableau de bord, membres, rencontres, inscriptions, catégories, contenu du site, et gestion des comptes administrateurs/modérateurs. -- **Modérateur** : accès aux membres, aux rencontres, aux rencontres passées et aux +- **Modérateur** : accès aux membres, aux demandes d'adhésion, aux rencontres, aux rencontres passées et aux inscriptions (opérations du quotidien : créer, éditer) ; pas de tableau de bord, pas de catégories, pas de contenu du site, pas de gestion des comptes. Aucune action destructrice n'est autorisée à un modérateur : il ne peut ni valider/suspendre un membre, ni supprimer @@ -134,6 +134,9 @@ l'utilisateur est bloqué sur un écran de changement obligatoire avant d'accéd (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. +- Demandes d'adhésion : suivi partagé entre administrateurs et modérateurs, avec étapes + « nouvelle », « contact pris » puis « membre validé ». La validation réactive la fiche + existante trouvée par email ou crée un nouveau membre et son accès temporaire. - Rencontres : création, édition (avec photo affichée sur les cartes de la page d'accueil) ; suppression réservée aux administrateurs. Liste des inscrits avec impression et export Excel. - Rencontres passées : gestion complète de la section « Ils y étaient » et de la page publique diff --git a/server/src/routes/admin.js b/server/src/routes/admin.js index 58141e2..fd862cc 100644 --- a/server/src/routes/admin.js +++ b/server/src/routes/admin.js @@ -1,5 +1,5 @@ import { Router } from 'express'; -import { query } from '../db.js'; +import { pool, query } from '../db.js'; import { requireAuth } from '../middleware/auth.js'; import { validate } from '../middleware/validate.js'; import { @@ -552,16 +552,120 @@ adminRouter.post('/content/hero-photo', adminOnly, (req, res, next) => { }); }); -// ---------- Demandes d'adhésion (admin only) ---------- -adminRouter.get('/demandes', adminOnly, async (_req, res, next) => { +// ---------- Demandes d'adhésion (admin + moderator) ---------- +adminRouter.get('/demandes', async (_req, res, next) => { try { - const result = await query('SELECT * FROM demandes_adhesion ORDER BY created_at DESC'); + const result = await query(` + SELECT d.*, m.nom AS membre_nom + FROM demandes_adhesion d + LEFT JOIN members m ON m.id = d.member_id + ORDER BY CASE d.statut WHEN 'nouvelle' THEN 0 WHEN 'contactee' THEN 1 ELSE 2 END, + d.created_at DESC`); res.json({ demandes: result.rows }); } catch (err) { next(err); } }); +adminRouter.post('/demandes/:id/contact', validate(idParam, 'params'), async (req, res, next) => { + try { + const result = await query( + `UPDATE demandes_adhesion + SET statut = 'contactee', contacted_at = COALESCE(contacted_at, now()) + WHERE id = $1 AND statut = 'nouvelle' + RETURNING *`, + [req.params.id] + ); + if (result.rowCount === 0) { + const existing = await query('SELECT statut FROM demandes_adhesion WHERE id = $1', [req.params.id]); + if (existing.rowCount === 0) return res.status(404).json({ error: 'Demande introuvable' }); + return res.status(409).json({ error: 'Cette demande a déjà été prise en charge.' }); + } + res.json({ demande: result.rows[0] }); + } catch (err) { + next(err); + } +}); + +adminRouter.post('/demandes/:id/validate', validate(idParam, 'params'), async (req, res, next) => { + let client; + try { + client = await pool.connect(); + await client.query('BEGIN'); + const request = await client.query( + 'SELECT * FROM demandes_adhesion WHERE id = $1 FOR UPDATE', + [req.params.id] + ); + if (request.rowCount === 0) { + await client.query('ROLLBACK'); + return res.status(404).json({ error: 'Demande introuvable' }); + } + const d = request.rows[0]; + if (d.statut !== 'contactee') { + await client.query('ROLLBACK'); + return res.status(409).json({ + error: d.statut === 'validee' + ? 'Cette demande a déjà été validée.' + : "Validez d'abord la prise de contact.", + }); + } + if (!d.email) { + await client.query('ROLLBACK'); + return res.status(400).json({ error: 'Une adresse email est nécessaire pour créer le compte membre.' }); + } + + let member = await client.query( + 'SELECT id FROM members WHERE email = $1 ORDER BY id LIMIT 1 FOR UPDATE', + [d.email] + ); + let memberId; + if (member.rowCount > 0) { + memberId = member.rows[0].id; + await client.query( + `UPDATE members + SET valide = true, adhesion = $1, dirigeant = CASE WHEN dirigeant = '' THEN $2 ELSE dirigeant END, + tel = COALESCE(tel, $3), updated_at = now() + WHERE id = $4`, + [new Date().getFullYear(), d.nom, d.tel, memberId] + ); + } else { + member = await client.query( + `INSERT INTO members (nom, secteur, dirigeant, adhesion, email, tel, presentation, valide) + VALUES ($1, '', $2, $3, $4, $5, '', true) RETURNING id`, + [d.entreprise, d.nom, new Date().getFullYear(), d.email, d.tel] + ); + memberId = member.rows[0].id; + } + + const login = await client.query('SELECT id FROM users WHERE member_id = $1', [memberId]); + await client.query( + `UPDATE demandes_adhesion + SET statut = 'validee', contacted_at = COALESCE(contacted_at, now()), + validated_at = now(), member_id = $1 + WHERE id = $2`, + [memberId, d.id] + ); + await client.query('COMMIT'); + + let tempPassword = null; + let accessError = null; + if (login.rowCount === 0) { + try { + tempPassword = await ensureMemberAccess(memberId, d.email); + } catch (err) { + if (!(err instanceof AccessError)) throw err; + accessError = err.message; + } + } + res.json({ memberId, email: d.email, tempPassword, accessError }); + } catch (err) { + if (client) await client.query('ROLLBACK').catch(() => {}); + next(err); + } finally { + client?.release(); + } +}); + // ---------- Admin & moderator accounts (admin only) ---------- adminRouter.get('/users', adminOnly, async (req, res, next) => { try { diff --git a/server/src/sql/schema.sql b/server/src/sql/schema.sql index c18624f..338663d 100644 --- a/server/src/sql/schema.sql +++ b/server/src/sql/schema.sql @@ -136,10 +136,21 @@ CREATE TABLE IF NOT EXISTS demandes_adhesion ( entreprise TEXT NOT NULL CHECK (char_length(entreprise) BETWEEN 1 AND 120), email CITEXT NOT NULL CHECK (char_length(email) <= 254), tel TEXT CHECK (char_length(tel) <= 30), - statut TEXT NOT NULL DEFAULT 'nouvelle' CHECK (statut IN ('nouvelle', 'traitee')), + statut TEXT NOT NULL DEFAULT 'nouvelle' CHECK (statut IN ('nouvelle', 'contactee', 'validee')), + contacted_at TIMESTAMPTZ, + validated_at TIMESTAMPTZ, + member_id INTEGER REFERENCES members(id) ON DELETE SET NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); ALTER TABLE demandes_adhesion ADD COLUMN IF NOT EXISTS tel TEXT CHECK (char_length(tel) <= 30); +ALTER TABLE demandes_adhesion ADD COLUMN IF NOT EXISTS contacted_at TIMESTAMPTZ; +ALTER TABLE demandes_adhesion ADD COLUMN IF NOT EXISTS validated_at TIMESTAMPTZ; +ALTER TABLE demandes_adhesion ADD COLUMN IF NOT EXISTS member_id INTEGER REFERENCES members(id) ON DELETE SET NULL; +ALTER TABLE demandes_adhesion DROP CONSTRAINT IF EXISTS demandes_adhesion_statut_check; +UPDATE demandes_adhesion SET statut = 'contactee', contacted_at = COALESCE(contacted_at, created_at) + WHERE statut = 'traitee'; +ALTER TABLE demandes_adhesion ADD CONSTRAINT demandes_adhesion_statut_check + CHECK (statut IN ('nouvelle', 'contactee', 'validee')); CREATE TABLE IF NOT EXISTS site_content ( key TEXT PRIMARY KEY, diff --git a/web/src/components/admin/AdminRequests.jsx b/web/src/components/admin/AdminRequests.jsx new file mode 100644 index 0000000..254221c --- /dev/null +++ b/web/src/components/admin/AdminRequests.jsx @@ -0,0 +1,124 @@ +import { useEffect, useMemo, useState } from 'react'; +import { api } from '../../lib/api.js'; +import { CredentialsModal } from './AccessControls.jsx'; + +const STATUS = { + nouvelle: { label: 'Nouvelle', className: 'badge-red' }, + contactee: { label: 'Contact pris', className: 'badge-amber' }, + validee: { label: 'Membre validé', className: 'badge-green' }, +}; + +const formatDate = (value) => new Date(value).toLocaleDateString('fr-FR', { + day: '2-digit', month: 'short', year: 'numeric', +}); + +export function RequestsTab() { + const [requests, setRequests] = useState([]); + const [filter, setFilter] = useState('pending'); + const [busyId, setBusyId] = useState(null); + const [error, setError] = useState(''); + const [credentials, setCredentials] = useState(null); + + const reload = () => api.get('/api/admin/demandes') + .then((data) => setRequests(data.demandes)) + .catch((err) => setError(err.message)); + + useEffect(() => { reload(); }, []); + + const visible = useMemo(() => requests.filter((request) => ( + filter === 'completed' ? request.statut === 'validee' : request.statut !== 'validee' + )), [filter, requests]); + const pendingCount = requests.filter((request) => request.statut !== 'validee').length; + const newCount = requests.filter((request) => request.statut === 'nouvelle').length; + + const run = async (request, action) => { + setBusyId(request.id); + setError(''); + try { + const result = await api.post(`/api/admin/demandes/${request.id}/${action}`); + if (result.tempPassword) { + setCredentials({ recipient: result.email, tempPassword: result.tempPassword }); + } else if (result.accessError) { + setError(`Membre validé, mais accès non créé : ${result.accessError}`); + } + await reload(); + } catch (err) { + setError(err.message); + } finally { + setBusyId(null); + } + }; + + return ( +
Contactez chaque personne avant validation membre.
+{error}
} +{request.fonction || 'Fonction non précisée'} · {request.entreprise}
+Les nouvelles demandes apparaîtront ici.
+