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.
+
+ Ce mot de passe reste visible dans la liste tant qu'il n'a pas été changé.
+ {tempPassword}
+
+
+ {tempPassword}
+
+
+
+ - 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é. -
- -
- {member.temp_password}
-
-
-
-