Completed Admin Users form

This commit is contained in:
PxlLoewe
2025-06-04 17:27:58 -07:00
parent 7aceae7c17
commit 3c620b9b67
22 changed files with 592 additions and 235 deletions

View File

@@ -1,6 +1,7 @@
import { ExtendedError, Server, Socket } from "socket.io"; import { ExtendedError, Server, Socket } from "socket.io";
import { prisma } from "@repo/db"; import { prisma } from "@repo/db";
if (!process.env.DISPATCH_APP_TOKEN) throw new Error("DISPATCH_APP_TOKEN is not defined"); import jwt from "jsonwebtoken";
/* if (!process.env.DISPATCH_APP_TOKEN) throw new Error("DISPATCH_APP_TOKEN is not defined"); */
export const jwtMiddleware = async (socket: Socket, next: (err?: ExtendedError) => void) => { export const jwtMiddleware = async (socket: Socket, next: (err?: ExtendedError) => void) => {
try { try {

View File

@@ -24,6 +24,7 @@
"@react-email/components": "^0.0.41", "@react-email/components": "^0.0.41",
"@redis/json": "^5.1.1", "@redis/json": "^5.1.1",
"@socket.io/redis-adapter": "^8.3.0", "@socket.io/redis-adapter": "^8.3.0",
"@types/jsonwebtoken": "^9.0.9",
"axios": "^1.9.0", "axios": "^1.9.0",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"cors": "^2.8.5", "cors": "^2.8.5",

View File

@@ -1,4 +1,11 @@
import { ConnectedAircraft, getPublicUser, MissionLog, Prisma, prisma } from "@repo/db"; import {
AdminMessage,
ConnectedAircraft,
getPublicUser,
MissionLog,
Prisma,
prisma,
} from "@repo/db";
import { Router } from "express"; import { Router } from "express";
import { io } from "../index"; import { io } from "../index";
@@ -95,6 +102,7 @@ router.patch("/:id", async (req, res) => {
// When change is only the estimated logout time, we don't need to emit an event // When change is only the estimated logout time, we don't need to emit an event
if (Object.keys(aircraftUpdate).length === 1 && aircraftUpdate.esimatedLogoutTime) return; if (Object.keys(aircraftUpdate).length === 1 && aircraftUpdate.esimatedLogoutTime) return;
io.to("dispatchers").emit("update-connectedAircraft", updatedConnectedAircraft); io.to("dispatchers").emit("update-connectedAircraft", updatedConnectedAircraft);
io.to(`user:${updatedConnectedAircraft.userId}`).emit( io.to(`user:${updatedConnectedAircraft.userId}`).emit(
"aircraft-update", "aircraft-update",
updatedConnectedAircraft, updatedConnectedAircraft,
@@ -105,18 +113,61 @@ router.patch("/:id", async (req, res) => {
} }
}); });
// Delete a connectedAircraft by ID // Kick a connectedAircraft by ID
router.delete("/:id", async (req, res) => { router.delete("/:id", async (req, res) => {
const { id } = req.params; const { id } = req.params;
const bann = req.body?.bann as boolean;
const requiredPermission = bann ? "ADMIN_USER" : "ADMIN_KICK";
if (!req.user) {
res.status(401).json({ error: "Unauthorized" });
return;
}
if (!req.user.permissions.includes(requiredPermission)) {
res.status(403).json({ error: "Forbidden" });
return;
}
try { try {
await prisma.connectedAircraft.delete({ const aircraft = await prisma.connectedAircraft.update({
where: { id: Number(id) }, where: { id: Number(id) },
data: { logoutTime: new Date() },
include: bann ? { User: true } : undefined,
}); });
io.to("dispatchers").emit("delete-connectedAircraft", id);
if (!aircraft) {
res.status(404).json({ error: "ConnectedAircraft not found" });
return;
}
const status = bann ? "ban" : "kick";
io.to(`user:${aircraft.userId}`).emit("notification", {
type: "admin-message",
message: "Verbindung durch einen Administrator getrennt",
status,
data: { admin: getPublicUser(req.user) },
} as AdminMessage);
io.in(`user:${aircraft.userId}`).disconnectSockets(true);
if (bann) {
await prisma.user.update({
where: { id: aircraft.userId },
data: {
permissions: {
set: req.user.permissions.filter((p) => p !== "PILOT"),
},
},
});
}
res.status(204).send(); res.status(204).send();
} catch (error) { } catch (error) {
console.error(error); console.error(error);
res.status(500).json({ error: "Failed to delete connectedAircraft" }); res.status(500).json({ error: "Failed to disconnect pilot" });
} }
}); });

View File

@@ -1,5 +1,6 @@
import { Prisma, prisma } from "@repo/db"; import { AdminMessage, getPublicUser, Prisma, prisma } from "@repo/db";
import { Router } from "express"; import { Router } from "express";
import { io } from "index";
import { pubClient } from "modules/redis"; import { pubClient } from "modules/redis";
const router: Router = Router(); const router: Router = Router();
@@ -28,4 +29,63 @@ router.patch("/:id", async (req, res) => {
res.json(newDispatcher); res.json(newDispatcher);
}); });
import { Request, Response } from "express";
router.delete("/:id", async (req, res) => {
const { id } = req.params;
const bann = req.body?.bann as boolean;
const requiredPermission = bann ? "ADMIN_USER" : "ADMIN_KICK";
if (!req.user) {
res.status(401).json({ error: "Unauthorized" });
return;
}
if (!req.user.permissions.includes(requiredPermission)) {
res.status(403).json({ error: "Forbidden" });
return;
}
try {
const dispatcher = await prisma.connectedDispatcher.update({
where: { id: Number(id) },
data: { logoutTime: new Date() },
include: bann ? { user: true } : undefined,
});
if (!dispatcher) {
res.status(404).json({ error: "ConnectedDispatcher not found" });
return;
}
const status = bann ? "ban" : "kick";
io.to(`user:${dispatcher.userId}`).emit("notification", {
type: "admin-message",
message: "Verbindung durch einen Administrator getrennt",
status,
data: { admin: getPublicUser(req.user) },
} as AdminMessage);
io.in(`user:${dispatcher.userId}`).disconnectSockets(true);
if (bann) {
await prisma.user.update({
where: { id: dispatcher.userId },
data: {
permissions: {
set: req.user.permissions.filter((p) => p !== "DISPO"),
},
},
});
}
res.status(204).send();
} catch (error) {
console.error(error);
res.status(500).json({ error: "Failed to disconnect dispatcher" });
}
});
export default router; export default router;

View File

@@ -8,6 +8,8 @@ import { dispatchSocket } from "dispatch/socket";
import { Mission, NotificationPayload } from "@repo/db"; import { Mission, NotificationPayload } from "@repo/db";
import { HPGnotificationToast } from "_components/customToasts/HPGnotification"; import { HPGnotificationToast } from "_components/customToasts/HPGnotification";
import { useMapStore } from "_store/mapStore"; import { useMapStore } from "_store/mapStore";
import { AdminMessageToast } from "_components/customToasts/AdminMessage";
import { pilotSocket } from "pilot/socket";
export function QueryProvider({ children }: { children: ReactNode }) { export function QueryProvider({ children }: { children: ReactNode }) {
const mapStore = useMapStore((s) => s); const mapStore = useMapStore((s) => s);
@@ -39,6 +41,9 @@ export function QueryProvider({ children }: { children: ReactNode }) {
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ["aircrafts"], queryKey: ["aircrafts"],
}); });
queryClient.invalidateQueries({
queryKey: ["dispatchers"],
});
}; };
const invalidateConenctedAircrafts = () => { const invalidateConenctedAircrafts = () => {
@@ -58,13 +63,18 @@ export function QueryProvider({ children }: { children: ReactNode }) {
toast.custom( toast.custom(
(t) => <HPGnotificationToast event={notification} mapStore={mapStore} t={t} />, (t) => <HPGnotificationToast event={notification} mapStore={mapStore} t={t} />,
{ {
duration: 9999, duration: 99999,
}, },
); );
break;
case "admin-message":
toast.custom((t) => <AdminMessageToast event={notification} t={t} />, {
duration: 999999,
});
break; break;
default: default:
toast(notification.message); toast("unbekanntes Notification-Event");
break; break;
} }
}; };
@@ -76,6 +86,7 @@ export function QueryProvider({ children }: { children: ReactNode }) {
dispatchSocket.on("pilots-update", invalidateConnectedUsers); dispatchSocket.on("pilots-update", invalidateConnectedUsers);
dispatchSocket.on("update-connectedAircraft", invalidateConenctedAircrafts); dispatchSocket.on("update-connectedAircraft", invalidateConenctedAircrafts);
dispatchSocket.on("notification", handleNotification); dispatchSocket.on("notification", handleNotification);
pilotSocket.on("notification", handleNotification);
return () => { return () => {
dispatchSocket.off("update-mission", invalidateMission); dispatchSocket.off("update-mission", invalidateMission);

View File

@@ -0,0 +1,34 @@
import { AdminMessage } from "@repo/db";
import { BaseNotification } from "_components/customToasts/BaseNotification";
import { cn } from "_helpers/cn";
import { TriangleAlert } from "lucide-react";
import toast, { Toast } from "react-hot-toast";
export const AdminMessageToast = ({ event, t }: { event: AdminMessage; t: Toast }) => {
const handleClick = () => {
toast.dismiss(t.id);
};
return (
<BaseNotification icon={<TriangleAlert />} className="flex flex-row">
<div className="flex-1">
<h1
className={cn(
"font-bold",
event.status == "ban" && "text-red-500 ",
event.status == "kick" && "text-yellow-500 ",
)}
>
Du wurdes durch den Admin {event.data?.admin.publicId}{" "}
{event.status == "ban" ? "gebannt" : "gekickt"}!
</h1>
<p>{event.message}</p>
</div>
<div className="ml-11">
<button className="btn" onClick={handleClick}>
OK
</button>
</div>
</BaseNotification>
);
};

View File

@@ -1,4 +1,4 @@
import { NotificationPayload } from "@repo/db"; import { NotificationPayload, ValidationFailed, ValidationSuccess } from "@repo/db";
import { BaseNotification } from "_components/customToasts/BaseNotification"; import { BaseNotification } from "_components/customToasts/BaseNotification";
import { MapStore, useMapStore } from "_store/mapStore"; import { MapStore, useMapStore } from "_store/mapStore";
import { Check, Cross } from "lucide-react"; import { Check, Cross } from "lucide-react";
@@ -9,7 +9,7 @@ export const HPGnotificationToast = ({
t, t,
mapStore, mapStore,
}: { }: {
event: NotificationPayload; event: ValidationFailed | ValidationSuccess;
t: Toast; t: Toast;
mapStore: MapStore; mapStore: MapStore;
}) => { }) => {

View File

@@ -1,21 +1,102 @@
"use client";
import { PublicUser } from "@repo/db";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { getConnectedAircraftsAPI, kickAircraftAPI } from "_querys/aircrafts";
import { getConnectedDispatcherAPI, kickDispatcherAPI } from "_querys/connected-user";
import { getLivekitRooms, kickLivekitParticipant } from "_querys/livekit";
import { editUserAPI } from "_querys/user";
import { ParticipantInfo } from "livekit-server-sdk";
import { import {
ArrowLeftRight,
Eye, Eye,
LockKeyhole, LockKeyhole,
Plane, Plane,
RedoDot, RedoDot,
Shield, Shield,
ShieldAlert, ShieldAlert,
Speaker,
User, User,
UserCheck, UserCheck,
Workflow, Workflow,
} from "lucide-react"; } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useRef } from "react"; import { useRef } from "react";
import toast from "react-hot-toast";
export default function AdminPanel() { export default function AdminPanel() {
const path = usePathname(); const queryClient = useQueryClient();
const { data: pilots } = useQuery({
queryKey: ["pilots"],
queryFn: () => getConnectedAircraftsAPI(),
refetchInterval: 10000,
});
const { data: dispatcher } = useQuery({
queryKey: ["dispatcher"],
queryFn: () => getConnectedDispatcherAPI(),
refetchInterval: 10000,
});
const { data: livekitRooms } = useQuery({
queryKey: ["connected-audio-users"],
queryFn: () => getLivekitRooms(),
refetchInterval: 10000,
});
const kickLivekitParticipantMutation = useMutation({
mutationFn: kickLivekitParticipant,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["connected-audio-users"] });
},
});
const editUSerMutation = useMutation({
mutationFn: editUserAPI,
});
const kickPilotMutation = useMutation({
mutationFn: kickAircraftAPI,
onSuccess: () => {
toast.success("Pilot wurde erfolgreich gekickt");
queryClient.invalidateQueries({
queryKey: ["aircrafts"],
});
queryClient.invalidateQueries({
queryKey: ["connected-audio-users"],
});
},
});
const kickDispatchMutation = useMutation({
mutationFn: kickDispatcherAPI,
onSuccess: () => {
toast.success("Disponent wurde erfolgreich gekickt");
queryClient.invalidateQueries({
queryKey: ["dispatcher"],
});
queryClient.invalidateQueries({
queryKey: ["connected-audio-users"],
});
},
});
const participants: { participant: ParticipantInfo; room: string }[] = [];
if (livekitRooms) {
livekitRooms?.forEach((room) => {
room.participants.forEach((participant) => {
participants.push({
participant,
room: room.room.name,
});
});
});
}
const livekitUserNotConnected = participants.filter((p) => {
const pilot = pilots?.find(
(d) => (d.publicUser as unknown as PublicUser).publicId === p.participant.identity,
);
const fDispatcher = dispatcher?.find(
(d) => (d.publicUser as unknown as PublicUser).publicId === p.participant.identity,
);
return !pilot && !fDispatcher;
});
console.log("Livekit Rooms", livekitRooms);
const modalRef = useRef<HTMLDialogElement>(null); const modalRef = useRef<HTMLDialogElement>(null);
return ( return (
@@ -29,7 +110,7 @@ export default function AdminPanel() {
> >
<Shield size={18} /> Admin Panel <Shield size={18} /> Admin Panel
</button> </button>
<dialog ref={modalRef} className="modal"> <dialog ref={modalRef} className="modal min-w-[500px]">
<div className="modal-box w-11/12 max-w-7xl"> <div className="modal-box w-11/12 max-w-7xl">
<form method="dialog"> <form method="dialog">
<button className="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"></button> <button className="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"></button>
@@ -50,203 +131,171 @@ export default function AdminPanel() {
<th>Name</th> <th>Name</th>
<th>Station</th> <th>Station</th>
<th>Voice</th> <th>Voice</th>
<th>Dispatch</th>
<th>Actions</th> <th>Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr> {pilots?.map((p) => {
<td>VAR0124</td> const publicUser = p.publicUser as unknown as PublicUser;
<td>Max Mustermann</td> const livekitParticipant = participants.find(
<td>Christoph 31</td> (p) => p.participant.identity === publicUser.publicId,
<td className="text-error"> );
<span>Nicht verbunden</span> return (
</td> <tr key={p.id}>
<td className="text-success"> <td className="flex items-center gap-2">
<span>Verbunden</span> <Plane /> {publicUser.publicId}
</td> </td>
<td className="flex gap-2"> <td>{publicUser.fullName}</td>
<button <td>{p.Station.bosCallsign}</td>
className="btn btn-xs btn-square btn-warning btn-soft tooltip tooltip-bottom tooltip-warning" <td>
data-tip="Kick" {!livekitParticipant ? (
> <span className="text-error">Nicht verbunden</span>
<RedoDot size={15} /> ) : (
</button> <span className="text-success">{livekitParticipant.room}</span>
<button )}
className="btn btn-xs btn-square btn-error btn-soft tooltip tooltip-bottom tooltip-error" </td>
data-tip="Ban" <td className="flex gap-2">
> <button
<LockKeyhole size={15} /> className="btn btn-xs btn-square btn-warning btn-soft tooltip tooltip-bottom tooltip-warning"
</button> data-tip="Kick"
<button onClick={() => kickPilotMutation.mutate({ id: p.id })}
className="btn btn-xs btn-square btn-info btn-soft tooltip tooltip-bottom tooltip-info" >
data-tip="Profil" <RedoDot size={15} />
> </button>
<User size={15} /> <button
</button> className="btn btn-xs btn-square btn-error btn-soft tooltip tooltip-bottom tooltip-error"
</td> data-tip="Ban"
</tr> onClick={() => {
<tr> kickPilotMutation.mutate({ id: p.id, bann: true });
<td>VAR0124</td> }}
<td>Max Mustermann</td> >
<td>Christoph 31</td> <LockKeyhole size={15} />
<td className="text-error"> </button>
<span>Nicht verbunden</span> <a
</td> href={`${process.env.NEXT_PUBLIC_HUB_URL}/admin/user/${p.userId}`}
<td className="text-success"> target="_blank"
<span>Verbunden</span> rel="noopener noreferrer"
</td> >
<td className="flex gap-2"> <button
<button className="btn btn-xs btn-square btn-info btn-soft tooltip tooltip-bottom tooltip-info"
className="btn btn-xs btn-square btn-warning btn-soft tooltip tooltip-bottom tooltip-warning" data-tip="Profil"
data-tip="Kick" >
> <User size={15} />
<RedoDot size={15} /> </button>
</button> </a>
<button </td>
className="btn btn-xs btn-square btn-error btn-soft tooltip tooltip-bottom tooltip-error" </tr>
data-tip="Ban" );
> })}
<LockKeyhole size={15} /> {dispatcher?.map((d) => {
</button> const publicUser = d.publicUser as unknown as PublicUser;
<button const livekitParticipant = participants.find(
className="btn btn-xs btn-square btn-info btn-soft tooltip tooltip-bottom tooltip-info" (p) => p.participant.identity === publicUser.publicId,
data-tip="Profil" );
> return (
<User size={15} /> <tr key={d.id}>
</button> <td className="flex items-center gap-2">
</td> <Workflow /> {publicUser.publicId}
</tr> </td>
<tr> <td>{publicUser.fullName}</td>
<td>VAR0124</td> <td>{d.zone}</td>
<td>Max Mustermann</td> <td>
<td>Christoph 31</td> {!livekitParticipant ? (
<td className="text-error"> <span className="text-error">Nicht verbunden</span>
<span>Nicht verbunden</span> ) : (
</td> <span className="text-success">{livekitParticipant.room}</span>
<td className="text-success"> )}
<span>Verbunden</span> </td>
</td> <td className="flex gap-2">
<td className="flex gap-2"> <button
<button className="btn btn-xs btn-square btn-warning btn-soft tooltip tooltip-bottom tooltip-warning"
className="btn btn-xs btn-square btn-warning btn-soft tooltip tooltip-bottom tooltip-warning" data-tip="Kick"
data-tip="Kick" onClick={() => kickDispatchMutation.mutate({ id: d.id })}
> >
<RedoDot size={15} /> <RedoDot size={15} />
</button> </button>
<button <button
className="btn btn-xs btn-square btn-error btn-soft tooltip tooltip-bottom tooltip-error" className="btn btn-xs btn-square btn-error btn-soft tooltip tooltip-bottom tooltip-error"
data-tip="Ban" data-tip="Ban"
> onClick={() => {
<LockKeyhole size={15} /> kickDispatchMutation.mutate({ id: d.id, bann: true });
</button> }}
<button >
className="btn btn-xs btn-square btn-info btn-soft tooltip tooltip-bottom tooltip-info" <LockKeyhole size={15} />
data-tip="Profil" </button>
> <a
<User size={15} /> href={`${process.env.NEXT_PUBLIC_HUB_URL}/admin/user/${d.userId}`}
</button> target="_blank"
</td> rel="noopener noreferrer"
</tr> >
<tr> <button
<td>VAR0124</td> className="btn btn-xs btn-square btn-info btn-soft tooltip tooltip-bottom tooltip-info"
<td>Max Mustermann</td> data-tip="Profil"
<td>Christoph 31</td> >
<td className="text-error"> <User size={15} />
<span>Nicht verbunden</span> </button>
</td> </a>
<td className="text-success"> </td>
<span>Verbunden</span> </tr>
</td> );
<td className="flex gap-2"> })}
<button {livekitUserNotConnected.map((p) => {
className="btn btn-xs btn-square btn-warning btn-soft tooltip tooltip-bottom tooltip-warning" const publicUser = JSON.parse(
data-tip="Kick" p.participant.attributes.publicUser || "{}",
> ) as PublicUser;
<RedoDot size={15} /> return (
</button> <tr key={p.participant.identity}>
<button <td className="flex items-center gap-2">
className="btn btn-xs btn-square btn-error btn-soft tooltip tooltip-bottom tooltip-error" <Speaker /> {p.participant.identity}
data-tip="Ban" </td>
> <td>{publicUser?.fullName}</td>
<LockKeyhole size={15} /> <td>
</button> <span className="text-error">Nicht verbunden</span>
<button </td>
className="btn btn-xs btn-square btn-info btn-soft tooltip tooltip-bottom tooltip-info" <td>
data-tip="Profil" <span className="text-success">{p.room}</span>
> </td>
<User size={15} /> <td className="flex gap-2">
</button> <button
</td> className="btn btn-xs btn-square btn-warning btn-soft tooltip tooltip-bottom tooltip-warning"
</tr> data-tip="Kick"
<tr> onClick={() =>
<td>VAR0124</td> kickLivekitParticipantMutation.mutate({
<td>Max Mustermann</td> roomName: p.room,
<td>Christoph 31</td> identity: p.participant.identity,
<td className="text-error"> })
<span>Nicht verbunden</span> }
</td> >
<td className="text-success"> <RedoDot size={15} />
<span>Verbunden</span> </button>
</td> <button
<td className="flex gap-2"> className="btn btn-xs btn-square btn-error btn-soft tooltip tooltip-bottom tooltip-error"
<button data-tip="Ban"
className="btn btn-xs btn-square btn-warning btn-soft tooltip tooltip-bottom tooltip-warning" >
data-tip="Kick" <LockKeyhole size={15} />
> </button>
<RedoDot size={15} /> <a
</button> href={`${process.env.NEXT_PUBLIC_HUB_URL}/admin/user/${p.participant.attributes.userId}`}
<button target="_blank"
className="btn btn-xs btn-square btn-error btn-soft tooltip tooltip-bottom tooltip-error" rel="noopener noreferrer"
data-tip="Ban" >
> <button
<LockKeyhole size={15} /> className="btn btn-xs btn-square btn-info btn-soft tooltip tooltip-bottom tooltip-info"
</button> data-tip="Profil"
<button >
className="btn btn-xs btn-square btn-info btn-soft tooltip tooltip-bottom tooltip-info" <User size={15} />
data-tip="Profil" </button>
> </a>
<User size={15} /> </td>
</button> </tr>
</td> );
</tr> })}
<tr>
<td>VAR0124</td>
<td>Max Mustermann</td>
<td>Christoph 31</td>
<td className="text-error">
<span>Nicht verbunden</span>
</td>
<td className="text-success">
<span>Verbunden</span>
</td>
<td className="flex gap-2">
<button
className="btn btn-xs btn-square btn-warning btn-soft tooltip tooltip-bottom tooltip-warning"
data-tip="Kick"
>
<RedoDot size={15} />
</button>
<button
className="btn btn-xs btn-square btn-error btn-soft tooltip tooltip-bottom tooltip-error"
data-tip="Ban"
>
<LockKeyhole size={15} />
</button>
<button
className="btn btn-xs btn-square btn-info btn-soft tooltip tooltip-bottom tooltip-info"
data-tip="Profil"
>
<User size={15} />
</button>
</td>
</tr>
</tbody> </tbody>
</table> </table>
</div> </div>
</div> </div>
</div> </div>
<div className="card bg-base-300 shadow-md w-full mt-4 max-h-48 overflow-y-auto"> {/* <div className="card bg-base-300 shadow-md w-full mt-4 max-h-48 overflow-y-auto">
<div className="card-body"> <div className="card-body">
<div className="card-title flex items-center gap-2"> <div className="card-title flex items-center gap-2">
<ShieldAlert size={20} /> Allgemeine Befehle <ShieldAlert size={20} /> Allgemeine Befehle
@@ -266,7 +315,7 @@ export default function AdminPanel() {
</button> </button>
</div> </div>
</div> </div>
</div> </div> */}
</div> </div>
<form method="dialog" className="modal-backdrop"> <form method="dialog" className="modal-backdrop">
<button>close</button> <button>close</button>

View File

@@ -19,8 +19,7 @@ export const SettingsBtn = () => {
const testSoundRef = useRef<HTMLAudioElement | null>(null); const testSoundRef = useRef<HTMLAudioElement | null>(null);
const editUserMutation = useMutation({ const editUserMutation = useMutation({
mutationFn: ({ user }: { user: Prisma.UserUpdateInput }) => mutationFn: editUserAPI,
editUserAPI(session.data!.user.id, user),
}); });
useEffect(() => { useEffect(() => {
@@ -201,7 +200,8 @@ export const SettingsBtn = () => {
onSubmit={() => false} onSubmit={() => false}
onClick={async () => { onClick={async () => {
testSoundRef.current?.pause(); testSoundRef.current?.pause();
const res = await editUserMutation.mutateAsync({ await editUserMutation.mutateAsync({
id: session.data!.user.id,
user: { user: {
settingsMicDevice: selectedDevice, settingsMicDevice: selectedDevice,
settingsMicVolume: micVol, settingsMicVolume: micVol,

View File

@@ -0,0 +1,9 @@
import { RoomServiceClient } from "livekit-server-sdk";
if (!process.env.NEXT_PUBLIC_LIVEKIT_URL) throw new Error("NEXT_PUBLIC_LIVEKIT_URL is not defined");
export const RoomManager = new RoomServiceClient(
process.env.NEXT_PUBLIC_LIVEKIT_URL!,
process.env.LIVEKIT_API_KEY,
process.env.LIVEKIT_API_SECRET,
);

View File

@@ -27,3 +27,14 @@ export const getConnectedAircraftPositionLogAPI = async ({ id }: { id: number })
} }
return res.data; return res.data;
}; };
export const kickAircraftAPI = async ({ id, bann }: { id: number; bann?: boolean }) => {
const res = await serverApi.delete(`/aircrafts/${id}`, {
data: { bann },
});
console.log(res.status);
if (res.status != 204) {
throw new Error("Failed to kick aircraft");
}
return res.data;
};

View File

@@ -35,3 +35,14 @@ export const getConnectedDispatcherAPI = async (filter?: Prisma.ConnectedDispatc
} }
return res.data; return res.data;
}; };
export const kickDispatcherAPI = async ({ id, bann }: { id: number; bann?: boolean }) => {
const res = await serverApi.delete(`/dispatcher/${id}`, {
data: { bann },
});
console.log(res.status);
if (res.status != 204) {
throw new Error("Failed to kick aircraft");
}
return res.data;
};

View File

@@ -0,0 +1,28 @@
import axios from "axios";
import { Room } from "livekit-client";
import { ParticipantInfo } from "livekit-server-sdk";
export const getLivekitRooms = async () => {
const res = await axios.get<
{
room: Room;
participants: ParticipantInfo[];
}[]
>("/api/livekit-participant");
if (res.status !== 200) {
throw new Error("Failed to fetch keywords");
}
return res.data;
};
export const kickLivekitParticipant = async (body: { identity: string; roomName: string }) => {
const res = await axios.delete("/api/livekit-participant", {
params: body,
});
if (res.status !== 200) {
throw new Error("Failed to kick participant");
}
return res.data;
};

View File

@@ -1,7 +1,7 @@
import { Prisma, User } from "@repo/db"; import { Prisma, User } from "@repo/db";
import axios from "axios"; import axios from "axios";
export const editUserAPI = async (id: string, user: Prisma.UserUpdateInput) => { export const editUserAPI = async ({ id, user }: { id: string; user: Prisma.UserUpdateInput }) => {
const response = await axios.post<User>(`/api/user?id=${id}`, user); const response = await axios.post<User>(`/api/user?id=${id}`, user);
return response.data; return response.data;
}; };

View File

@@ -0,0 +1,85 @@
import { prisma } from "@repo/db";
import { RoomManager } from "_helpers/LivekitRoomManager";
import { getServerSession } from "api/auth/[...nextauth]/auth";
import { NextRequest } from "next/server";
export const GET = async (request: NextRequest) => {
const session = await getServerSession();
if (!session) return Response.json({ message: "Unauthorized" }, { status: 401 });
const user = await prisma.user.findUnique({
where: {
id: session.user.id,
},
});
if (!user || !user.permissions.includes("AUDIO_ADMIN"))
return Response.json({ message: "Missing permissions" }, { status: 401 });
const rooms = await RoomManager.listRooms();
const roomsWithParticipants = rooms.map(async (room) => {
const participants = await RoomManager.listParticipants(room.name);
return {
room,
participants,
};
});
return Response.json(await Promise.all(roomsWithParticipants), { status: 200 });
};
export const DELETE = async (request: NextRequest) => {
try {
const identity = request.nextUrl.searchParams.get("identity");
const roomName = request.nextUrl.searchParams.get("roomName");
const ban = request.nextUrl.searchParams.get("ban");
if (!identity) return Response.json({ message: "Missing User identity" }, { status: 400 });
if (!roomName) return Response.json({ message: "Missing roomName" }, { status: 400 });
const session = await getServerSession();
if (!session) return Response.json({ message: "Unauthorized" }, { status: 401 });
const user = await prisma.user.findUnique({
where: {
id: session.user.id,
},
});
if (!user || !user.permissions.includes("AUDIO_ADMIN"))
return Response.json({ message: "Missing permissions" }, { status: 401 });
if (ban && !user.permissions.includes("ADMIN_USER")) {
return Response.json({ message: "Missing permissions to ban user" }, { status: 401 });
}
if (ban) {
const participant = await RoomManager.getParticipant(roomName, identity);
const pUser = await prisma.user.findUnique({
where: {
id: participant.attributes.userId,
},
});
if (!pUser) return;
// If the user is banned, we need to remove their permissions
await prisma.user.update({
where: { id: session.user.id },
data: {
permissions: {
set: pUser.permissions.filter((p) => p !== "AUDIO"),
},
},
});
}
await RoomManager.removeParticipant(roomName, identity);
return Response.json(
{ message: `User ${identity} kicked from room ${roomName}` },
{ status: 200 },
);
} catch (error) {
console.error("Error in DELETE /api/livekit-participant:", error);
return Response.json({ message: "Internal Server Error" }, { status: 500 });
}
};

View File

@@ -27,8 +27,7 @@ export const GET = async (request: NextRequest) => {
const at = new AccessToken(process.env.LIVEKIT_API_KEY, process.env.LIVEKIT_API_SECRET, { const at = new AccessToken(process.env.LIVEKIT_API_KEY, process.env.LIVEKIT_API_SECRET, {
identity: user.publicId, identity: user.publicId,
// Token to expire after 10 minutes ttl: "1h",
ttl: "1d",
}); });
at.addGrant({ at.addGrant({
@@ -41,6 +40,8 @@ export const GET = async (request: NextRequest) => {
at.attributes = { at.attributes = {
publicId: user.publicId, publicId: user.publicId,
publicUser: JSON.stringify(getPublicUser(user)),
userId: user.id,
}; };
const token = await at.toJwt(); const token = await at.toJwt();

View File

@@ -1,5 +1,3 @@
"use client";
import { Connection } from "./_components/Connection"; import { Connection } from "./_components/Connection";
/* import { ThemeSwap } from "./_components/ThemeSwap"; */ /* import { ThemeSwap } from "./_components/ThemeSwap"; */
import { Audio } from "../../../_components/Audio/Audio"; import { Audio } from "../../../_components/Audio/Audio";
@@ -9,24 +7,16 @@ import Link from "next/link";
import { Settings } from "_components/navbar/Settings"; import { Settings } from "_components/navbar/Settings";
import ModeSwitchDropdown from "_components/navbar/ModeSwitchDropdown"; import ModeSwitchDropdown from "_components/navbar/ModeSwitchDropdown";
import AdminPanel from "_components/navbar/AdminPanel"; import AdminPanel from "_components/navbar/AdminPanel";
import { getServerSession } from "api/auth/[...nextauth]/auth";
export default function Navbar() { export default async function Navbar() {
/* const [isDark, setIsDark] = useState(false); const session = await getServerSession();
const toggleTheme = () => {
const newTheme = !isDark;
setIsDark(newTheme);
document.documentElement.setAttribute(
"data-theme",
newTheme ? "nord" : "dark",
);
}; */
return ( return (
<div className="navbar bg-base-100 shadow-sm flex gap-5 justify-between"> <div className="navbar bg-base-100 shadow-sm flex gap-5 justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ModeSwitchDropdown /> <ModeSwitchDropdown />
<AdminPanel /> {session?.user.permissions.includes("ADMIN_KICK") && <AdminPanel />}
</div> </div>
<div className="flex items-center gap-5"> <div className="flex items-center gap-5">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">

View File

@@ -7,6 +7,7 @@
"migrate": "turbo migrate", "migrate": "turbo migrate",
"lint": "turbo lint", "lint": "turbo lint",
"studio": "turbo run studio", "studio": "turbo run studio",
"prod-start": "docker-compose --env-file .env.prod -f 'docker-compose.prod.yml' up -d --build",
"format": "prettier --write \"**/*.{ts,tsx,md}\"" "format": "prettier --write \"**/*.{ts,tsx,md}\""
}, },
"devDependencies": { "devDependencies": {

View File

@@ -1,6 +1,7 @@
import { Mission } from "../../generated/client"; import { Mission } from "../../generated/client";
import { PublicUser } from "./User";
interface ValidationFailed { export interface ValidationFailed {
type: "hpg-validation"; type: "hpg-validation";
status: "failed"; status: "failed";
message: string; message: string;
@@ -9,7 +10,7 @@ interface ValidationFailed {
}; };
} }
interface ValidationSuccess { export interface ValidationSuccess {
type: "hpg-validation"; type: "hpg-validation";
status: "success"; status: "success";
message: string; message: string;
@@ -18,4 +19,13 @@ interface ValidationSuccess {
}; };
} }
export type NotificationPayload = ValidationFailed | ValidationSuccess; export interface AdminMessage {
type: "admin-message";
status: "kick" | "ban";
message: string;
data?: {
admin: PublicUser;
};
}
export type NotificationPayload = ValidationFailed | ValidationSuccess | AdminMessage;

View File

@@ -15,6 +15,7 @@ enum PERMISSION {
ADMIN_STATION ADMIN_STATION
ADMIN_KEYWORD ADMIN_KEYWORD
ADMIN_MESSAGE ADMIN_MESSAGE
ADMIN_KICK
AUDIO AUDIO
PILOT PILOT
DISPO DISPO

17
pnpm-lock.yaml generated
View File

@@ -34,7 +34,7 @@ importers:
version: 0.5.7(@types/dom-mediacapture-transform@0.1.11)(livekit-client@2.13.3(@types/dom-mediacapture-record@1.0.22)) version: 0.5.7(@types/dom-mediacapture-transform@0.1.11)(livekit-client@2.13.3(@types/dom-mediacapture-record@1.0.22))
'@next-auth/prisma-adapter': '@next-auth/prisma-adapter':
specifier: ^1.0.7 specifier: ^1.0.7
version: 1.0.7(@prisma/client@6.8.2(prisma@6.8.2(typescript@5.8.3))(typescript@5.8.3))(next-auth@4.24.11(next@15.3.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)) version: 1.0.7(@prisma/client@6.8.2(prisma@6.8.2(typescript@5.8.3))(typescript@5.8.3))(next-auth@4.24.11(next@15.3.3(@babel/core@7.27.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0))
'@radix-ui/react-icons': '@radix-ui/react-icons':
specifier: ^1.3.2 specifier: ^1.3.2
version: 1.3.2(react@19.1.0) version: 1.3.2(react@19.1.0)
@@ -103,7 +103,7 @@ importers:
version: 15.3.3(@babel/core@7.27.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) version: 15.3.3(@babel/core@7.27.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
next-auth: next-auth:
specifier: ^4.24.11 specifier: ^4.24.11
version: 4.24.11(next@15.3.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0) version: 4.24.11(next@15.3.3(@babel/core@7.27.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
npm: npm:
specifier: ^11.4.1 specifier: ^11.4.1
version: 11.4.1 version: 11.4.1
@@ -164,6 +164,9 @@ importers:
'@socket.io/redis-adapter': '@socket.io/redis-adapter':
specifier: ^8.3.0 specifier: ^8.3.0
version: 8.3.0(socket.io-adapter@2.5.5) version: 8.3.0(socket.io-adapter@2.5.5)
'@types/jsonwebtoken':
specifier: ^9.0.9
version: 9.0.9
axios: axios:
specifier: ^1.9.0 specifier: ^1.9.0
version: 1.9.0 version: 1.9.0
@@ -248,7 +251,7 @@ importers:
version: 5.0.1(react-hook-form@7.57.0(react@19.1.0)) version: 5.0.1(react-hook-form@7.57.0(react@19.1.0))
'@next-auth/prisma-adapter': '@next-auth/prisma-adapter':
specifier: ^1.0.7 specifier: ^1.0.7
version: 1.0.7(@prisma/client@6.8.2(prisma@6.8.2(typescript@5.8.3))(typescript@5.8.3))(next-auth@4.24.11(next@15.3.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)) version: 1.0.7(@prisma/client@6.8.2(prisma@6.8.2(typescript@5.8.3))(typescript@5.8.3))(next-auth@4.24.11(next@15.3.3(@babel/core@7.27.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0))
'@radix-ui/react-icons': '@radix-ui/react-icons':
specifier: ^1.3.2 specifier: ^1.3.2
version: 1.3.2(react@19.1.0) version: 1.3.2(react@19.1.0)
@@ -326,7 +329,7 @@ importers:
version: 15.3.3(@babel/core@7.27.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) version: 15.3.3(@babel/core@7.27.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
next-auth: next-auth:
specifier: ^4.24.11 specifier: ^4.24.11
version: 4.24.11(next@15.3.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0) version: 4.24.11(next@15.3.3(@babel/core@7.27.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
next-remove-imports: next-remove-imports:
specifier: ^1.0.12 specifier: ^1.0.12
version: 1.0.12(webpack@5.99.9) version: 1.0.12(webpack@5.99.9)
@@ -5164,10 +5167,10 @@ snapshots:
'@tybys/wasm-util': 0.9.0 '@tybys/wasm-util': 0.9.0
optional: true optional: true
'@next-auth/prisma-adapter@1.0.7(@prisma/client@6.8.2(prisma@6.8.2(typescript@5.8.3))(typescript@5.8.3))(next-auth@4.24.11(next@15.3.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0))': '@next-auth/prisma-adapter@1.0.7(@prisma/client@6.8.2(prisma@6.8.2(typescript@5.8.3))(typescript@5.8.3))(next-auth@4.24.11(next@15.3.3(@babel/core@7.27.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0))':
dependencies: dependencies:
'@prisma/client': 6.8.2(prisma@6.8.2(typescript@5.8.3))(typescript@5.8.3) '@prisma/client': 6.8.2(prisma@6.8.2(typescript@5.8.3))(typescript@5.8.3)
next-auth: 4.24.11(next@15.3.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0) next-auth: 4.24.11(next@15.3.3(@babel/core@7.27.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@next/env@15.3.3': {} '@next/env@15.3.3': {}
@@ -8046,7 +8049,7 @@ snapshots:
neo-async@2.6.2: {} neo-async@2.6.2: {}
next-auth@4.24.11(next@15.3.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0): next-auth@4.24.11(next@15.3.3(@babel/core@7.27.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
dependencies: dependencies:
'@babel/runtime': 7.27.4 '@babel/runtime': 7.27.4
'@panva/hkdf': 1.2.1 '@panva/hkdf': 1.2.1