From 214250bc390667cb9c694ae5e58b399f039c168e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 14:58:32 +0000 Subject: [PATCH] =?UTF-8?q?Gestion=20des=20rencontres=20pass=C3=A9es=20+?= =?UTF-8?q?=20interdiction=20des=20actions=20destructrices=20aux=20mod?= =?UTF-8?q?=C3=A9rateurs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rencontres passées (section « Ils y étaient » de l'accueil) : - Nouvel onglet « Rencontres passées » (admin + modérateur) : création, édition, jusqu'à 3 photos par rencontre (1 grande + 2 petites, upload après enregistrement — même schéma que la photo des rencontres à venir) - Nouvelles colonnes rencontres_passees.image_path/_2/_3 (migration ALTER TABLE IF NOT EXISTS) - Home.jsx affiche désormais les vraies photos uploadées (repli sur le motif placeholder si absentes) ; ordre passé de ORDER BY id à ORDER BY id DESC (les plus récentes en premier, seed réordonné en conséquence pour préserver l'affichage actuel) - Suppression réservée aux administrateurs Modérateurs : plus aucune action destructrice possible, imposé côté serveur (adminOnly) et masqué côté interface : - Validation/suspension d'un membre (toggle-valide) - Suppression d'une rencontre, d'une rencontre passée, d'une inscription Vérifié : 34 + 21 + 26 tests existants toujours au vert, 21 nouveaux tests (CRUD rencontres passées, upload des 3 emplacements photo, ordre d'affichage, restrictions modérateur sur chaque action désormais admin- only), parcours navigateur complet (upload des 3 photos, rendu réel sur la page d'accueil). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Fkg15RCxNgUys4ru73He2o --- README.md | 31 +++-- server/src/routes/admin.js | 122 ++++++++++++++++- server/src/routes/public.js | 4 +- server/src/schemas.js | 14 ++ server/src/sql/schema.sql | 11 +- server/src/sql/seed.sql | 6 +- web/src/components/ImageSlot.jsx | 33 +++-- web/src/components/admin/AdminPast.jsx | 181 +++++++++++++++++++++++++ web/src/components/admin/AdminTabs.jsx | 52 +++++-- web/src/pages/Admin.jsx | 10 +- web/src/pages/Home.jsx | 17 ++- 11 files changed, 426 insertions(+), 55 deletions(-) create mode 100644 web/src/components/admin/AdminPast.jsx diff --git a/README.md b/README.md index edc30b6..8fe94a5 100644 --- a/README.md +++ b/README.md @@ -112,24 +112,31 @@ 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 restreint aux membres, aux rencontres et aux inscriptions - (opérations du quotidien) ; pas de tableau de bord, pas de catégories, pas de contenu - du site, pas de gestion des comptes. Un modérateur peut être promu administrateur (et - inversement) depuis l'onglet « Administrateurs ». Le système empêche de se supprimer - ou de se rétrograder soi-même, et de supprimer le dernier compte administrateur restant. +- **Modérateur** : accès aux membres, 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 + une rencontre, une rencontre passée ou une inscription — ces actions restent strictement + réservées aux administrateurs, imposé côté serveur (pas seulement caché dans l'interface). + Un modérateur peut être promu administrateur (et inversement) depuis l'onglet + « Administrateurs ». Le système empêche de se supprimer ou de se rétrograder soi-même, + et de supprimer le dernier compte administrateur restant. **Espace admin** (rôle `admin`) - Administrateurs : création de comptes administrateur ou modérateur (mot de passe temporaire généré, changement obligatoire à la première connexion — même mécanique que pour les membres), réinitialisation d'accès, promotion/rétrogradation, suppression. - 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). 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. +- Membres : création, édition ; validation/suspension par saison réservée aux administrateurs + (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. +- 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 » de la page d'accueil — + titre, date, lieu, description, participants, et jusqu'à 3 photos par rencontre (1 grande + + 2 petites, comme affiché sur l'accueil) ; suppression réservée aux administrateurs. +- Inscriptions : modification, confirmation ; annulation (suppression) réservée aux administrateurs. - Catégories : ajout, renommage, suppression (les entreprises deviennent « Non classée »). - Contenu du site : citation, signature et photo de la page d'accueil. diff --git a/server/src/routes/admin.js b/server/src/routes/admin.js index ea8c275..77ac102 100644 --- a/server/src/routes/admin.js +++ b/server/src/routes/admin.js @@ -10,6 +10,8 @@ import { contentSchema, staffUserSchema, roleChangeSchema, + rencontrePasseeSchema, + pastEventImageParam, idParam, } from '../schemas.js'; import { imageUpload, saveImage, deleteImage } from '../uploads.js'; @@ -37,7 +39,7 @@ const MEMBER_SQL = ` 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, + SELECT r.id, r.titre, r.date_renc, r.heure, r.lieu, r.description, r.places, r.image_path, COUNT(i.id)::int AS inscrits FROM rencontres r LEFT JOIN inscriptions i ON i.rencontre_id = r.id`; @@ -125,7 +127,10 @@ adminRouter.put('/members/:id', validate(idParam, 'params'), validate(adminMembe } }); -adminRouter.post('/members/:id/toggle-valide', validate(idParam, 'params'), async (req, res, next) => { +// Suspending/validating a member's membership is a moderation decision +// reserved to admins — moderators can create and edit members but not +// block them. +adminRouter.post('/members/:id/toggle-valide', adminOnly, validate(idParam, 'params'), async (req, res, next) => { try { const result = await query( 'UPDATE members SET valide = NOT valide, updated_at = now() WHERE id = $1 RETURNING valide', @@ -192,15 +197,36 @@ adminRouter.put('/rencontres/:id', validate(idParam, 'params'), validate(rencont } }); -adminRouter.delete('/rencontres/:id', validate(idParam, 'params'), async (req, res, next) => { +// Deleting is admin-only — moderators manage rencontres but can't destroy data. +adminRouter.delete('/rencontres/:id', adminOnly, validate(idParam, 'params'), async (req, res, next) => { try { - await query('DELETE FROM rencontres WHERE id = $1', [req.params.id]); + const result = await query('DELETE FROM rencontres WHERE id = $1 RETURNING image_path', [req.params.id]); + if (result.rowCount > 0) await deleteImage(result.rows[0].image_path); res.json({ ok: true }); } catch (err) { next(err); } }); +// Photo shown on the "Prochaines rencontres" cards on the home page. +adminRouter.post('/rencontres/:id/image', validate(idParam, 'params'), (req, res, next) => { + imageUpload(req, res, async (err) => { + if (err) return res.status(400).json({ error: 'Fichier invalide (2 Mo max).' }); + try { + if (!req.file) return res.status(400).json({ error: 'Aucun fichier reçu.' }); + const existing = await query('SELECT image_path FROM rencontres WHERE id = $1', [req.params.id]); + if (existing.rowCount === 0) return res.status(404).json({ error: 'Rencontre introuvable' }); + const publicPath = await saveImage(req.file.buffer); + if (!publicPath) return res.status(400).json({ error: 'Format accepté : JPEG, PNG ou WebP.' }); + await query('UPDATE rencontres SET image_path = $1 WHERE id = $2', [publicPath, req.params.id]); + await deleteImage(existing.rows[0].image_path); + res.json({ path: publicPath }); + } catch (e) { + next(e); + } + }); +}); + adminRouter.get('/rencontres/:id/inscriptions', validate(idParam, 'params'), async (req, res, next) => { try { const result = await query(`${INSCR_SQL} WHERE i.rencontre_id = $1 ORDER BY i.nom`, [req.params.id]); @@ -210,6 +236,90 @@ adminRouter.get('/rencontres/:id/inscriptions', validate(idParam, 'params'), asy } }); +// ---------- Rencontres passées (admin + moderator manage; admin-only delete) ---------- +// Powers the "Rencontres passées" gallery on the home page. +const PAST_SQL = ` + SELECT id, date_label, lieu, titre, texte, participants, nb_photos, + image_path, image_path_2, image_path_3 + FROM rencontres_passees`; + +adminRouter.get('/rencontres-passees', async (_req, res, next) => { + try { + const result = await query(`${PAST_SQL} ORDER BY id DESC`); + res.json({ rencontresPassees: result.rows }); + } catch (err) { + next(err); + } +}); + +adminRouter.post('/rencontres-passees', validate(rencontrePasseeSchema), async (req, res, next) => { + try { + const d = req.data; + const result = await query( + `INSERT INTO rencontres_passees (date_label, lieu, titre, texte, participants, nb_photos) + VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`, + [d.date_label, d.lieu, d.titre, d.texte, d.participants, d.nb_photos] + ); + res.status(201).json({ id: result.rows[0].id }); + } catch (err) { + next(err); + } +}); + +adminRouter.put('/rencontres-passees/:id', validate(idParam, 'params'), validate(rencontrePasseeSchema), async (req, res, next) => { + try { + const d = req.data; + const result = await query( + `UPDATE rencontres_passees SET date_label=$1, lieu=$2, titre=$3, texte=$4, participants=$5, nb_photos=$6 + WHERE id=$7 RETURNING id`, + [d.date_label, d.lieu, d.titre, d.texte, d.participants, d.nb_photos, req.params.id] + ); + if (result.rowCount === 0) return res.status(404).json({ error: 'Rencontre passée introuvable' }); + res.json({ ok: true }); + } catch (err) { + next(err); + } +}); + +// Deleting is admin-only, same reasoning as rencontres/inscriptions above. +adminRouter.delete('/rencontres-passees/:id', adminOnly, validate(idParam, 'params'), async (req, res, next) => { + try { + const result = await query( + 'DELETE FROM rencontres_passees WHERE id = $1 RETURNING image_path, image_path_2, image_path_3', + [req.params.id] + ); + if (result.rowCount > 0) { + const row = result.rows[0]; + await Promise.all([deleteImage(row.image_path), deleteImage(row.image_path_2), deleteImage(row.image_path_3)]); + } + res.json({ ok: true }); + } catch (err) { + next(err); + } +}); + +// Three photo slots per past event (matches the home page's 1 large + 2 +// small grid). :slot is 1, 2 or 3. +const PAST_IMAGE_COLUMNS = { 1: 'image_path', 2: 'image_path_2', 3: 'image_path_3' }; +adminRouter.post('/rencontres-passees/:id/image/:slot', validate(pastEventImageParam, 'params'), (req, res, next) => { + imageUpload(req, res, async (err) => { + if (err) return res.status(400).json({ error: 'Fichier invalide (2 Mo max).' }); + try { + if (!req.file) return res.status(400).json({ error: 'Aucun fichier reçu.' }); + const column = PAST_IMAGE_COLUMNS[req.params.slot]; + const existing = await query(`SELECT ${column} AS path FROM rencontres_passees WHERE id = $1`, [req.params.id]); + if (existing.rowCount === 0) return res.status(404).json({ error: 'Rencontre passée introuvable' }); + const publicPath = await saveImage(req.file.buffer); + if (!publicPath) return res.status(400).json({ error: 'Format accepté : JPEG, PNG ou WebP.' }); + await query(`UPDATE rencontres_passees SET ${column} = $1 WHERE id = $2`, [publicPath, req.params.id]); + await deleteImage(existing.rows[0].path); + res.json({ path: publicPath }); + } catch (e) { + next(e); + } + }); +}); + // ---------- Inscriptions (admin + moderator) ---------- adminRouter.get('/inscriptions', async (_req, res, next) => { try { @@ -248,7 +358,9 @@ adminRouter.post('/inscriptions/:id/confirm', validate(idParam, 'params'), async } }); -adminRouter.delete('/inscriptions/:id', validate(idParam, 'params'), async (req, res, next) => { +// Deleting (cancelling) an inscription is admin-only, same reasoning as +// above — moderators edit and confirm, they don't destroy data. +adminRouter.delete('/inscriptions/:id', adminOnly, validate(idParam, 'params'), async (req, res, next) => { try { await query('DELETE FROM inscriptions WHERE id = $1', [req.params.id]); res.json({ ok: true }); diff --git a/server/src/routes/public.js b/server/src/routes/public.js index b984931..0bef2b5 100644 --- a/server/src/routes/public.js +++ b/server/src/routes/public.js @@ -19,13 +19,13 @@ publicRouter.get('/bootstrap', async (_req, res, next) => { WHERE m.valide = true ORDER BY m.nom` ), query( - `SELECT r.id, r.titre, r.date_renc, r.heure, r.lieu, r.description, r.places, + `SELECT r.id, r.titre, r.date_renc, r.heure, r.lieu, r.description, r.places, r.image_path, COUNT(i.id)::int AS inscrits FROM rencontres r LEFT JOIN inscriptions i ON i.rencontre_id = r.id WHERE r.date_renc >= CURRENT_DATE GROUP BY r.id ORDER BY r.date_renc` ), - query('SELECT * FROM rencontres_passees ORDER BY id'), + query('SELECT * FROM rencontres_passees ORDER BY id DESC'), ]); const contentMap = Object.fromEntries(content.rows.map((r) => [r.key, r.value])); res.json({ diff --git a/server/src/schemas.js b/server/src/schemas.js index cdda71a..80423c8 100644 --- a/server/src/schemas.js +++ b/server/src/schemas.js @@ -66,6 +66,20 @@ export const inscriptionAdminSchema = z.object({ statut: z.enum(['confirmee', 'en_attente']), }); +export const rencontrePasseeSchema = z.object({ + date_label: trimmed(60, 1), + lieu: trimmed(200, 1), + titre: trimmed(200, 1), + texte: trimmed(2000, 1), + participants: z.coerce.number().int().min(0).max(100000), + nb_photos: z.coerce.number().int().min(0).max(100000), +}); + +export const pastEventImageParam = z.object({ + id: z.coerce.number().int().positive(), + slot: z.coerce.number().int().min(1).max(3), +}); + export const categorySchema = z.object({ name: trimmed(80, 1) }); export const staffRoleEnum = z.enum(['admin', 'moderator']); diff --git a/server/src/sql/schema.sql b/server/src/sql/schema.sql index 5fc0fa2..10a8957 100644 --- a/server/src/sql/schema.sql +++ b/server/src/sql/schema.sql @@ -56,8 +56,10 @@ CREATE TABLE IF NOT EXISTS rencontres ( lieu TEXT NOT NULL DEFAULT '' CHECK (char_length(lieu) <= 200), description TEXT NOT NULL DEFAULT '' CHECK (char_length(description) <= 2000), places INTEGER NOT NULL CHECK (places >= 0 AND places <= 100000), + image_path TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); +ALTER TABLE rencontres ADD COLUMN IF NOT EXISTS image_path TEXT; CREATE TABLE IF NOT EXISTS inscriptions ( id SERIAL PRIMARY KEY, @@ -78,8 +80,15 @@ CREATE TABLE IF NOT EXISTS rencontres_passees ( titre TEXT NOT NULL, texte TEXT NOT NULL, participants INTEGER NOT NULL DEFAULT 0, - nb_photos INTEGER NOT NULL DEFAULT 0 + nb_photos INTEGER NOT NULL DEFAULT 0, + -- 1 large + 2 small photos, matching the home page grid. + image_path TEXT, + image_path_2 TEXT, + image_path_3 TEXT ); +ALTER TABLE rencontres_passees ADD COLUMN IF NOT EXISTS image_path TEXT; +ALTER TABLE rencontres_passees ADD COLUMN IF NOT EXISTS image_path_2 TEXT; +ALTER TABLE rencontres_passees ADD COLUMN IF NOT EXISTS image_path_3 TEXT; CREATE TABLE IF NOT EXISTS demandes_adhesion ( id SERIAL PRIMARY KEY, diff --git a/server/src/sql/seed.sql b/server/src/sql/seed.sql index 9e793a7..939ee3b 100644 --- a/server/src/sql/seed.sql +++ b/server/src/sql/seed.sql @@ -40,10 +40,12 @@ INSERT INTO inscriptions (rencontre_id, nom, entreprise, email, tel, statut) VAL ((SELECT id FROM rencontres WHERE titre = 'Afterwork Business & Basket'), 'Émilie Colin', 'Cristal Événements', 'e.colin@cristal-events.fr', '+33 3 83 38 66 12', 'confirmee'), ((SELECT id FROM rencontres WHERE titre = 'Conférence — L''esprit d''équipe'), 'Philippe Aubry', 'Groupe Batigest', 'p.aubry@batigest.fr', '+33 3 83 22 18 04', 'confirmee'); +-- Inserted oldest-first: the home page and admin list show these newest +-- first (ORDER BY id DESC), so insertion order must be chronological. INSERT INTO rencontres_passees (date_label, lieu, titre, texte, participants, nb_photos) VALUES - ('Juin 2026', 'Château de Rémicourt', 'Soirée de Gala annuelle', 'Plus de 200 convives réunis pour célébrer une saison d''exception. Une soirée d''élégance mêlant remise de trophées, dîner gastronomique et rencontres privilégiées entre membres du Club.', 210, 48), + ('Février 2026', 'La Filature, Nancy', 'Table ronde — Digitaliser sa PME', 'Retours d''expérience et bonnes pratiques : trois dirigeants membres ont partagé leur parcours de transformation numérique devant une salle comble.', 56, 15), ('Avril 2026', 'Site industriel, Florange', 'Visite privée — Usine ArcelorMittal', 'Immersion industrielle exclusive pour nos membres au cœur d''un fleuron de la métallurgie lorraine, suivie d''un échange avec la direction du site.', 34, 22), - ('Février 2026', 'La Filature, Nancy', 'Table ronde — Digitaliser sa PME', 'Retours d''expérience et bonnes pratiques : trois dirigeants membres ont partagé leur parcours de transformation numérique devant une salle comble.', 56, 15); + ('Juin 2026', 'Château de Rémicourt', 'Soirée de Gala annuelle', 'Plus de 200 convives réunis pour célébrer une saison d''exception. Une soirée d''élégance mêlant remise de trophées, dîner gastronomique et rencontres privilégiées entre membres du Club.', 210, 48); INSERT INTO demandes_adhesion (nom, fonction, entreprise, email) VALUES ('Marc Dubois', 'Directeur général', 'Dubois Traiteur', 'm.dubois@dubois-traiteur.fr'), diff --git a/web/src/components/ImageSlot.jsx b/web/src/components/ImageSlot.jsx index 9f6946f..09352c6 100644 --- a/web/src/components/ImageSlot.jsx +++ b/web/src/components/ImageSlot.jsx @@ -3,13 +3,16 @@ import { api } from '../lib/api.js'; // Real replacement for the mock's : click or drop an image, // it is uploaded to `endpoint` and the stored path is displayed. -export default function ImageSlot({ endpoint, value, onUploaded, placeholder, circle }) { +// `disabled` shows the placeholder without any upload interaction — used +// when the endpoint needs an id that doesn't exist yet (e.g. an entity +// that must be saved first). +export default function ImageSlot({ endpoint, value, onUploaded, placeholder, circle, disabled }) { const inputRef = useRef(null); const [busy, setBusy] = useState(false); const [error, setError] = useState(''); const send = async (file) => { - if (!file) return; + if (!file || disabled) return; setBusy(true); setError(''); try { @@ -25,25 +28,27 @@ export default function ImageSlot({ endpoint, value, onUploaded, placeholder, ci return (
inputRef.current?.click()} - onDragOver={(e) => e.preventDefault()} + style={{ cursor: disabled ? 'default' : 'pointer', opacity: disabled ? 0.6 : 1 }} + onClick={() => !disabled && inputRef.current?.click()} + onDragOver={(e) => !disabled && e.preventDefault()} onDrop={(e) => { e.preventDefault(); - send(e.dataTransfer.files?.[0]); + if (!disabled) send(e.dataTransfer.files?.[0]); }} role="button" - tabIndex={0} + tabIndex={disabled ? -1 : 0} > {value && } {!value && {busy ? 'Envoi…' : error || placeholder}} - send(e.target.files?.[0])} - /> + {!disabled && ( + send(e.target.files?.[0])} + /> + )}
); } diff --git a/web/src/components/admin/AdminPast.jsx b/web/src/components/admin/AdminPast.jsx new file mode 100644 index 0000000..755c00a --- /dev/null +++ b/web/src/components/admin/AdminPast.jsx @@ -0,0 +1,181 @@ +import { useEffect, useState } from 'react'; +import { api } from '../../lib/api.js'; +import { useAuth } from '../../lib/AuthContext.jsx'; +import Modal from '../Modal.jsx'; +import ImageSlot from '../ImageSlot.jsx'; + +function PastEventFormModal({ event, onClose, onSaved }) { + const [form, setForm] = useState( + event + ? { date_label: event.date_label, lieu: event.lieu, titre: event.titre, texte: event.texte, participants: event.participants, nb_photos: event.nb_photos } + : { date_label: '', lieu: '', titre: '', texte: '', participants: 0, nb_photos: 0 } + ); + const [images, setImages] = useState([event?.image_path || '', event?.image_path_2 || '', event?.image_path_3 || '']); + const [error, setError] = useState(''); + + const onChange = (e) => setForm({ ...form, [e.target.name]: e.target.value }); + + const setImageAt = (i) => (path) => setImages((prev) => prev.map((p, idx) => (idx === i ? path : p))); + + const submit = async (e) => { + e.preventDefault(); + setError(''); + try { + if (event) await api.put(`/api/admin/rencontres-passees/${event.id}`, form); + else await api.post('/api/admin/rencontres-passees', form); + onSaved(); + } catch (err) { + setError(err.message); + } + }; + + const remove = async () => { + if (!window.confirm('Supprimer cette rencontre passée et ses photos ?')) return; + try { + await api.del(`/api/admin/rencontres-passees/${event.id}`); + onSaved(); + } catch (err) { + setError(err.message); + } + }; + + return ( + +
+
+
+
Photos
+
+
+ +
+ + +
+
+ +
+ + +
+