diff --git a/Caddyfile b/Caddyfile
index f3b90a4..7ce18bb 100644
--- a/Caddyfile
+++ b/Caddyfile
@@ -3,6 +3,7 @@
}
:80 {
+ reverse_proxy /api/websocket* server:3001
reverse_proxy /gitea* gitea:3000
reverse_proxy server:3000
}
diff --git a/agent/activation.go b/agent/activation.go
index e651199..064a356 100644
--- a/agent/activation.go
+++ b/agent/activation.go
@@ -7,9 +7,10 @@ import (
)
type Activation struct {
- Activated bool `json:"activated"`
+ Activated bool `json:"activated"`
+ StudentId string `json:"studentId,omitempty"`
StudentName string `json:"studentName,omitempty"`
- Code string `json:"code,omitempty"`
+ Code string `json:"code,omitempty"`
}
func activationFile(dataDir string) string {
diff --git a/agent/docker.go b/agent/docker.go
index ea9742c..603e88f 100644
--- a/agent/docker.go
+++ b/agent/docker.go
@@ -10,6 +10,13 @@ func instanceDir(dataDir, instanceID string) string {
return filepath.Join(dataDir, "instances", instanceID)
}
+func getContainerEngine() string {
+ if _, err := exec.LookPath("podman"); err == nil {
+ return "podman"
+ }
+ return "docker"
+}
+
func writeCompose(dataDir, instanceID, compose string) error {
dir := instanceDir(dataDir, instanceID)
if err := os.MkdirAll(dir, 0755); err != nil {
@@ -21,7 +28,7 @@ func writeCompose(dataDir, instanceID, compose string) error {
func dockerComposeUp(dataDir, instanceID string) error {
dir := instanceDir(dataDir, instanceID)
- cmd := exec.Command("docker", "compose", "-f", filepath.Join(dir, "docker-compose.yml"), "up", "-d")
+ cmd := exec.Command(getContainerEngine(), "compose", "-f", filepath.Join(dir, "docker-compose.yml"), "up", "-d")
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
@@ -29,7 +36,7 @@ func dockerComposeUp(dataDir, instanceID string) error {
func dockerComposeDown(dataDir, instanceID string) error {
dir := instanceDir(dataDir, instanceID)
- cmd := exec.Command("docker", "compose", "-f", filepath.Join(dir, "docker-compose.yml"), "down")
+ cmd := exec.Command(getContainerEngine(), "compose", "-f", filepath.Join(dir, "docker-compose.yml"), "down")
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
@@ -37,7 +44,7 @@ func dockerComposeDown(dataDir, instanceID string) error {
func dockerComposeRm(dataDir, instanceID string) error {
dir := instanceDir(dataDir, instanceID)
- cmd := exec.Command("docker", "compose", "-f", filepath.Join(dir, "docker-compose.yml"), "down", "-v")
+ cmd := exec.Command(getContainerEngine(), "compose", "-f", filepath.Join(dir, "docker-compose.yml"), "down", "-v")
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
diff --git a/agent/websocket.go b/agent/websocket.go
index ddc795f..ede8640 100644
--- a/agent/websocket.go
+++ b/agent/websocket.go
@@ -15,6 +15,7 @@ type WSMessage struct {
Type string `json:"type,omitempty"`
Port int `json:"port,omitempty"`
ComposeConfig string `json:"composeConfig,omitempty"`
+ StudentId string `json:"studentId,omitempty"`
StudentName string `json:"studentName,omitempty"`
Error string `json:"error,omitempty"`
}
@@ -84,7 +85,7 @@ func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string)
case "activate":
// handled by UI, but server can also push activation response
if msg.StudentName != "" {
- act := &Activation{Activated: true, StudentName: msg.StudentName, Code: msg.Code}
+ act := &Activation{Activated: true, StudentId: msg.StudentId, StudentName: msg.StudentName, Code: msg.Code}
saveActivation(dataDir, act)
log.Printf("Activated as %s", msg.StudentName)
}
diff --git a/server/app/dashboard/classes/[id]/page.tsx b/server/app/dashboard/classes/[id]/page.tsx
index 79d81fd..42ee07e 100644
--- a/server/app/dashboard/classes/[id]/page.tsx
+++ b/server/app/dashboard/classes/[id]/page.tsx
@@ -9,14 +9,15 @@ import { DeleteClassDialog } from "./DeleteClassDialog";
export const dynamic = "force-dynamic";
-export default async function ClassDetailPage({ params }: { params: { id: string } }) {
+export default async function ClassDetailPage({ params }: { params: Promise<{ id: string }> }) {
+ const { id } = await params;
const session = await getServerSession(authOptions);
if (!session?.user) redirect("/login");
if (!session.user.establishmentId && session.user.role !== "superadmin") redirect("/dashboard");
const establishmentId = session.user.establishmentId;
const cls = await prisma.class.findFirst({
- where: { id: params.id, ...(establishmentId ? { establishmentId } : {}) },
+ where: { id, ...(establishmentId ? { establishmentId } : {}) },
include: { students: true },
});
diff --git a/server/app/dashboard/classes/new/page.tsx b/server/app/dashboard/classes/new/page.tsx
index fd22a9e..bd2d622 100644
--- a/server/app/dashboard/classes/new/page.tsx
+++ b/server/app/dashboard/classes/new/page.tsx
@@ -18,7 +18,7 @@ async function createClass(formData: FormData) {
"use server";
const session = await getServerSession(authOptions);
if (!session?.user) redirect("/login");
- if (!session.user.establishmentId && session.user.role !== "superadmin") redirect("/dashboard");
+ if (!session.user.establishmentId) redirect("/dashboard");
const parsed = schema.safeParse(Object.fromEntries(formData));
if (!parsed.success) return;
@@ -27,7 +27,7 @@ async function createClass(formData: FormData) {
data: {
name: parsed.data.name,
level: parsed.data.level,
- establishmentId: session.user.establishmentId!,
+ establishment: { connect: { id: session.user.establishmentId } },
},
});
@@ -37,7 +37,7 @@ async function createClass(formData: FormData) {
export default async function NewClassPage() {
const session = await getServerSession(authOptions);
if (!session?.user) redirect("/login");
- if (!session.user.establishmentId && session.user.role !== "superadmin") redirect("/dashboard");
+ if (!session.user.establishmentId) redirect("/dashboard");
return (
diff --git a/server/app/dashboard/download/page.tsx b/server/app/dashboard/download/page.tsx
index 92b54b3..ac9486c 100644
--- a/server/app/dashboard/download/page.tsx
+++ b/server/app/dashboard/download/page.tsx
@@ -14,7 +14,7 @@ export default function DownloadPage() {
Agent EduBox pour Windows (64 bits)
- Télécharger (.exe)
+ Télécharger (.exe)
@@ -23,7 +23,7 @@ export default function DownloadPage() {
Agent EduBox pour Linux (64 bits)
- Télécharger
+ Télécharger
@@ -32,7 +32,7 @@ export default function DownloadPage() {
Agent EduBox pour macOS (Intel & Apple Silicon)
- Télécharger
+ Télécharger
diff --git a/server/app/dashboard/students/[id]/page.tsx b/server/app/dashboard/students/[id]/page.tsx
index 7bc2c5a..ef13cd4 100644
--- a/server/app/dashboard/students/[id]/page.tsx
+++ b/server/app/dashboard/students/[id]/page.tsx
@@ -11,7 +11,8 @@ import { generateActivationCodeAction } from "./actions";
export const dynamic = "force-dynamic";
-export default async function StudentDetailPage({ params }: { params: { id: string } }) {
+export default async function StudentDetailPage({ params }: { params: Promise<{ id: string }> }) {
+ const { id } = await params;
const session = await getServerSession(authOptions);
if (!session?.user) redirect("/login");
if (!session.user.establishmentId && session.user.role !== "superadmin") redirect("/dashboard");
@@ -19,7 +20,7 @@ export default async function StudentDetailPage({ params }: { params: { id: stri
const establishmentId = session.user.establishmentId;
const student = await prisma.student.findFirst({
where: {
- id: params.id,
+ id,
class: establishmentId ? { establishmentId } : undefined,
},
include: { class: true },
diff --git a/server/app/superadmin/establishments/[id]/actions.ts b/server/app/superadmin/establishments/[id]/actions.ts
index 7642660..1f4b1a9 100644
--- a/server/app/superadmin/establishments/[id]/actions.ts
+++ b/server/app/superadmin/establishments/[id]/actions.ts
@@ -4,6 +4,7 @@ import { prisma } from "@/lib/prisma";
import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache";
import { z } from "zod";
+import { hashPassword } from "@/lib/auth";
const updateSchema = z.object({
plan: z.enum(["trial", "starter", "standard", "premium"]),
@@ -36,3 +37,42 @@ export async function deleteEstablishment(establishmentId: string) {
});
redirect("/superadmin/establishments");
}
+
+const createAdminSchema = z.object({
+ email: z.string().email("Email invalide"),
+ password: z.string().min(8, "Le mot de passe doit contenir au moins 8 caractères"),
+});
+
+export async function createAdmin(establishmentId: string, formData: FormData) {
+ const email = formData.get("email") as string;
+ const password = formData.get("password") as string;
+
+ const parsed = createAdminSchema.safeParse({ email, password });
+ if (!parsed.success) {
+ throw new Error(parsed.error.issues.map((e) => e.message).join(", "));
+ }
+
+ const existing = await prisma.user.findUnique({ where: { email: parsed.data.email } });
+ if (existing) throw new Error("Cet email est déjà utilisé");
+
+ const hashed = await hashPassword(parsed.data.password);
+
+ await prisma.user.create({
+ data: {
+ email: parsed.data.email,
+ password: hashed,
+ role: "admin",
+ establishmentId,
+ },
+ });
+
+ revalidatePath(`/superadmin/establishments/${establishmentId}`);
+}
+
+export async function deleteAdmin(establishmentId: string, userId: string) {
+ await prisma.user.deleteMany({
+ where: { id: userId, establishmentId, role: "admin" },
+ });
+
+ revalidatePath(`/superadmin/establishments/${establishmentId}`);
+}
diff --git a/server/app/superadmin/establishments/[id]/page.tsx b/server/app/superadmin/establishments/[id]/page.tsx
index 8243fba..53d9736 100644
--- a/server/app/superadmin/establishments/[id]/page.tsx
+++ b/server/app/superadmin/establishments/[id]/page.tsx
@@ -6,13 +6,16 @@ import { notFound } from "next/navigation";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Select } from "@/components/ui/select";
+import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
-import { updateSubscription, deleteEstablishment } from "./actions";
+import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "@/components/ui/table";
+import { updateSubscription, deleteEstablishment, createAdmin, deleteAdmin } from "./actions";
import { DeleteDialog } from "./delete-dialog";
-export default async function EstablishmentDetailPage({ params }: { params: { id: string } }) {
+export default async function EstablishmentDetailPage({ params }: { params: Promise<{ id: string }> }) {
+ const { id } = await params;
const establishment = await prisma.establishment.findUnique({
- where: { id: params.id },
+ where: { id },
include: {
subscription: true,
_count: { select: { users: true, classes: true } },
@@ -21,7 +24,13 @@ export default async function EstablishmentDetailPage({ params }: { params: { id
if (!establishment) notFound();
+ const admins = await prisma.user.findMany({
+ where: { establishmentId: id, role: "admin" },
+ orderBy: { createdAt: "desc" },
+ });
+
const boundDelete = deleteEstablishment.bind(null, establishment.id);
+ const boundCreateAdmin = createAdmin.bind(null, establishment.id);
return (
@@ -107,6 +116,57 @@ export default async function EstablishmentDetailPage({ params }: { params: { id
+
+
+
+ Administrateurs
+
+
+
+
+
+
+
+ Email
+ Rôle
+ Créé le
+ Actions
+
+
+
+ {admins.map((user) => (
+
+ {user.email}
+ {user.role}
+ {new Date(user.createdAt).toLocaleDateString("fr-FR")}
+
+
+
+
+ ))}
+ {admins.length === 0 && (
+
+
+ Aucun administrateur
+
+
+ )}
+
+
+
+
);
}
diff --git a/server/lib/websocket.ts b/server/lib/websocket.ts
index 5f877b5..639d21c 100644
--- a/server/lib/websocket.ts
+++ b/server/lib/websocket.ts
@@ -49,7 +49,7 @@ export function initWebSocketServer(wss: WebSocketServer) {
update: { studentId: student.id, status: "online", lastSeen: new Date() },
create: { id: nodeId, studentId: student.id, status: "online", lastSeen: new Date() },
});
- ws.send(JSON.stringify({ action: "activated", studentName: `${student.firstName} ${student.lastName}` }));
+ ws.send(JSON.stringify({ action: "activated", studentId: student.id, studentName: `${student.firstName} ${student.lastName}` }));
return;
}