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:
parent
a7c4740d02
commit
4e9b820686
6 changed files with 263 additions and 14 deletions
|
|
@ -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 },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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). */
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -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. */
|
||||||
|
|
|
||||||
|
|
@ -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 } }),
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue