v0.4.0 — gestion des admins (promotion / rétrogradation)

- DB : colonne is_creator sur members + migration automatique
- API : GET /api/config/members (liste admin), PATCH /api/config/members/:id (rôle)
- Règles : créateur non rétrogradable, admin non auto-rétrogradable, dernier admin protégé
- Front : panneau 'Membres de la classe' dans l'onglet Plus (admin)
- Chaque membre affiche son badge (Créateur / Admin / Élève) + boutons promote/demote
This commit is contained in:
Mathis 2026-10-04 17:46:12 +02:00
parent a7c4740d02
commit 4e9b820686
6 changed files with 263 additions and 14 deletions

View file

@ -9,6 +9,7 @@ interface MemberRow {
class_id: number class_id: number
nickname: string nickname: string
role: Role role: Role
is_creator: number
class_name: string class_name: string
class_level: string | null class_level: string | null
class_flash_message: string | null class_flash_message: string | null
@ -47,11 +48,11 @@ export function createClass(name: string, level: string | null, nickname: string
.prepare('INSERT INTO classes (name, level, code, created_at) VALUES (?, ?, ?, ?)') .prepare('INSERT INTO classes (name, level, code, created_at) VALUES (?, ?, ?, ?)')
.run(name, level, code, now).lastInsertRowid as number .run(name, level, code, now).lastInsertRowid as number
const memberId = db const memberId = db
.prepare('INSERT INTO members (class_id, nickname, role, token, created_at) VALUES (?, ?, ?, ?, ?)') .prepare('INSERT INTO members (class_id, nickname, role, is_creator, token, created_at) VALUES (?, ?, ?, ?, ?, ?)')
.run(classId, nickname, 'admin', token, now).lastInsertRowid as number .run(classId, nickname, 'admin', 1, token, now).lastInsertRowid as number
return { return {
token, token,
member: { id: memberId, classId, nickname, role: 'admin' }, member: { id: memberId, classId, nickname, role: 'admin', isCreator: true },
klass: { id: classId, name, level, flashMessage: null }, klass: { id: classId, name, level, flashMessage: null },
} }
}) })
@ -71,7 +72,7 @@ export function joinClass(code: string, nickname: string): NewSession | null {
.run(row.id, nickname, 'student', token, nowIso()).lastInsertRowid as number .run(row.id, nickname, 'student', token, nowIso()).lastInsertRowid as number
return { return {
token, token,
member: { id: memberId, classId: row.id, nickname, role: 'student' }, member: { id: memberId, classId: row.id, nickname, role: 'student', isCreator: false },
klass: { id: row.id, name: row.name, level: row.level, flashMessage: null }, klass: { id: row.id, name: row.name, level: row.level, flashMessage: null },
} }
} }
@ -79,7 +80,7 @@ export function joinClass(code: string, nickname: string): NewSession | null {
export function findSession(token: string): { member: Member; klass: ClassInfo } | null { export function findSession(token: string): { member: Member; klass: ClassInfo } | null {
const row = db const row = db
.prepare<[string], MemberRow>( .prepare<[string], MemberRow>(
`SELECT m.id, m.class_id, m.nickname, m.role, `SELECT m.id, m.class_id, m.nickname, m.role, m.is_creator,
c.name AS class_name, c.level AS class_level, c.name AS class_name, c.level AS class_level,
c.flash_message AS class_flash_message c.flash_message AS class_flash_message
FROM members m FROM members m
@ -89,7 +90,7 @@ export function findSession(token: string): { member: Member; klass: ClassInfo }
.get(token) .get(token)
if (!row) return null if (!row) return null
return { return {
member: { id: row.id, classId: row.class_id, nickname: row.nickname, role: row.role }, member: { id: row.id, classId: row.class_id, nickname: row.nickname, role: row.role, isCreator: row.is_creator === 1 },
klass: { id: row.class_id, name: row.class_name, level: row.class_level, flashMessage: row.class_flash_message }, klass: { id: row.class_id, name: row.class_name, level: row.class_level, flashMessage: row.class_flash_message },
} }
} }

View file

@ -22,12 +22,13 @@ db.exec(`
); );
CREATE TABLE IF NOT EXISTS members ( CREATE TABLE IF NOT EXISTS members (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
class_id INTEGER NOT NULL REFERENCES classes(id) ON DELETE CASCADE, class_id INTEGER NOT NULL REFERENCES classes(id) ON DELETE CASCADE,
nickname TEXT NOT NULL, nickname TEXT NOT NULL,
role TEXT NOT NULL CHECK (role IN ('admin','student')), role TEXT NOT NULL CHECK (role IN ('admin','student')),
token TEXT NOT NULL UNIQUE, is_creator INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL token TEXT NOT NULL UNIQUE,
created_at TEXT NOT NULL
); );
CREATE INDEX IF NOT EXISTS idx_members_token ON members(token); CREATE INDEX IF NOT EXISTS idx_members_token ON members(token);
@ -68,6 +69,20 @@ try {
// colonne déjà présente : rien à faire // colonne déjà présente : rien à faire
} }
// Migration : is_creator sur members (le premier admin d'une classe = créateur).
try {
db.exec(`ALTER TABLE members ADD COLUMN is_creator INTEGER NOT NULL DEFAULT 0`)
// Marquer le premier membre de chaque classe comme créateur.
db.exec(`
UPDATE members SET is_creator = 1
WHERE id = (
SELECT MIN(m2.id) FROM members m2 WHERE m2.class_id = members.class_id
)
`)
} catch {
// colonne déjà présente : rien à faire
}
export const nowIso = (): string => new Date().toISOString() export const nowIso = (): string => new Date().toISOString()
/** Raccourci : préparer une requête dont `.get()` renvoie `changes` (DELETE). */ /** Raccourci : préparer une requête dont `.get()` renvoie `changes` (DELETE). */

View file

@ -1,6 +1,6 @@
import { Router } from 'express' import { Router } from 'express'
import { z } from 'zod' import { z } from 'zod'
import type { ClassInfo, DayWeather, FlashInfo, WeatherKind } from '@meteo-exam/shared' import type { ClassInfo, DayWeather, FlashInfo, Role, WeatherKind } from '@meteo-exam/shared'
import { isWeatherKind } from '@meteo-exam/shared' import { isWeatherKind } from '@meteo-exam/shared'
import { db, nowIso } from '../db.js' import { db, nowIso } from '../db.js'
import { requireSession, requireAdmin } from '../auth.js' import { requireSession, requireAdmin } from '../auth.js'
@ -142,3 +142,111 @@ configRouter.patch('/class', requireSession, requireAdmin, (req, res) => {
broadcast(classId, { type: 'class.updated', klass }) broadcast(classId, { type: 'class.updated', klass })
res.json(klass) res.json(klass)
}) })
// --- Gestion des membres (promotion / rétrogradation) ------------------------
interface MemberListRow {
id: number
nickname: string
role: Role
is_creator: number
created_at: string
}
/** Liste les membres de la classe (réservé aux admins). */
configRouter.get('/members', requireSession, requireAdmin, (req, res) => {
const classId = req.session!.member.classId
const rows = db
.prepare<[number], MemberListRow>(
'SELECT id, nickname, role, is_creator, created_at FROM members WHERE class_id = ? ORDER BY is_creator DESC, role ASC, nickname ASC',
)
.all(classId)
res.json(
rows.map((r) => ({
id: r.id,
classId,
nickname: r.nickname,
role: r.role,
isCreator: r.is_creator === 1,
})),
)
})
const roleSchema = z.object({
role: z.enum(['admin', 'student']),
})
/** Change le rôle d'un membre. Règles :
* - le créateur ne peut pas être rétrogradé ;
* - un admin ne peut pas se rétrograder lui-même (sinon plus personne ne gère) ;
* - la dernière admin de la classe ne peut pas être rétrogradée.
*/
configRouter.patch('/members/:id', requireSession, requireAdmin, (req, res) => {
const raw = req.params['id']
const memberId = Number(Array.isArray(raw) ? raw[0] : raw)
if (!Number.isInteger(memberId)) {
res.status(400).json({ error: 'Identifiant invalide' })
return
}
const parsed = roleSchema.safeParse(req.body)
if (!parsed.success) {
res.status(400).json({ error: 'Rôle invalide' })
return
}
const classId = req.session!.member.classId
const target = db
.prepare<[number, number], MemberListRow>(
'SELECT id, nickname, role, is_creator, created_at FROM members WHERE id = ? AND class_id = ?',
)
.get(memberId, classId)
if (!target) {
res.status(404).json({ error: 'Membre introuvable' })
return
}
const newRole = parsed.data.role
const selfId = req.session!.member.id
// Le créateur reste toujours admin.
if (target.is_creator === 1 && newRole === 'student') {
res.status(403).json({ error: 'Le créateur de la classe ne peut pas être rétrogradé.' })
return
}
// Un admin ne peut pas se rétrograder soi-même.
if (memberId === selfId && newRole === 'student') {
res.status(403).json({ error: 'Vous ne pouvez pas vous rétrograder vous-même.' })
return
}
// Si on rétrograde un admin, vérifier qu'il en reste au moins un autre.
if (target.role === 'admin' && newRole === 'student') {
const adminCount = db
.prepare<[number, number], { n: number }>(
`SELECT COUNT(*) AS n FROM members WHERE class_id = ? AND role = 'admin' AND id != ?`,
)
.get(classId, memberId)!
if (adminCount.n === 0) {
res.status(403).json({ error: 'Impossible de rétrograder le dernier admin de la classe.' })
return
}
}
db.prepare('UPDATE members SET role = ? WHERE id = ? AND class_id = ?').run(newRole, memberId, classId)
const updated = db
.prepare<[number, number], MemberListRow>(
'SELECT id, nickname, role, is_creator, created_at FROM members WHERE id = ? AND class_id = ?',
)
.get(memberId, classId)!
res.json({
id: updated.id,
classId,
nickname: updated.nickname,
role: updated.role,
isCreator: updated.is_creator === 1,
})
})

View file

@ -155,6 +155,8 @@ export interface Member {
classId: number classId: number
nickname: string nickname: string
role: Role role: Role
/** true = créateur de la classe (ne peut pas être rétrogradé). */
isCreator: boolean
} }
/** Charge utile de `GET /api/me`, renvoyée aussi à la création et à l'adhésion. */ /** Charge utile de `GET /api/me`, renvoyée aussi à la création et à l'adhésion. */

View file

@ -1,5 +1,5 @@
import { Capacitor, CapacitorHttp } from '@capacitor/core' import { Capacitor, CapacitorHttp } from '@capacitor/core'
import type { ClassInfo, DayWeather, Exam, FlashInfo, Session, StreamEvent, WeatherKind } from '@meteo-exam/shared' import type { ClassInfo, DayWeather, Exam, FlashInfo, Member, Role, Session, StreamEvent, WeatherKind } from '@meteo-exam/shared'
import { getBaseUrl, getToken } from './config' import { getBaseUrl, getToken } from './config'
export class ApiFailure extends Error { export class ApiFailure extends Error {
@ -123,6 +123,12 @@ export const api = {
updateClass: (name: string, level: string | null) => updateClass: (name: string, level: string | null) =>
request<ClassInfo>('/api/config/class', { method: 'PATCH', body: { name, level } }), request<ClassInfo>('/api/config/class', { method: 'PATCH', body: { name, level } }),
// --- Gestion des membres ---
listMembers: () => request<Member[]>('/api/config/members'),
setMemberRole: (id: number, role: Role) =>
request<Member>(`/api/config/members/${id}`, { method: 'PATCH', body: { role } }),
} }
/** /**

View file

@ -1,8 +1,11 @@
import { h, mount } from '../dom' import { h, mount } from '../dom'
import type { Member, Role } from '@meteo-exam/shared'
import { getBaseUrl, setBaseUrl, DEFAULT_BASE_URL } from '../config' import { getBaseUrl, setBaseUrl, DEFAULT_BASE_URL } from '../config'
import { api } from '../api'
import { import {
currentFlash, currentFlash,
currentKlass, currentKlass,
getState,
isAdmin, isAdmin,
setFlash, setFlash,
signOut, signOut,
@ -20,6 +23,7 @@ export function renderPlus(root: HTMLElement, rerender: () => void): void {
const klass = currentKlass() const klass = currentKlass()
const flash = currentFlash() const flash = currentFlash()
const admin = isAdmin() const admin = isAdmin()
const selfMember = getState().session?.member ?? null
const serverInput = h('input', { const serverInput = h('input', {
class: inputClass, class: inputClass,
@ -48,6 +52,12 @@ export function renderPlus(root: HTMLElement, rerender: () => void): void {
// --- Panneau admin --- // --- Panneau admin ---
if (admin) { if (admin) {
const membersSection = h('div', { class: 'pt-3 border-t border-slate-700/60' },
h('span', { class: labelClass }, 'Membres de la classe'),
h('div', { id: 'members-list', class: 'space-y-1.5' },
h('p', { class: 'text-[11px] text-slate-500 font-semibold' }, 'Chargement…')),
)
children.push( children.push(
h( h(
'div', 'div',
@ -102,8 +112,14 @@ export function renderPlus(root: HTMLElement, rerender: () => void): void {
'Enregistrer la classe', 'Enregistrer la classe',
), ),
), ),
// Membres
membersSection,
), ),
) )
// Charger les membres de manière asynchrone
void loadMembers(membersSection.querySelector('#members-list')!, selfMember, rerender)
} }
// --- Serveur --- // --- Serveur ---
@ -181,3 +197,104 @@ export function renderPlus(root: HTMLElement, rerender: () => void): void {
mount(root, ...children.filter((c): c is Node => c !== null)) mount(root, ...children.filter((c): c is Node => c !== null))
} }
/** Charge et affiche la liste des membres avec les boutons promote/demote. */
async function loadMembers(
container: HTMLElement,
selfMember: { id: number; role: Role } | null,
rerender: () => void,
): Promise<void> {
try {
const members = await api.listMembers()
container.replaceChildren()
if (members.length === 0) {
container.append(h('p', { class: 'text-[11px] text-slate-500 font-semibold' }, 'Aucun membre.'))
return
}
for (const member of members) {
container.append(memberRow(member, selfMember, container, rerender))
}
} catch (error) {
container.replaceChildren(
h('p', { class: 'text-[11px] text-rose-400 font-semibold' },
error instanceof Error ? error.message : 'Erreur de chargement'),
)
}
}
function memberRow(
member: Member,
selfMember: { id: number; role: Role } | null,
container: HTMLElement,
rerender: () => void,
): HTMLElement {
const isSelf = selfMember?.id === member.id
const isCreator = member.isCreator
const isAdmin = member.role === 'admin'
const badge = isCreator
? h('span', { class: 'px-1.5 py-0.5 rounded text-[9px] font-black bg-amber-500/15 text-amber-400 border border-amber-400/30' }, 'Créateur')
: isAdmin
? h('span', { class: 'px-1.5 py-0.5 rounded text-[9px] font-black bg-sky-500/15 text-sky-400 border border-sky-400/30' }, 'Admin')
: h('span', { class: 'px-1.5 py-0.5 rounded text-[9px] font-black bg-slate-600/30 text-slate-400 border border-slate-600/50' }, 'Élève')
const actions = h('div', { class: 'flex gap-1.5 shrink-0' })
// Promouvoir un élève en admin
if (!isAdmin) {
actions.append(
h('button', {
class: 'px-2 py-1 rounded-lg bg-sky-500/10 hover:bg-sky-500/20 text-sky-400 text-[10px] font-extrabold transition-colors',
title: 'Promouvoir admin',
onclick: () => changeRole(member.id, 'admin', container, rerender),
}, '↑ Admin'),
)
}
// Rétrograder un admin (sauf créateur et soi-même)
if (isAdmin && !isCreator && !isSelf) {
actions.append(
h('button', {
class: 'px-2 py-1 rounded-lg bg-rose-500/10 hover:bg-rose-500/20 text-rose-400 text-[10px] font-extrabold transition-colors',
title: 'Rétrograder élève',
onclick: () => changeRole(member.id, 'student', container, rerender),
}, '↓ Élève'),
)
}
return h(
'div',
{ class: 'flex items-center justify-between gap-2 bg-slate-900/40 border border-slate-700/40 rounded-xl px-3 py-2' },
h(
'div',
{ class: 'flex items-center gap-2 min-w-0' },
h('span', { class: 'w-7 h-7 rounded-full bg-slate-700/60 flex items-center justify-center text-[10px] font-black text-slate-300 shrink-0' },
member.nickname.charAt(0).toUpperCase()),
h('span', { class: 'text-xs font-bold text-slate-200 truncate' }, member.nickname),
badge,
isSelf ? h('span', { class: 'text-[9px] text-slate-500 font-semibold' }, '(vous)') : null,
),
actions,
)
}
async function changeRole(
memberId: number,
role: Role,
container: HTMLElement,
rerender: () => void,
): Promise<void> {
try {
await api.setMemberRole(memberId, role)
showToast(role === 'admin' ? 'Membre promu admin' : 'Membre rétrogradé', 'success')
rerender()
} catch (error) {
const msg = error instanceof Error ? error.message : 'Erreur'
showToast(msg, 'reject')
// Recharger la liste pour refléter l'état réel
const selfMember = getState().session?.member ?? null
await loadMembers(container, selfMember, rerender)
}
}