From fab64bc38a40d45b7101a5ead36b2d28305951e2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 14:42:29 +0000 Subject: [PATCH] =?UTF-8?q?R=C3=B4le=20mod=C3=A9rateur=20+=20gestion=20des?= =?UTF-8?q?=20comptes=20admin/mod=C3=A9rateur?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Nouveau rôle users.role='moderator' (contrainte CHECK migrée via DROP+ADD CONSTRAINT, idempotent) : accès à Membres, Rencontres, Inscriptions uniquement. Dashboard, Catégories (écriture), Contenu du site, Demandes d'adhésion et gestion des comptes restent admin-only, imposé côté serveur (requireAuth accepte désormais un tableau de rôles) - Nouvel onglet « Administrateurs » (admin-only) : créer un compte admin ou modérateur (mot de passe temporaire généré, même mécanique que pour les membres — visible tant que non changé, changement forcé à la première connexion), réinitialiser l'accès, promouvoir/rétrograder, supprimer. Garde-fous : impossible de se supprimer ou de se rétrograder soi-même, impossible de supprimer le dernier administrateur - users.full_name (colonne migrée) pour l'affichage des comptes staff - AccessCell/CredentialsModal extraits dans AccessControls.jsx, partagés entre la gestion des membres et celle des comptes admin/modérateur - AdminShell filtre ses onglets par rôle ; Espace.jsx route admin et modérateur vers le back-office Corrigé en cours de route : GET /api/admin/categories doit rester lisible par les modérateurs (nécessaire au formulaire membre) même si sa gestion en écriture reste admin-only — sans quoi le Promise.all du frontend échouait silencieusement et vidait la liste des membres. Vérifié : 34 + 21 tests existants toujours au vert, 26 nouveaux tests de permissions par rôle, parcours navigateur complet (création modérateur, connexion, changement forcé, menu restreint, accès aux membres). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Fkg15RCxNgUys4ru73He2o --- README.md | 22 ++- server/src/errors.js | 9 + server/src/memberAccess.js | 8 +- server/src/middleware/auth.js | 7 +- server/src/routes/admin.js | 117 +++++++++++-- server/src/schemas.js | 10 ++ server/src/sql/schema.sql | 12 +- server/src/sql/seed.sql | 4 +- server/src/userAccess.js | 39 +++++ web/src/App.jsx | 11 +- web/src/components/admin/AccessControls.jsx | 94 ++++++++++ web/src/components/admin/AdminTabs.jsx | 104 ++--------- web/src/components/admin/AdminUsers.jsx | 180 ++++++++++++++++++++ web/src/pages/Admin.jsx | 33 ++-- web/src/pages/Espace.jsx | 8 +- 15 files changed, 514 insertions(+), 144 deletions(-) create mode 100644 server/src/errors.js create mode 100644 server/src/userAccess.js create mode 100644 web/src/components/admin/AccessControls.jsx create mode 100644 web/src/components/admin/AdminUsers.jsx diff --git a/README.md b/README.md index e44d1f5..edc30b6 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,9 @@ s'ils n'en ont pas déjà un : | Admin | `admin@sluc-businessclub.fr` | `ADMIN_INITIAL_PASSWORD` (défaut : `SlucAdmin2026!`) | | Membre (×12) | email de contact de chaque entreprise de démo, ex. `contact@lorraine-assurances.fr` | `MEMBER_INITIAL_PASSWORD` (défaut : `SlucMembre2026!`) | +D'autres comptes administrateur ou modérateur se créent depuis le back-office +(onglet « Administrateurs », réservé aux admins) — voir ci-dessous. + 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`). @@ -106,7 +109,19 @@ l'utilisateur est bloqué sur un écran de changement obligatoire avant d'accéd des octets magiques côté serveur). - Bannière d'état d'adhésion (validée / à renouveler) et changement de mot de passe. +**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. + **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 @@ -128,8 +143,11 @@ l'utilisateur est bloqué sur un écran de changement obligatoire avant d'accéd derrière HTTPS). - **CSRF** : cookie SameSite=Strict + vérification de l'en-tête `Origin` sur toutes les mutations. -- **Autorisation** : middleware de rôles (`member` / `admin`) sur chaque route protégée ; - un membre ne peut modifier que sa propre fiche. +- **Autorisation** : middleware de rôles (`member` / `moderator` / `admin`) sur chaque + route protégée ; un membre ne peut modifier que sa propre fiche ; les routes + administratives sensibles (catégories en écriture, contenu du site, demandes + d'adhésion, gestion des comptes) restent strictement admin-only même si un + modérateur est authentifié. - **Validation** : schémas `zod` sur toutes les entrées (types, longueurs, formats), contraintes `CHECK` en base en seconde ligne. - **Rate limiting** : global (300/min), connexion (10 / 15 min), formulaires publics diff --git a/server/src/errors.js b/server/src/errors.js new file mode 100644 index 0000000..ee8ed19 --- /dev/null +++ b/server/src/errors.js @@ -0,0 +1,9 @@ +// Thrown by access-management helpers (member/staff account creation and +// resets) for expected, user-facing failures — routes map these to the +// right HTTP status instead of a generic 500. +export class AccessError extends Error { + constructor(status, message) { + super(message); + this.status = status; + } +} diff --git a/server/src/memberAccess.js b/server/src/memberAccess.js index ba2dcb0..ddf5064 100644 --- a/server/src/memberAccess.js +++ b/server/src/memberAccess.js @@ -1,13 +1,7 @@ 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; - } -} +import { AccessError } from './errors.js'; // 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 diff --git a/server/src/middleware/auth.js b/server/src/middleware/auth.js index bea8cec..c9d4b83 100644 --- a/server/src/middleware/auth.js +++ b/server/src/middleware/auth.js @@ -31,10 +31,13 @@ export function attachUser(req, _res, next) { next(); } -export function requireAuth(role) { +// `roles` may be a single role string, an array of allowed roles, or +// omitted to just require any authenticated session. +export function requireAuth(roles) { + const allowed = roles ? (Array.isArray(roles) ? roles : [roles]) : null; return (req, res, next) => { if (!req.user) return res.status(401).json({ error: 'Authentification requise' }); - if (role && req.user.role !== role) return res.status(403).json({ error: 'Accès refusé' }); + if (allowed && !allowed.includes(req.user.role)) return res.status(403).json({ error: 'Accès refusé' }); next(); }; } diff --git a/server/src/routes/admin.js b/server/src/routes/admin.js index a4a4b53..ea8c275 100644 --- a/server/src/routes/admin.js +++ b/server/src/routes/admin.js @@ -8,14 +8,22 @@ import { inscriptionAdminSchema, categorySchema, contentSchema, + staffUserSchema, + roleChangeSchema, idParam, } from '../schemas.js'; import { imageUpload, saveImage, deleteImage } from '../uploads.js'; -import { ensureMemberAccess, AccessError } from '../memberAccess.js'; +import { ensureMemberAccess } from '../memberAccess.js'; +import { createStaffUser, resetStaffAccess } from '../userAccess.js'; +import { AccessError } from '../errors.js'; export const adminRouter = Router(); -adminRouter.use(requireAuth('admin')); +// Every route below requires at least an authenticated staff session +// (admin or moderator); individual routes further restrict to admin-only +// where noted. +adminRouter.use(requireAuth(['admin', 'moderator'])); +const adminOnly = requireAuth('admin'); const MEMBER_SQL = ` SELECT m.id, m.nom, m.secteur, m.categorie_id, c.name AS categorie, m.dirigeant, @@ -38,8 +46,8 @@ const INSCR_SQL = ` i.rencontre_id, r.titre AS rencontre FROM inscriptions i JOIN rencontres r ON r.id = i.rencontre_id`; -// ---------- Dashboard ---------- -adminRouter.get('/dashboard', async (_req, res, next) => { +// ---------- Dashboard (admin only) ---------- +adminRouter.get('/dashboard', adminOnly, async (_req, res, next) => { try { const [kpis, latest, upcoming] = await Promise.all([ query(` @@ -60,7 +68,7 @@ adminRouter.get('/dashboard', async (_req, res, next) => { } }); -// ---------- Members ---------- +// ---------- Members (admin + moderator) ---------- adminRouter.get('/members', async (_req, res, next) => { try { const result = await query(`${MEMBER_SQL} ORDER BY m.nom`); @@ -145,7 +153,7 @@ adminRouter.post('/members/:id/reset-access', validate(idParam, 'params'), async } }); -// ---------- Rencontres ---------- +// ---------- Rencontres (admin + moderator) ---------- adminRouter.get('/rencontres', async (_req, res, next) => { try { const result = await query(`${RENC_SQL} GROUP BY r.id ORDER BY r.date_renc`); @@ -202,7 +210,7 @@ adminRouter.get('/rencontres/:id/inscriptions', validate(idParam, 'params'), asy } }); -// ---------- Inscriptions ---------- +// ---------- Inscriptions (admin + moderator) ---------- adminRouter.get('/inscriptions', async (_req, res, next) => { try { const result = await query(`${INSCR_SQL} ORDER BY i.created_at DESC, i.id DESC`); @@ -250,6 +258,9 @@ adminRouter.delete('/inscriptions/:id', validate(idParam, 'params'), async (req, }); // ---------- Categories ---------- +// Read access is shared with moderators: the member form's category +// dropdown needs it. Managing categories (create/rename/delete) stays +// admin-only. adminRouter.get('/categories', async (_req, res, next) => { try { const result = await query(` @@ -262,7 +273,7 @@ adminRouter.get('/categories', async (_req, res, next) => { } }); -adminRouter.post('/categories', validate(categorySchema), async (req, res, next) => { +adminRouter.post('/categories', adminOnly, validate(categorySchema), async (req, res, next) => { try { const result = await query( 'INSERT INTO categories (name) VALUES ($1) ON CONFLICT (name) DO NOTHING RETURNING id', @@ -275,7 +286,7 @@ adminRouter.post('/categories', validate(categorySchema), async (req, res, next) } }); -adminRouter.put('/categories/:id', validate(idParam, 'params'), validate(categorySchema), async (req, res, next) => { +adminRouter.put('/categories/:id', adminOnly, validate(idParam, 'params'), validate(categorySchema), async (req, res, next) => { try { const result = await query('UPDATE categories SET name = $1 WHERE id = $2 RETURNING id', [ req.data.name, @@ -289,7 +300,7 @@ adminRouter.put('/categories/:id', validate(idParam, 'params'), validate(categor } }); -adminRouter.delete('/categories/:id', validate(idParam, 'params'), async (req, res, next) => { +adminRouter.delete('/categories/:id', adminOnly, validate(idParam, 'params'), async (req, res, next) => { try { // members.categorie_id has ON DELETE SET NULL: they become "Non classée" await query('DELETE FROM categories WHERE id = $1', [req.params.id]); @@ -299,8 +310,8 @@ adminRouter.delete('/categories/:id', validate(idParam, 'params'), async (req, r } }); -// ---------- Site content ---------- -adminRouter.put('/content', validate(contentSchema), async (req, res, next) => { +// ---------- Site content (admin only) ---------- +adminRouter.put('/content', adminOnly, validate(contentSchema), async (req, res, next) => { try { const entries = Object.entries(req.data).filter(([, v]) => v !== undefined); for (const [key, value] of entries) { @@ -316,7 +327,7 @@ adminRouter.put('/content', validate(contentSchema), async (req, res, next) => { } }); -adminRouter.post('/content/hero-photo', (req, res, next) => { +adminRouter.post('/content/hero-photo', adminOnly, (req, res, next) => { imageUpload(req, res, async (err) => { if (err) return res.status(400).json({ error: 'Fichier invalide (2 Mo max).' }); try { @@ -337,8 +348,8 @@ adminRouter.post('/content/hero-photo', (req, res, next) => { }); }); -// ---------- Demandes d'adhésion ---------- -adminRouter.get('/demandes', async (_req, res, next) => { +// ---------- Demandes d'adhésion (admin only) ---------- +adminRouter.get('/demandes', adminOnly, async (_req, res, next) => { try { const result = await query('SELECT * FROM demandes_adhesion ORDER BY created_at DESC'); res.json({ demandes: result.rows }); @@ -346,3 +357,79 @@ adminRouter.get('/demandes', async (_req, res, next) => { next(err); } }); + +// ---------- Admin & moderator accounts (admin only) ---------- +adminRouter.get('/users', adminOnly, async (req, res, next) => { + try { + const result = await query(` + SELECT id, email, full_name, role, must_change_password, + CASE WHEN must_change_password THEN temp_password ELSE NULL END AS temp_password, + created_at + FROM users WHERE role IN ('admin', 'moderator') + ORDER BY role, full_name, email`); + res.json({ users: result.rows.map((u) => ({ ...u, is_self: u.id === req.user.sub })) }); + } catch (err) { + next(err); + } +}); + +adminRouter.post('/users', adminOnly, validate(staffUserSchema), async (req, res, next) => { + try { + const { fullName, email, role } = req.data; + const { id, tempPassword } = await createStaffUser({ fullName, email, role }); + res.status(201).json({ id, tempPassword }); + } catch (err) { + if (err instanceof AccessError) return res.status(err.status).json({ error: err.message }); + next(err); + } +}); + +adminRouter.post('/users/:id/reset-access', adminOnly, validate(idParam, 'params'), async (req, res, next) => { + try { + const tempPassword = await resetStaffAccess(req.params.id); + res.json({ tempPassword }); + } catch (err) { + if (err instanceof AccessError) return res.status(err.status).json({ error: err.message }); + next(err); + } +}); + +adminRouter.put('/users/:id/role', adminOnly, validate(idParam, 'params'), validate(roleChangeSchema), async (req, res, next) => { + try { + if (req.params.id === req.user.sub) { + return res.status(400).json({ error: 'Vous ne pouvez pas modifier votre propre rôle.' }); + } + const target = await query(`SELECT role FROM users WHERE id = $1 AND role IN ('admin', 'moderator')`, [req.params.id]); + if (target.rowCount === 0) return res.status(404).json({ error: 'Compte introuvable' }); + if (target.rows[0].role === 'admin' && req.data.role !== 'admin') { + const adminCount = await query(`SELECT COUNT(*)::int AS n FROM users WHERE role = 'admin'`); + if (adminCount.rows[0].n <= 1) { + return res.status(400).json({ error: 'Impossible : il doit rester au moins un administrateur.' }); + } + } + await query('UPDATE users SET role = $1 WHERE id = $2', [req.data.role, req.params.id]); + res.json({ ok: true }); + } catch (err) { + next(err); + } +}); + +adminRouter.delete('/users/:id', adminOnly, validate(idParam, 'params'), async (req, res, next) => { + try { + if (req.params.id === req.user.sub) { + return res.status(400).json({ error: 'Vous ne pouvez pas supprimer votre propre compte.' }); + } + const target = await query(`SELECT role FROM users WHERE id = $1 AND role IN ('admin', 'moderator')`, [req.params.id]); + if (target.rowCount === 0) return res.status(404).json({ error: 'Compte introuvable' }); + if (target.rows[0].role === 'admin') { + const adminCount = await query(`SELECT COUNT(*)::int AS n FROM users WHERE role = 'admin'`); + if (adminCount.rows[0].n <= 1) { + return res.status(400).json({ error: 'Impossible de supprimer le dernier compte administrateur.' }); + } + } + await query(`DELETE FROM users WHERE id = $1 AND role IN ('admin', 'moderator')`, [req.params.id]); + res.json({ ok: true }); + } catch (err) { + next(err); + } +}); diff --git a/server/src/schemas.js b/server/src/schemas.js index b7daa10..cdda71a 100644 --- a/server/src/schemas.js +++ b/server/src/schemas.js @@ -68,6 +68,16 @@ export const inscriptionAdminSchema = z.object({ export const categorySchema = z.object({ name: trimmed(80, 1) }); +export const staffRoleEnum = z.enum(['admin', 'moderator']); + +export const staffUserSchema = z.object({ + fullName: trimmed(120, 1), + email: trimmed(254, 3).email(), + role: staffRoleEnum, +}); + +export const roleChangeSchema = z.object({ role: staffRoleEnum }); + export const contentSchema = z.object({ hero_quote_text: trimmed(300).optional(), hero_quote_author: trimmed(120).optional(), diff --git a/server/src/sql/schema.sql b/server/src/sql/schema.sql index 7d88f26..5fc0fa2 100644 --- a/server/src/sql/schema.sql +++ b/server/src/sql/schema.sql @@ -28,8 +28,11 @@ 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')), + role TEXT NOT NULL CHECK (role IN ('member', 'admin', 'moderator')), member_id INTEGER UNIQUE REFERENCES members(id) ON DELETE CASCADE, + -- Display name for admin/moderator accounts (member accounts show their + -- name via the linked members row instead, this stays NULL for them). + full_name TEXT, -- 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. @@ -37,10 +40,13 @@ CREATE TABLE IF NOT EXISTS users ( 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 ... IF NOT EXISTS / DROP+ADD CONSTRAINT heal databases that already +-- had this table before these columns/roles 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; +ALTER TABLE users ADD COLUMN IF NOT EXISTS full_name TEXT; +ALTER TABLE users DROP CONSTRAINT IF EXISTS users_role_check; +ALTER TABLE users ADD CONSTRAINT users_role_check CHECK (role IN ('member', 'admin', 'moderator')); CREATE TABLE IF NOT EXISTS rencontres ( id SERIAL PRIMARY KEY, diff --git a/server/src/sql/seed.sql b/server/src/sql/seed.sql index 579dda6..9e793a7 100644 --- a/server/src/sql/seed.sql +++ b/server/src/sql/seed.sql @@ -62,6 +62,6 @@ INSERT INTO users (email, password_hash, role, member_id) SELECT email, '*seed*', 'member', id FROM members WHERE email IS NOT NULL ON CONFLICT (email) DO NOTHING; -INSERT INTO users (email, password_hash, role) VALUES - ('admin@sluc-businessclub.fr', '*seed*', 'admin') +INSERT INTO users (email, password_hash, role, full_name) VALUES + ('admin@sluc-businessclub.fr', '*seed*', 'admin', 'Administrateur') ON CONFLICT (email) DO NOTHING; diff --git a/server/src/userAccess.js b/server/src/userAccess.js new file mode 100644 index 0000000..9a5dc75 --- /dev/null +++ b/server/src/userAccess.js @@ -0,0 +1,39 @@ +import bcrypt from 'bcryptjs'; +import { query } from './db.js'; +import { generateTempPassword } from './passwords.js'; +import { AccessError } from './errors.js'; + +const STAFF_ROLES = ['admin', 'moderator']; + +// Creates an admin or moderator account with a temporary password — same +// readable-until-changed mechanic as member accounts (see memberAccess.js). +export async function createStaffUser({ fullName, email, role }) { + if (!STAFF_ROLES.includes(role)) throw new AccessError(400, 'Rôle invalide.'); + const tempPassword = generateTempPassword(); + const hash = await bcrypt.hash(tempPassword, 12); + try { + const result = await query( + `INSERT INTO users (email, password_hash, role, full_name, temp_password, must_change_password) + VALUES ($1, $2, $3, $4, $5, true) RETURNING id`, + [email, hash, role, fullName, tempPassword] + ); + return { id: result.rows[0].id, tempPassword }; + } catch (err) { + if (err.code === '23505') throw new AccessError(409, 'Cet email est déjà utilisé.'); + throw err; + } +} + +// Regenerates the temporary password for an existing admin/moderator +// account (e.g. they lost it). +export async function resetStaffAccess(userId) { + const tempPassword = generateTempPassword(); + const hash = await bcrypt.hash(tempPassword, 12); + const result = await query( + `UPDATE users SET password_hash=$1, temp_password=$2, must_change_password=true + WHERE id=$3 AND role IN ('admin', 'moderator') RETURNING id`, + [hash, tempPassword, userId] + ); + if (result.rowCount === 0) throw new AccessError(404, 'Compte introuvable.'); + return tempPassword; +} diff --git a/web/src/App.jsx b/web/src/App.jsx index b077413..21e78e9 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -12,12 +12,15 @@ import { useAuth } from './lib/AuthContext.jsx'; export default function App() { const location = useLocation(); 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. + // The admin backend (shown inside /espace-membre once an admin or + // moderator 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' && !user?.mustChangePassword); + (location.pathname === '/espace-membre' && + (user?.role === 'admin' || user?.role === 'moderator') && + !user?.mustChangePassword); useEffect(() => { window.scrollTo(0, 0); diff --git a/web/src/components/admin/AccessControls.jsx b/web/src/components/admin/AccessControls.jsx new file mode 100644 index 0000000..7a17fc3 --- /dev/null +++ b/web/src/components/admin/AccessControls.jsx @@ -0,0 +1,94 @@ +import { useState } from 'react'; +import Modal from '../Modal.jsx'; + +// Shows a just-generated temporary password so the admin can relay it +// (copy button). It also stays readable inline (via AccessCell below) +// until the account holder changes it. +export function CredentialsModal({ recipient, 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 à {recipient}. Ce mot de passe devra être + changé dès la première connexion. +

+
+ {tempPassword} + +
+

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

+ +
+
+ ); +} + +// Compact cell for a table row: shows the live temp password (+ copy + +// reset), a "Défini" badge once changed, or a "Créer l'accès" action when +// there's no login yet. +export function AccessCell({ hasEmail = true, hasLogin, mustChangePassword, tempPassword, onGenerate, createLabel = "Créer l'accès" }) { + 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 (!hasEmail) { + return ( + + — + + ); + } + if (!hasLogin) { + return ( + + ); + } + if (mustChangePassword && tempPassword) { + return ( +
+ + {tempPassword} + + + +
+ ); + } + return ( +
+ Défini + +
+ ); +} diff --git a/web/src/components/admin/AdminTabs.jsx b/web/src/components/admin/AdminTabs.jsx index 4472aa3..0b0ff86 100644 --- a/web/src/components/admin/AdminTabs.jsx +++ b/web/src/components/admin/AdminTabs.jsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react'; import { api, dateParts, seasonLabel, statutLabel } from '../../lib/api.js'; import Modal from '../Modal.jsx'; +import { AccessCell, CredentialsModal } from './AccessControls.jsx'; /* ---------------- Members ---------------- */ @@ -88,95 +89,6 @@ 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([]); @@ -206,7 +118,7 @@ export function MembersTab() { setAccessError(''); try { const d = await api.post(`/api/admin/members/${m.id}/reset-access`); - setCredentials({ email: m.email, tempPassword: d.tempPassword }); + setCredentials({ recipient: m.email, tempPassword: d.tempPassword }); reload(); } catch (err) { setAccessError(err.message); @@ -255,7 +167,13 @@ export function MembersTab() { - + generateAccess(m)} + /> + + + + + ); +} + +export function UsersTab() { + const [users, setUsers] = useState([]); + const [modal, setModal] = useState(false); + const [credentials, setCredentials] = useState(null); + const [error, setError] = useState(''); + + const reload = () => api.get('/api/admin/users').then((d) => setUsers(d.users)).catch(() => {}); + + useEffect(() => { + reload(); + }, []); + + const generateAccess = async (u) => { + setError(''); + try { + const d = await api.post(`/api/admin/users/${u.id}/reset-access`); + setCredentials({ recipient: `${u.full_name} (${u.email})`, tempPassword: d.tempPassword }); + reload(); + } catch (err) { + setError(err.message); + } + }; + + const changeRole = async (u, role) => { + setError(''); + try { + await api.put(`/api/admin/users/${u.id}/role`, { role }); + reload(); + } catch (err) { + setError(err.message); + } + }; + + const remove = async (u) => { + if (!window.confirm(`Supprimer le compte de ${u.full_name || u.email} ?`)) return; + setError(''); + try { + await api.del(`/api/admin/users/${u.id}`); + reload(); + } catch (err) { + setError(err.message); + } + }; + + return ( +
+
+
+

Administrateurs & modérateurs

+
+ Les modérateurs gèrent les membres, les rencontres et les inscriptions. Les administrateurs + ont accès à l'ensemble du back-office. +
+
+ +
+ {error &&

{error}

} +
+ + + + + + + + {users.map((u) => ( + + + + + + + + ))} + +
NomEmailRôleAccès
{u.full_name || '—'}{u.email} + + {ROLE_LABEL[u.role]} + + + generateAccess(u)} + /> + + {u.is_self ? ( + Vous + ) : ( + <> + + + + )} +
+
+ {modal && ( + setModal(false)} + onCreated={(result) => { + setModal(false); + reload(); + setCredentials({ recipient: `${result.fullName} (${result.email})`, tempPassword: result.tempPassword }); + }} + /> + )} + {credentials && ( + setCredentials(null)} + /> + )} +
+ ); +} diff --git a/web/src/pages/Admin.jsx b/web/src/pages/Admin.jsx index df7aae0..ded0d48 100644 --- a/web/src/pages/Admin.jsx +++ b/web/src/pages/Admin.jsx @@ -4,14 +4,19 @@ 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 { 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). const TABS = [ - { key: 'dashboard', icon: '◧', label: 'Tableau de bord', title: 'Tableau de bord' }, - { key: 'membres', icon: '▤', label: 'Membres', title: 'Gestion des membres' }, - { key: 'rencontres', icon: '◈', label: 'Rencontres', title: 'Rencontres' }, - { key: 'inscriptions', icon: '✎', label: 'Inscriptions', title: 'Inscriptions' }, - { key: 'categories', icon: '☲', label: 'Catégories', title: 'Catégories' }, - { key: 'contenu', icon: '▧', label: 'Contenu du site', title: 'Contenu du site' }, + { 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: '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'] }, + { key: 'utilisateurs', icon: '⚿', label: 'Administrateurs', title: 'Administrateurs & modérateurs', roles: ['admin'] }, ]; function Dashboard() { @@ -86,13 +91,14 @@ function Dashboard() { } // Rendered inside /espace-membre once a logged-in user is confirmed as -// admin (see Espace.jsx). Assumes an authenticated admin user — no auth -// gate here, the caller already checked it. +// admin or moderator (see Espace.jsx). Assumes an authenticated staff +// user — no auth gate here, the caller already checked it. export function AdminShell() { const { user, logout } = useAuth(); - const [tab, setTab] = useState('dashboard'); + const tabs = TABS.filter((t) => t.roles.includes(user.role)); + const [tab, setTab] = useState(tabs[0].key); - const current = TABS.find((t) => t.key === tab); + const current = tabs.find((t) => t.key === tab) || tabs[0]; const today = new Date().toLocaleDateString('fr-FR', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' }); return ( @@ -101,12 +107,14 @@ export function AdminShell() {
SLUC
-
Admin
+
+ {user.role === 'admin' ? 'Admin' : 'Modérateur'} +
Business Club