diff --git a/server/src/auth.ts b/server/src/auth.ts index d1f6ca8..a84a092 100644 --- a/server/src/auth.ts +++ b/server/src/auth.ts @@ -9,6 +9,7 @@ interface MemberRow { class_id: number nickname: string role: Role + is_creator: number class_name: string class_level: 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 (?, ?, ?, ?)') .run(name, level, code, now).lastInsertRowid as number const memberId = db - .prepare('INSERT INTO members (class_id, nickname, role, token, created_at) VALUES (?, ?, ?, ?, ?)') - .run(classId, nickname, 'admin', token, now).lastInsertRowid as number + .prepare('INSERT INTO members (class_id, nickname, role, is_creator, token, created_at) VALUES (?, ?, ?, ?, ?, ?)') + .run(classId, nickname, 'admin', 1, token, now).lastInsertRowid as number return { token, - member: { id: memberId, classId, nickname, role: 'admin' }, + member: { id: memberId, classId, nickname, role: 'admin', isCreator: true }, 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 return { 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 }, } } @@ -79,7 +80,7 @@ export function joinClass(code: string, nickname: string): NewSession | null { export function findSession(token: string): { member: Member; klass: ClassInfo } | null { const row = db .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.flash_message AS class_flash_message FROM members m @@ -89,7 +90,7 @@ export function findSession(token: string): { member: Member; klass: ClassInfo } .get(token) if (!row) return null 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 }, } } diff --git a/server/src/db.ts b/server/src/db.ts index 4f6c146..d6d942f 100644 --- a/server/src/db.ts +++ b/server/src/db.ts @@ -22,12 +22,13 @@ db.exec(` ); CREATE TABLE IF NOT EXISTS members ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - class_id INTEGER NOT NULL REFERENCES classes(id) ON DELETE CASCADE, - nickname TEXT NOT NULL, - role TEXT NOT NULL CHECK (role IN ('admin','student')), - token TEXT NOT NULL UNIQUE, - created_at TEXT NOT NULL + id INTEGER PRIMARY KEY AUTOINCREMENT, + class_id INTEGER NOT NULL REFERENCES classes(id) ON DELETE CASCADE, + nickname TEXT NOT NULL, + role TEXT NOT NULL CHECK (role IN ('admin','student')), + is_creator INTEGER NOT NULL DEFAULT 0, + token TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_members_token ON members(token); @@ -68,6 +69,20 @@ try { // 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() /** Raccourci : préparer une requête dont `.get()` renvoie `changes` (DELETE). */ diff --git a/server/src/routes/config.ts b/server/src/routes/config.ts index 67938bf..1079eb5 100644 --- a/server/src/routes/config.ts +++ b/server/src/routes/config.ts @@ -1,6 +1,6 @@ import { Router } from 'express' 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 { db, nowIso } from '../db.js' import { requireSession, requireAdmin } from '../auth.js' @@ -142,3 +142,111 @@ configRouter.patch('/class', requireSession, requireAdmin, (req, res) => { broadcast(classId, { type: 'class.updated', 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, + }) +}) diff --git a/shared/src/index.ts b/shared/src/index.ts index 9176876..e0a0793 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -155,6 +155,8 @@ export interface Member { classId: number nickname: string 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. */ diff --git a/web/src/api.ts b/web/src/api.ts index 5830366..0a2bad8 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -1,5 +1,5 @@ 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' export class ApiFailure extends Error { @@ -123,6 +123,12 @@ export const api = { updateClass: (name: string, level: string | null) => request('/api/config/class', { method: 'PATCH', body: { name, level } }), + + // --- Gestion des membres --- + listMembers: () => request('/api/config/members'), + + setMemberRole: (id: number, role: Role) => + request(`/api/config/members/${id}`, { method: 'PATCH', body: { role } }), } /** diff --git a/web/src/views/plus.ts b/web/src/views/plus.ts index a8e31a7..3183f3b 100644 --- a/web/src/views/plus.ts +++ b/web/src/views/plus.ts @@ -1,8 +1,11 @@ import { h, mount } from '../dom' +import type { Member, Role } from '@meteo-exam/shared' import { getBaseUrl, setBaseUrl, DEFAULT_BASE_URL } from '../config' +import { api } from '../api' import { currentFlash, currentKlass, + getState, isAdmin, setFlash, signOut, @@ -20,6 +23,7 @@ export function renderPlus(root: HTMLElement, rerender: () => void): void { const klass = currentKlass() const flash = currentFlash() const admin = isAdmin() + const selfMember = getState().session?.member ?? null const serverInput = h('input', { class: inputClass, @@ -48,6 +52,12 @@ export function renderPlus(root: HTMLElement, rerender: () => void): void { // --- Panneau 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( h( 'div', @@ -102,8 +112,14 @@ export function renderPlus(root: HTMLElement, rerender: () => void): void { 'Enregistrer la classe', ), ), + + // Membres + membersSection, ), ) + + // Charger les membres de manière asynchrone + void loadMembers(membersSection.querySelector('#members-list')!, selfMember, rerender) } // --- Serveur --- @@ -181,3 +197,104 @@ export function renderPlus(root: HTMLElement, rerender: () => void): void { 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 { + 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 { + 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) + } +}