Gestion des rencontres passées + interdiction des actions destructrices aux modérateurs

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fkg15RCxNgUys4ru73He2o
This commit is contained in:
Claude committed 2026-07-13 14:58:32 +00:00
1 parent fab64bc38a
commit 214250bc39
11 files changed
+426 -55

No files matched your search

+19 -12
View File
@@ -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.
+117 -5
View File
@@ -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 });
+2 -2
View File
@@ -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({
+14
View File
@@ -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']);
+10 -1
View File
@@ -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,
+4 -2
View File
@@ -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'),
+19 -14
View File
@@ -3,13 +3,16 @@ import { api } from '../lib/api.js';
// Real replacement for the mock's <image-slot>: 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 (
<div
className={`image-slot${circle ? ' circle' : ''}`}
style={{ cursor: 'pointer' }}
onClick={() => 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 && <img src={value} alt="" />}
{!value && <span>{busy ? 'Envoi…' : error || placeholder}</span>}
<input
ref={inputRef}
type="file"
accept="image/jpeg,image/png,image/webp"
style={{ display: 'none' }}
onChange={(e) => send(e.target.files?.[0])}
/>
{!disabled && (
<input
ref={inputRef}
type="file"
accept="image/jpeg,image/png,image/webp"
style={{ display: 'none' }}
onChange={(e) => send(e.target.files?.[0])}
/>
)}
</div>
);
}
+181
View File
@@ -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 (
<Modal onClose={onClose} maxWidth={620} header={{ kicker: 'Rencontres passées', title: event ? 'Modifier la rencontre passée' : 'Nouvelle rencontre passée' }}>
<form onSubmit={submit} style={{ padding: '26px 30px' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div>
<div className="field" style={{ marginBottom: 8 }}>Photos</div>
<div style={{ display: 'grid', gridTemplateColumns: '1.4fr 1fr', gridTemplateRows: '1fr 1fr', gap: 8, height: 160 }}>
<div style={{ gridRow: 'span 2' }}>
<ImageSlot
endpoint={event ? `/api/admin/rencontres-passees/${event.id}/image/1` : undefined}
value={images[0]}
disabled={!event}
placeholder={event ? 'Photo principale' : "Enregistrez d'abord"}
onUploaded={setImageAt(0)}
/>
</div>
<ImageSlot
endpoint={event ? `/api/admin/rencontres-passees/${event.id}/image/2` : undefined}
value={images[1]}
disabled={!event}
placeholder="Photo"
onUploaded={setImageAt(1)}
/>
<ImageSlot
endpoint={event ? `/api/admin/rencontres-passees/${event.id}/image/3` : undefined}
value={images[2]}
disabled={!event}
placeholder="Photo"
onUploaded={setImageAt(2)}
/>
</div>
</div>
<label className="field">Titre
<input name="titre" value={form.titre} onChange={onChange} required maxLength={200} />
</label>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 2fr', gap: 12 }}>
<label className="field">Date affichée
<input name="date_label" value={form.date_label} onChange={onChange} placeholder="Juin 2026" required maxLength={60} />
</label>
<label className="field">Lieu
<input name="lieu" value={form.lieu} onChange={onChange} required maxLength={200} />
</label>
</div>
<label className="field">Description
<textarea name="texte" value={form.texte} onChange={onChange} rows={3} required maxLength={2000} />
</label>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<label className="field">Participants
<input name="participants" type="number" min={0} max={100000} value={form.participants} onChange={onChange} required />
</label>
<label className="field">Nombre de photos affiché
<input name="nb_photos" type="number" min={0} max={100000} value={form.nb_photos} onChange={onChange} required />
</label>
</div>
</div>
{error && <p className="error-text" style={{ marginTop: 12 }}>{error}</p>}
<div style={{ display: 'flex', gap: 12, marginTop: 24, alignItems: 'center' }}>
<button type="submit" className="btn btn-red btn-sm" style={{ flex: 1, fontSize: 14.5, padding: 13 }}>
{event ? 'Enregistrer' : 'Créer'}
</button>
{event && (
<PastDeleteButton onDelete={remove} />
)}
<button type="button" className="btn btn-outline-soft btn-sm" style={{ fontSize: 14.5, padding: '13px 22px' }} onClick={onClose}>
Annuler
</button>
</div>
</form>
</Modal>
);
}
// Only admins can delete — moderators manage this section but can't
// destroy data (same rule as members/rencontres/inscriptions).
function PastDeleteButton({ onDelete }) {
const { user } = useAuth();
if (user.role !== 'admin') return null;
return (
<button type="button" className="btn-link-gray" style={{ fontSize: 13.5, padding: '13px 10px' }} onClick={onDelete}>
Supprimer
</button>
);
}
export function PastEventsTab() {
const [events, setEvents] = useState([]);
const [modal, setModal] = useState(null); // null | 'new' | event
const reload = () => api.get('/api/admin/rencontres-passees').then((d) => setEvents(d.rencontresPassees)).catch(() => {});
useEffect(() => {
reload();
}, []);
return (
<>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 22 }}>
<div>
<h3 className="serif" style={{ fontSize: 20, fontWeight: 600 }}>Rencontres passées</h3>
<div style={{ fontSize: 12.5, color: 'var(--gray-light)', marginTop: 3 }}>
Section « Ils y étaient » de la page d'accueil.
</div>
</div>
<button className="btn btn-red btn-sm" style={{ fontSize: 13, padding: '10px 18px' }} onClick={() => setModal('new')}>
+ Ajouter une rencontre passée
</button>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
{events.map((ev) => (
<div key={ev.id} className="card" style={{ borderRadius: 6, padding: '18px 20px', display: 'grid', gridTemplateColumns: 'auto 1fr auto', gap: 20, alignItems: 'center' }}>
<div className={ev.image_path ? '' : 'placeholder-pattern'} style={{ width: 84, height: 60, borderRadius: 4, overflow: 'hidden', flexShrink: 0 }}>
{ev.image_path && <img src={ev.image_path} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />}
</div>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 16, fontWeight: 600 }}>{ev.titre}</div>
<div style={{ fontSize: 13, color: 'var(--gray-light)', marginTop: 4 }}>
{ev.date_label} · {ev.lieu} · {ev.participants} participants
</div>
</div>
<button className="btn btn-sm" style={{ background: 'var(--admin-bg)', fontSize: 13, padding: '9px 16px' }} onClick={() => setModal(ev)}>
Gérer
</button>
</div>
))}
{events.length === 0 && (
<div style={{ textAlign: 'center', padding: '60px 20px', color: 'var(--gray-light)', fontSize: 14 }}>
Aucune rencontre passée pour le moment.
</div>
)}
</div>
{modal && (
<PastEventFormModal
event={modal === 'new' ? null : modal}
onClose={() => setModal(null)}
onSaved={() => { setModal(null); reload(); }}
/>
)}
</>
);
}
+40 -12
View File
@@ -1,6 +1,8 @@
import { useEffect, useState } from 'react';
import { api, dateParts, seasonLabel, statutLabel } from '../../lib/api.js';
import { useAuth } from '../../lib/AuthContext.jsx';
import Modal from '../Modal.jsx';
import ImageSlot from '../ImageSlot.jsx';
import { AccessCell, CredentialsModal } from './AccessControls.jsx';
/* ---------------- Members ---------------- */
@@ -90,6 +92,8 @@ function MemberFormModal({ member, categories, onClose, onSaved }) {
}
export function MembersTab() {
const { user } = useAuth();
const isAdmin = user.role === 'admin';
const [members, setMembers] = useState([]);
const [categories, setCategories] = useState([]);
const [modal, setModal] = useState(null); // null | 'new' | member
@@ -176,9 +180,11 @@ export function MembersTab() {
/>
</td>
<td style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
<button className="btn-link-gray" style={{ color: 'var(--gray)', marginRight: 14 }} onClick={() => toggle(m)}>
{m.valide ? 'Suspendre' : 'Valider'}
</button>
{isAdmin && (
<button className="btn-link-gray" style={{ color: 'var(--gray)', marginRight: 14 }} onClick={() => toggle(m)}>
{m.valide ? 'Suspendre' : 'Valider'}
</button>
)}
<button className="btn-link" style={{ fontSize: 13 }} onClick={() => setModal(m)}>Modifier</button>
</td>
</tr>
@@ -216,11 +222,13 @@ export function MembersTab() {
/* ---------------- Rencontres ---------------- */
function RencFormModal({ renc, onClose, onSaved }) {
const { user } = useAuth();
const [form, setForm] = useState(
renc
? { titre: renc.titre, date_renc: String(renc.date_renc).slice(0, 10), heure: renc.heure, lieu: renc.lieu, description: renc.description, places: renc.places }
: { titre: '', date_renc: '', heure: '', lieu: '', description: '', places: 30 }
);
const [image, setImage] = useState(renc?.image_path || '');
const [error, setError] = useState('');
const onChange = (e) => setForm({ ...form, [e.target.name]: e.target.value });
@@ -251,9 +259,20 @@ function RencFormModal({ renc, onClose, onSaved }) {
<Modal onClose={onClose} maxWidth={560} header={{ kicker: 'Rencontres', title: renc ? 'Gérer la rencontre' : 'Nouvelle rencontre' }}>
<form onSubmit={submit} style={{ padding: '26px 30px' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<label className="field">Titre de la rencontre
<input name="titre" value={form.titre} onChange={onChange} required maxLength={200} />
</label>
<div style={{ display: 'flex', gap: 16, alignItems: 'flex-start' }}>
<div style={{ width: 120, height: 84, flexShrink: 0 }}>
<ImageSlot
endpoint={renc ? `/api/admin/rencontres/${renc.id}/image` : undefined}
value={image}
disabled={!renc}
placeholder={renc ? 'Photo de la rencontre' : "Enregistrez d'abord la rencontre"}
onUploaded={setImage}
/>
</div>
<label className="field" style={{ flex: 1 }}>Titre de la rencontre
<input name="titre" value={form.titre} onChange={onChange} required maxLength={200} />
</label>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 12 }}>
<label className="field" style={{ gridColumn: 'span 2' }}>Date
<input name="date_renc" type="date" value={form.date_renc} onChange={onChange} required />
@@ -279,7 +298,7 @@ function RencFormModal({ renc, onClose, onSaved }) {
<button type="submit" className="btn btn-red btn-sm" style={{ flex: 1, fontSize: 14.5, padding: 13 }}>
{renc ? 'Enregistrer' : 'Créer la rencontre'}
</button>
{renc && (
{renc && user.role === 'admin' && (
<button type="button" className="btn-link-gray" style={{ fontSize: 13.5, padding: '13px 10px' }} onClick={remove}>
Supprimer
</button>
@@ -298,6 +317,7 @@ function escHtml(v) {
}
function ParticipantsModal({ renc, onClose, onEdit, onCancel, refreshKey }) {
const { user } = useAuth();
const [rows, setRows] = useState([]);
const { jour, mois } = dateParts(renc.date_renc);
const meta = `${jour} ${mois} · ${renc.heure} · ${renc.lieu}`;
@@ -370,7 +390,9 @@ function ParticipantsModal({ renc, onClose, onEdit, onCancel, refreshKey }) {
<td><span className={`badge ${p.statut === 'confirmee' ? 'badge-green' : 'badge-amber'}`}>{statutLabel(p.statut)}</span></td>
<td style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
<button className="btn-link" style={{ fontSize: 12.5, marginRight: 12 }} onClick={() => onEdit(p)}>Modifier</button>
<button className="btn-link-gray" style={{ fontSize: 12.5 }} onClick={() => onCancel(p)}>Annuler</button>
{user.role === 'admin' && (
<button className="btn-link-gray" style={{ fontSize: 12.5 }} onClick={() => onCancel(p)}>Annuler</button>
)}
</td>
</tr>
))}
@@ -480,6 +502,7 @@ export function RencontresTab() {
/* ---------------- Inscriptions ---------------- */
function InscriptionEditModal({ inscription, rencontres, onClose, onSaved, onRequestCancel }) {
const { user } = useAuth();
const [form, setForm] = useState({
nom: inscription.nom,
entreprise: inscription.entreprise,
@@ -541,9 +564,11 @@ function InscriptionEditModal({ inscription, rencontres, onClose, onSaved, onReq
{error && <p className="error-text" style={{ marginTop: 12 }}>{error}</p>}
<div style={{ display: 'flex', gap: 12, marginTop: 24, alignItems: 'center' }}>
<button type="submit" className="btn btn-red btn-sm" style={{ flex: 1, fontSize: 14.5, padding: 13 }}>Enregistrer</button>
<button type="button" className="btn-link-gray" style={{ fontSize: 13.5, padding: '13px 10px' }} onClick={() => onRequestCancel(inscription)}>
Annuler l'inscription
</button>
{user.role === 'admin' && (
<button type="button" className="btn-link-gray" style={{ fontSize: 13.5, padding: '13px 10px' }} onClick={() => onRequestCancel(inscription)}>
Annuler l'inscription
</button>
)}
<button type="button" className="btn btn-outline-soft btn-sm" style={{ fontSize: 14.5, padding: '13px 22px' }} onClick={onClose}>
Fermer
</button>
@@ -587,6 +612,7 @@ function CancelConfirmModal({ inscription, onClose, onDone }) {
}
export function InscriptionsTab() {
const { user } = useAuth();
const [inscriptions, setInscriptions] = useState([]);
const [rencontres, setRencontres] = useState([]);
const [edit, setEdit] = useState(null);
@@ -623,7 +649,9 @@ export function InscriptionsTab() {
<td><span className={`badge ${i.statut === 'confirmee' ? 'badge-green' : 'badge-amber'}`}>{statutLabel(i.statut)}</span></td>
<td style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
<button className="btn-link" style={{ fontSize: 13, marginRight: 14 }} onClick={() => setEdit(i)}>Modifier</button>
<button className="btn-link-gray" onClick={() => setCancel(i)}>Annuler</button>
{user.role === 'admin' && (
<button className="btn-link-gray" onClick={() => setCancel(i)}>Annuler</button>
)}
</td>
</tr>
))}
+7 -3
View File
@@ -4,15 +4,18 @@ import { useAuth } from '../lib/AuthContext.jsx';
import { api, dateParts, statutLabel } from '../lib/api.js';
import { MembersTab, RencontresTab, InscriptionsTab } from '../components/admin/AdminTabs.jsx';
import { CategoriesTab, ContenuTab } from '../components/admin/AdminContent.jsx';
import { PastEventsTab } from '../components/admin/AdminPast.jsx';
import { UsersTab } from '../components/admin/AdminUsers.jsx';
// Moderators only get members/rencontres/inscriptions — everything else
// (dashboard, taxonomy, site content, staff account management) is
// admin-only, enforced both here (sidebar) and server-side (routes).
// Moderators only get members/rencontres/inscriptions/rencontres passées
// (create + edit, never delete) — everything else (dashboard, taxonomy,
// site content, staff account management) is admin-only, enforced both
// here (sidebar) and server-side (routes).
const TABS = [
{ key: 'dashboard', icon: '◧', label: 'Tableau de bord', title: 'Tableau de bord', roles: ['admin'] },
{ key: 'membres', icon: '▤', label: 'Membres', title: 'Gestion des membres', roles: ['admin', 'moderator'] },
{ key: 'rencontres', icon: '◈', label: 'Rencontres', title: 'Rencontres', roles: ['admin', 'moderator'] },
{ key: 'passees', icon: '▦', label: 'Rencontres passées', title: 'Rencontres passées', roles: ['admin', 'moderator'] },
{ key: 'inscriptions', icon: '✎', label: 'Inscriptions', title: 'Inscriptions', roles: ['admin', 'moderator'] },
{ key: 'categories', icon: '☲', label: 'Catégories', title: 'Catégories', roles: ['admin'] },
{ key: 'contenu', icon: '▧', label: 'Contenu du site', title: 'Contenu du site', roles: ['admin'] },
@@ -143,6 +146,7 @@ export function AdminShell() {
{tab === 'dashboard' && <Dashboard />}
{tab === 'membres' && <MembersTab />}
{tab === 'rencontres' && <RencontresTab />}
{tab === 'passees' && <PastEventsTab />}
{tab === 'inscriptions' && <InscriptionsTab />}
{tab === 'categories' && <CategoriesTab />}
{tab === 'contenu' && <ContenuTab />}
+13 -4
View File
@@ -213,7 +213,10 @@ export default function Home() {
const restantes = Math.max(0, e.places - e.inscrits);
return (
<div key={e.id} className="card" style={{ overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<div className="placeholder-pattern" style={{ height: 170, position: 'relative' }}>
<div className={e.image_path ? '' : 'placeholder-pattern'} style={{ height: 170, position: 'relative' }}>
{e.image_path && (
<img src={e.image_path} alt="" style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover' }} />
)}
<div style={{ position: 'absolute', top: 16, left: 16, background: 'var(--dark)', color: '#fff', borderRadius: 3, padding: '8px 12px', textAlign: 'center', lineHeight: 1.05 }}>
<div className="serif" style={{ fontSize: 24, fontWeight: 600 }}>{jour}</div>
<div style={{ fontSize: 10, letterSpacing: '.12em', textTransform: 'uppercase', color: '#B7AFA6' }}>{mois}</div>
@@ -262,9 +265,15 @@ export default function Home() {
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gridTemplateRows: '1fr 1fr', gap: 10, height: 240 }}>
<div className="placeholder-pattern" style={{ gridRow: 'span 2', borderRadius: 3 }} />
<div className="placeholder-pattern" style={{ borderRadius: 3 }} />
<div className="placeholder-pattern" style={{ borderRadius: 3 }} />
<div className={p.image_path ? '' : 'placeholder-pattern'} style={{ gridRow: 'span 2', borderRadius: 3, overflow: 'hidden' }}>
{p.image_path && <img src={p.image_path} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />}
</div>
<div className={p.image_path_2 ? '' : 'placeholder-pattern'} style={{ borderRadius: 3, overflow: 'hidden' }}>
{p.image_path_2 && <img src={p.image_path_2} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />}
</div>
<div className={p.image_path_3 ? '' : 'placeholder-pattern'} style={{ borderRadius: 3, overflow: 'hidden' }}>
{p.image_path_3 && <img src={p.image_path_3} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />}
</div>
</div>
</article>
))}