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

@ -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<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 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<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)
}
}