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 ( +
+
+
À traiter{pendingCount}
+
Nouvelles{newCount}
+
Validées{requests.length - pendingCount}
+
+ +
+
+
+

Demandes d’adhésion

+

Contactez chaque personne avant validation membre.

+
+
+ + +
+
+ + {error &&

{error}

} +
+ {visible.map((request) => { + const status = STATUS[request.statut] || STATUS.nouvelle; + const busy = busyId === request.id; + return ( +
+
Reçue{formatDate(request.created_at)}
+
+
+

{request.nom}

+ {status.label} +
+

{request.fonction || 'Fonction non précisée'} · {request.entreprise}

+
+ {request.email} + {request.tel && {request.tel}} +
+
+
+ {request.statut === 'nouvelle' && ( + + )} + {request.statut === 'contactee' && ( + + )} + {request.statut === 'validee' && ( + Membre : {request.membre_nom || request.entreprise} + )} +
+
+ ); + })} + {visible.length === 0 && ( +
+ ✓ +

Aucune demande {filter === 'pending' ? 'en cours' : 'terminée'}

+

Les nouvelles demandes apparaîtront ici.

+
+ )} +
+
+ + {credentials && ( + setCredentials(null)} /> + )} +
+ ); +} diff --git a/web/src/pages/Admin.jsx b/web/src/pages/Admin.jsx index 5b03de3..a44a0a4 100644 --- a/web/src/pages/Admin.jsx +++ b/web/src/pages/Admin.jsx @@ -7,14 +7,16 @@ import { CategoriesTab, ContenuTab } from '../components/admin/AdminContent.jsx' import { PastEventsTab } from '../components/admin/AdminPast.jsx'; import { UsersTab } from '../components/admin/AdminUsers.jsx'; import { EmailCreatorTab } from '../components/admin/AdminEmail.jsx'; +import { RequestsTab } from '../components/admin/AdminRequests.jsx'; -// Moderators only get members/rencontres/inscriptions/rencontres passées +// Moderators get members/membership requests/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: 'demandes', icon: '◌', label: 'Demandes', title: "Demandes d’adhésion", 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'] }, @@ -147,6 +149,7 @@ export function AdminShell() {
{tab === 'dashboard' && } {tab === 'membres' && } + {tab === 'demandes' && } {tab === 'rencontres' && } {tab === 'passees' && } {tab === 'inscriptions' && } diff --git a/web/src/styles.css b/web/src/styles.css index 1fe81da..cf7ba9d 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -124,6 +124,41 @@ input:focus, textarea:focus, select:focus { outline: none; border-color: var(--r } .admin-nav-btn.active { background: rgba(193, 39, 45, .18); color: #fff; border-left-color: var(--red); font-weight: 600; } +/* Membership request workflow */ +.requests-layout { display: flex; flex-direction: column; gap: 20px; } +.requests-summary { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; } +.request-stat { padding: 20px 22px; border-radius: 6px; display: flex; align-items: flex-end; justify-content: space-between; } +.request-stat span { font-size: 12.5px; color: var(--gray-light); } +.request-stat strong { font-family: Georgia, serif; font-size: 32px; line-height: 1; } +.request-stat-accent { background: var(--dark); color: #fff; } +.request-stat-accent span { color: #B7AFA6; } +.requests-panel { border-radius: 6px; overflow: hidden; } +.requests-toolbar { padding: 22px 24px; display: flex; align-items: center; justify-content: space-between; gap: 20px; border-bottom: 1px solid var(--border); } +.requests-toolbar h3 { font-size: 20px; font-weight: 600; } +.requests-toolbar p { margin-top: 4px; color: var(--gray-light); font-size: 12.5px; } +.requests-filter { display: flex; padding: 3px; background: var(--admin-bg); border-radius: 5px; } +.requests-filter button { border: 0; background: transparent; color: var(--gray); padding: 8px 13px; border-radius: 3px; font-size: 12.5px; font-weight: 600; } +.requests-filter button.active { background: #fff; color: var(--ink); box-shadow: 0 1px 4px rgba(27,27,27,.1); } +.requests-error { margin: 14px 24px 0; } +.requests-list { display: flex; flex-direction: column; } +.request-row { display: grid; grid-template-columns: 110px minmax(260px, 1fr) auto; gap: 22px; align-items: center; padding: 22px 24px; border-top: 1px solid var(--border-soft); } +.request-row:first-child { border-top: 0; } +.request-row:hover { background: #FCFAF7; } +.request-date { display: flex; flex-direction: column; gap: 4px; color: var(--gray-light); font-size: 11px; text-transform: uppercase; letter-spacing: .08em; } +.request-date strong { color: var(--gray); font-size: 12px; letter-spacing: 0; text-transform: none; } +.request-title-line { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } +.request-title-line h4 { font-family: Georgia, serif; font-size: 18px; color: var(--ink); } +.request-identity > p { margin-top: 5px; color: var(--gray); font-size: 13px; } +.request-contact { display: flex; gap: 16px; flex-wrap: wrap; margin-top: 10px; } +.request-contact a { color: var(--red); font-size: 12.5px; text-decoration: none; font-weight: 600; } +.request-contact a:hover { text-decoration: underline; } +.request-actions { min-width: 170px; text-align: right; } +.request-member-link { color: var(--green); font-size: 12.5px; font-weight: 600; } +.requests-empty { padding: 60px 24px; text-align: center; color: var(--gray-light); } +.requests-empty > span { display: inline-flex; width: 44px; height: 44px; align-items: center; justify-content: center; border-radius: 50%; background: #E8F3EC; color: var(--green); font-size: 20px; } +.requests-empty h4 { margin-top: 14px; color: var(--ink); font-family: Georgia, serif; font-size: 18px; } +.requests-empty p { margin-top: 5px; font-size: 13px; } + /* Header nav */ .nav-btn { background: none; border: none; font-size: 14.5px; font-weight: 500; color: var(--gray-mid); padding: 10px 14px; border-radius: 2px; } .nav-btn:hover, .nav-btn.active { color: var(--red); } @@ -157,6 +192,10 @@ input:focus, textarea:focus, select:focus { outline: none; border-color: var(--r .admin-sidebar { width: 100% !important; height: auto !important; position: static !important; } .admin-content-header { padding: 16px 20px !important; } .admin-content-body { padding: 20px !important; } + .requests-summary { grid-template-columns: 1fr; } + .requests-toolbar { align-items: flex-start; flex-direction: column; } + .request-row { grid-template-columns: 1fr; gap: 12px; } + .request-actions { min-width: 0; text-align: left; } } @media (max-width: 640px) {