feat: auto-detect podman/docker in agent, add studentId to activation response, fix download URLs
This commit is contained in:
@@ -3,6 +3,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:80 {
|
:80 {
|
||||||
|
reverse_proxy /api/websocket* server:3001
|
||||||
reverse_proxy /gitea* gitea:3000
|
reverse_proxy /gitea* gitea:3000
|
||||||
reverse_proxy server:3000
|
reverse_proxy server:3000
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
|
|
||||||
type Activation struct {
|
type Activation struct {
|
||||||
Activated bool `json:"activated"`
|
Activated bool `json:"activated"`
|
||||||
|
StudentId string `json:"studentId,omitempty"`
|
||||||
StudentName string `json:"studentName,omitempty"`
|
StudentName string `json:"studentName,omitempty"`
|
||||||
Code string `json:"code,omitempty"`
|
Code string `json:"code,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-3
@@ -10,6 +10,13 @@ func instanceDir(dataDir, instanceID string) string {
|
|||||||
return filepath.Join(dataDir, "instances", instanceID)
|
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 {
|
func writeCompose(dataDir, instanceID, compose string) error {
|
||||||
dir := instanceDir(dataDir, instanceID)
|
dir := instanceDir(dataDir, instanceID)
|
||||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
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 {
|
func dockerComposeUp(dataDir, instanceID string) error {
|
||||||
dir := instanceDir(dataDir, instanceID)
|
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.Stdout = os.Stdout
|
||||||
cmd.Stderr = os.Stderr
|
cmd.Stderr = os.Stderr
|
||||||
return cmd.Run()
|
return cmd.Run()
|
||||||
@@ -29,7 +36,7 @@ func dockerComposeUp(dataDir, instanceID string) error {
|
|||||||
|
|
||||||
func dockerComposeDown(dataDir, instanceID string) error {
|
func dockerComposeDown(dataDir, instanceID string) error {
|
||||||
dir := instanceDir(dataDir, instanceID)
|
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.Stdout = os.Stdout
|
||||||
cmd.Stderr = os.Stderr
|
cmd.Stderr = os.Stderr
|
||||||
return cmd.Run()
|
return cmd.Run()
|
||||||
@@ -37,7 +44,7 @@ func dockerComposeDown(dataDir, instanceID string) error {
|
|||||||
|
|
||||||
func dockerComposeRm(dataDir, instanceID string) error {
|
func dockerComposeRm(dataDir, instanceID string) error {
|
||||||
dir := instanceDir(dataDir, instanceID)
|
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.Stdout = os.Stdout
|
||||||
cmd.Stderr = os.Stderr
|
cmd.Stderr = os.Stderr
|
||||||
if err := cmd.Run(); err != nil {
|
if err := cmd.Run(); err != nil {
|
||||||
|
|||||||
+2
-1
@@ -15,6 +15,7 @@ type WSMessage struct {
|
|||||||
Type string `json:"type,omitempty"`
|
Type string `json:"type,omitempty"`
|
||||||
Port int `json:"port,omitempty"`
|
Port int `json:"port,omitempty"`
|
||||||
ComposeConfig string `json:"composeConfig,omitempty"`
|
ComposeConfig string `json:"composeConfig,omitempty"`
|
||||||
|
StudentId string `json:"studentId,omitempty"`
|
||||||
StudentName string `json:"studentName,omitempty"`
|
StudentName string `json:"studentName,omitempty"`
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -84,7 +85,7 @@ func handleMessage(conn *websocket.Conn, msg WSMessage, dataDir, nodeID string)
|
|||||||
case "activate":
|
case "activate":
|
||||||
// handled by UI, but server can also push activation response
|
// handled by UI, but server can also push activation response
|
||||||
if msg.StudentName != "" {
|
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)
|
saveActivation(dataDir, act)
|
||||||
log.Printf("Activated as %s", msg.StudentName)
|
log.Printf("Activated as %s", msg.StudentName)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,14 +9,15 @@ import { DeleteClassDialog } from "./DeleteClassDialog";
|
|||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
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);
|
const session = await getServerSession(authOptions);
|
||||||
if (!session?.user) redirect("/login");
|
if (!session?.user) redirect("/login");
|
||||||
if (!session.user.establishmentId && session.user.role !== "superadmin") redirect("/dashboard");
|
if (!session.user.establishmentId && session.user.role !== "superadmin") redirect("/dashboard");
|
||||||
|
|
||||||
const establishmentId = session.user.establishmentId;
|
const establishmentId = session.user.establishmentId;
|
||||||
const cls = await prisma.class.findFirst({
|
const cls = await prisma.class.findFirst({
|
||||||
where: { id: params.id, ...(establishmentId ? { establishmentId } : {}) },
|
where: { id, ...(establishmentId ? { establishmentId } : {}) },
|
||||||
include: { students: true },
|
include: { students: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ async function createClass(formData: FormData) {
|
|||||||
"use server";
|
"use server";
|
||||||
const session = await getServerSession(authOptions);
|
const session = await getServerSession(authOptions);
|
||||||
if (!session?.user) redirect("/login");
|
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));
|
const parsed = schema.safeParse(Object.fromEntries(formData));
|
||||||
if (!parsed.success) return;
|
if (!parsed.success) return;
|
||||||
@@ -27,7 +27,7 @@ async function createClass(formData: FormData) {
|
|||||||
data: {
|
data: {
|
||||||
name: parsed.data.name,
|
name: parsed.data.name,
|
||||||
level: parsed.data.level,
|
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() {
|
export default async function NewClassPage() {
|
||||||
const session = await getServerSession(authOptions);
|
const session = await getServerSession(authOptions);
|
||||||
if (!session?.user) redirect("/login");
|
if (!session?.user) redirect("/login");
|
||||||
if (!session.user.establishmentId && session.user.role !== "superadmin") redirect("/dashboard");
|
if (!session.user.establishmentId) redirect("/dashboard");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 max-w-xl">
|
<div className="space-y-6 max-w-xl">
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export default function DownloadPage() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="text-sm text-muted-foreground mb-4">Agent EduBox pour Windows (64 bits)</p>
|
<p className="text-sm text-muted-foreground mb-4">Agent EduBox pour Windows (64 bits)</p>
|
||||||
<a href="/agent/edubox-agent.exe" download className="inline-flex items-center justify-center rounded-md text-sm font-medium bg-primary text-primary-foreground hover:bg-primary/90 h-10 px-4 py-2 w-full">Télécharger (.exe)</a>
|
<a href="/edubox-agent.exe" download className="inline-flex items-center justify-center rounded-md text-sm font-medium bg-primary text-primary-foreground hover:bg-primary/90 h-10 px-4 py-2 w-full">Télécharger (.exe)</a>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
@@ -23,7 +23,7 @@ export default function DownloadPage() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="text-sm text-muted-foreground mb-4">Agent EduBox pour Linux (64 bits)</p>
|
<p className="text-sm text-muted-foreground mb-4">Agent EduBox pour Linux (64 bits)</p>
|
||||||
<a href="/agent/edubox-agent" download className="inline-flex items-center justify-center rounded-md text-sm font-medium bg-primary text-primary-foreground hover:bg-primary/90 h-10 px-4 py-2 w-full">Télécharger</a>
|
<a href="/edubox-agent" download className="inline-flex items-center justify-center rounded-md text-sm font-medium bg-primary text-primary-foreground hover:bg-primary/90 h-10 px-4 py-2 w-full">Télécharger</a>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
@@ -32,7 +32,7 @@ export default function DownloadPage() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="text-sm text-muted-foreground mb-4">Agent EduBox pour macOS (Intel & Apple Silicon)</p>
|
<p className="text-sm text-muted-foreground mb-4">Agent EduBox pour macOS (Intel & Apple Silicon)</p>
|
||||||
<a href="/agent/edubox-agent-mac" download className="inline-flex items-center justify-center rounded-md text-sm font-medium bg-primary text-primary-foreground hover:bg-primary/90 h-10 px-4 py-2 w-full">Télécharger</a>
|
<a href="/edubox-agent-mac" download className="inline-flex items-center justify-center rounded-md text-sm font-medium bg-primary text-primary-foreground hover:bg-primary/90 h-10 px-4 py-2 w-full">Télécharger</a>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ import { generateActivationCodeAction } from "./actions";
|
|||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
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);
|
const session = await getServerSession(authOptions);
|
||||||
if (!session?.user) redirect("/login");
|
if (!session?.user) redirect("/login");
|
||||||
if (!session.user.establishmentId && session.user.role !== "superadmin") redirect("/dashboard");
|
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 establishmentId = session.user.establishmentId;
|
||||||
const student = await prisma.student.findFirst({
|
const student = await prisma.student.findFirst({
|
||||||
where: {
|
where: {
|
||||||
id: params.id,
|
id,
|
||||||
class: establishmentId ? { establishmentId } : undefined,
|
class: establishmentId ? { establishmentId } : undefined,
|
||||||
},
|
},
|
||||||
include: { class: true },
|
include: { class: true },
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { prisma } from "@/lib/prisma";
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { hashPassword } from "@/lib/auth";
|
||||||
|
|
||||||
const updateSchema = z.object({
|
const updateSchema = z.object({
|
||||||
plan: z.enum(["trial", "starter", "standard", "premium"]),
|
plan: z.enum(["trial", "starter", "standard", "premium"]),
|
||||||
@@ -36,3 +37,42 @@ export async function deleteEstablishment(establishmentId: string) {
|
|||||||
});
|
});
|
||||||
redirect("/superadmin/establishments");
|
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}`);
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,13 +6,16 @@ import { notFound } from "next/navigation";
|
|||||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Select } from "@/components/ui/select";
|
import { Select } from "@/components/ui/select";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
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";
|
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({
|
const establishment = await prisma.establishment.findUnique({
|
||||||
where: { id: params.id },
|
where: { id },
|
||||||
include: {
|
include: {
|
||||||
subscription: true,
|
subscription: true,
|
||||||
_count: { select: { users: true, classes: true } },
|
_count: { select: { users: true, classes: true } },
|
||||||
@@ -21,7 +24,13 @@ export default async function EstablishmentDetailPage({ params }: { params: { id
|
|||||||
|
|
||||||
if (!establishment) notFound();
|
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 boundDelete = deleteEstablishment.bind(null, establishment.id);
|
||||||
|
const boundCreateAdmin = createAdmin.bind(null, establishment.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -107,6 +116,57 @@ export default async function EstablishmentDetailPage({ params }: { params: { id
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Administrateurs</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<form action={boundCreateAdmin} className="grid gap-4 md:grid-cols-3 items-end">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="email" className="text-sm font-medium">Email</label>
|
||||||
|
<Input id="email" name="email" type="email" required />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="password" className="text-sm font-medium">Mot de passe</label>
|
||||||
|
<Input id="password" name="password" type="password" required minLength={8} />
|
||||||
|
</div>
|
||||||
|
<Button type="submit">Créer un admin</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Email</TableHead>
|
||||||
|
<TableHead>Rôle</TableHead>
|
||||||
|
<TableHead>Créé le</TableHead>
|
||||||
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{admins.map((user) => (
|
||||||
|
<TableRow key={user.id}>
|
||||||
|
<TableCell className="font-medium">{user.email}</TableCell>
|
||||||
|
<TableCell><Badge variant="secondary">{user.role}</Badge></TableCell>
|
||||||
|
<TableCell>{new Date(user.createdAt).toLocaleDateString("fr-FR")}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<form action={deleteAdmin.bind(null, establishment.id, user.id)}>
|
||||||
|
<Button type="submit" variant="destructive" size="sm">Supprimer</Button>
|
||||||
|
</form>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
{admins.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={4} className="text-center text-muted-foreground">
|
||||||
|
Aucun administrateur
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export function initWebSocketServer(wss: WebSocketServer) {
|
|||||||
update: { studentId: student.id, status: "online", lastSeen: new Date() },
|
update: { studentId: student.id, status: "online", lastSeen: new Date() },
|
||||||
create: { id: nodeId, 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user