From 3c620b9b670b4b6b80fee8b35d02393edd8205f0 Mon Sep 17 00:00:00 2001 From: PxlLoewe <72106766+PxlLoewe@users.noreply.github.com> Date: Wed, 4 Jun 2025 17:27:58 -0700 Subject: [PATCH] Completed Admin Users form --- .../modules/socketJWTmiddleware.ts | 3 +- apps/dispatch-server/package.json | 1 + apps/dispatch-server/routes/aircraft.ts | 61 ++- apps/dispatch-server/routes/dispatcher.ts | 62 ++- .../app/_components/QueryProvider.tsx | 15 +- .../_components/customToasts/AdminMessage.tsx | 34 ++ .../customToasts/HPGnotification.tsx | 4 +- .../{ => customToasts}/StationStatusToast.tsx | 0 .../app/_components/navbar/AdminPanel.tsx | 437 ++++++++++-------- .../app/_components/navbar/Settings.tsx | 6 +- .../app/_helpers/LivekitRoomManager.ts | 9 + apps/dispatch/app/_querys/aircrafts.ts | 11 + apps/dispatch/app/_querys/connected-user.ts | 11 + apps/dispatch/app/_querys/livekit.ts | 28 ++ apps/dispatch/app/_querys/user.ts | 2 +- .../app/api/livekit-participant/route.ts | 85 ++++ apps/dispatch/app/api/livekit-token/route.ts | 5 +- .../dispatch/_components/navbar/Navbar.tsx | 18 +- package.json | 1 + packages/database/prisma/json/SocketEvents.ts | 16 +- packages/database/prisma/schema/user.prisma | 1 + pnpm-lock.yaml | 17 +- 22 files changed, 592 insertions(+), 235 deletions(-) create mode 100644 apps/dispatch/app/_components/customToasts/AdminMessage.tsx rename apps/dispatch/app/_components/{ => customToasts}/StationStatusToast.tsx (100%) create mode 100644 apps/dispatch/app/_helpers/LivekitRoomManager.ts create mode 100644 apps/dispatch/app/_querys/livekit.ts create mode 100644 apps/dispatch/app/api/livekit-participant/route.ts diff --git a/apps/dispatch-server/modules/socketJWTmiddleware.ts b/apps/dispatch-server/modules/socketJWTmiddleware.ts index 6c556a58..252f55fe 100644 --- a/apps/dispatch-server/modules/socketJWTmiddleware.ts +++ b/apps/dispatch-server/modules/socketJWTmiddleware.ts @@ -1,6 +1,7 @@ import { ExtendedError, Server, Socket } from "socket.io"; 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) => { try { diff --git a/apps/dispatch-server/package.json b/apps/dispatch-server/package.json index c659b110..35e57464 100644 --- a/apps/dispatch-server/package.json +++ b/apps/dispatch-server/package.json @@ -24,6 +24,7 @@ "@react-email/components": "^0.0.41", "@redis/json": "^5.1.1", "@socket.io/redis-adapter": "^8.3.0", + "@types/jsonwebtoken": "^9.0.9", "axios": "^1.9.0", "cookie-parser": "^1.4.7", "cors": "^2.8.5", diff --git a/apps/dispatch-server/routes/aircraft.ts b/apps/dispatch-server/routes/aircraft.ts index 576d3e5f..7c1522b2 100644 --- a/apps/dispatch-server/routes/aircraft.ts +++ b/apps/dispatch-server/routes/aircraft.ts @@ -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 { 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 if (Object.keys(aircraftUpdate).length === 1 && aircraftUpdate.esimatedLogoutTime) return; io.to("dispatchers").emit("update-connectedAircraft", updatedConnectedAircraft); + io.to(`user:${updatedConnectedAircraft.userId}`).emit( "aircraft-update", 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) => { 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 { - await prisma.connectedAircraft.delete({ + const aircraft = await prisma.connectedAircraft.update({ 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(); } catch (error) { console.error(error); - res.status(500).json({ error: "Failed to delete connectedAircraft" }); + res.status(500).json({ error: "Failed to disconnect pilot" }); } }); diff --git a/apps/dispatch-server/routes/dispatcher.ts b/apps/dispatch-server/routes/dispatcher.ts index a3c79915..61f85e29 100644 --- a/apps/dispatch-server/routes/dispatcher.ts +++ b/apps/dispatch-server/routes/dispatcher.ts @@ -1,5 +1,6 @@ -import { Prisma, prisma } from "@repo/db"; +import { AdminMessage, getPublicUser, Prisma, prisma } from "@repo/db"; import { Router } from "express"; +import { io } from "index"; import { pubClient } from "modules/redis"; const router: Router = Router(); @@ -28,4 +29,63 @@ router.patch("/:id", async (req, res) => { 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; diff --git a/apps/dispatch/app/_components/QueryProvider.tsx b/apps/dispatch/app/_components/QueryProvider.tsx index d0ec5293..39eacfcb 100644 --- a/apps/dispatch/app/_components/QueryProvider.tsx +++ b/apps/dispatch/app/_components/QueryProvider.tsx @@ -8,6 +8,8 @@ import { dispatchSocket } from "dispatch/socket"; import { Mission, NotificationPayload } from "@repo/db"; import { HPGnotificationToast } from "_components/customToasts/HPGnotification"; import { useMapStore } from "_store/mapStore"; +import { AdminMessageToast } from "_components/customToasts/AdminMessage"; +import { pilotSocket } from "pilot/socket"; export function QueryProvider({ children }: { children: ReactNode }) { const mapStore = useMapStore((s) => s); @@ -39,6 +41,9 @@ export function QueryProvider({ children }: { children: ReactNode }) { queryClient.invalidateQueries({ queryKey: ["aircrafts"], }); + queryClient.invalidateQueries({ + queryKey: ["dispatchers"], + }); }; const invalidateConenctedAircrafts = () => { @@ -58,13 +63,18 @@ export function QueryProvider({ children }: { children: ReactNode }) { toast.custom( (t) => , { - duration: 9999, + duration: 99999, }, ); + break; + case "admin-message": + toast.custom((t) => , { + duration: 999999, + }); break; default: - toast(notification.message); + toast("unbekanntes Notification-Event"); break; } }; @@ -76,6 +86,7 @@ export function QueryProvider({ children }: { children: ReactNode }) { dispatchSocket.on("pilots-update", invalidateConnectedUsers); dispatchSocket.on("update-connectedAircraft", invalidateConenctedAircrafts); dispatchSocket.on("notification", handleNotification); + pilotSocket.on("notification", handleNotification); return () => { dispatchSocket.off("update-mission", invalidateMission); diff --git a/apps/dispatch/app/_components/customToasts/AdminMessage.tsx b/apps/dispatch/app/_components/customToasts/AdminMessage.tsx new file mode 100644 index 00000000..79e88b38 --- /dev/null +++ b/apps/dispatch/app/_components/customToasts/AdminMessage.tsx @@ -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 ( + } className="flex flex-row"> +
+

+ Du wurdes durch den Admin {event.data?.admin.publicId}{" "} + {event.status == "ban" ? "gebannt" : "gekickt"}! +

+

{event.message}

+
+
+ +
+
+ ); +}; diff --git a/apps/dispatch/app/_components/customToasts/HPGnotification.tsx b/apps/dispatch/app/_components/customToasts/HPGnotification.tsx index b566c0ca..5437598b 100644 --- a/apps/dispatch/app/_components/customToasts/HPGnotification.tsx +++ b/apps/dispatch/app/_components/customToasts/HPGnotification.tsx @@ -1,4 +1,4 @@ -import { NotificationPayload } from "@repo/db"; +import { NotificationPayload, ValidationFailed, ValidationSuccess } from "@repo/db"; import { BaseNotification } from "_components/customToasts/BaseNotification"; import { MapStore, useMapStore } from "_store/mapStore"; import { Check, Cross } from "lucide-react"; @@ -9,7 +9,7 @@ export const HPGnotificationToast = ({ t, mapStore, }: { - event: NotificationPayload; + event: ValidationFailed | ValidationSuccess; t: Toast; mapStore: MapStore; }) => { diff --git a/apps/dispatch/app/_components/StationStatusToast.tsx b/apps/dispatch/app/_components/customToasts/StationStatusToast.tsx similarity index 100% rename from apps/dispatch/app/_components/StationStatusToast.tsx rename to apps/dispatch/app/_components/customToasts/StationStatusToast.tsx diff --git a/apps/dispatch/app/_components/navbar/AdminPanel.tsx b/apps/dispatch/app/_components/navbar/AdminPanel.tsx index cf3f7d7b..62218809 100644 --- a/apps/dispatch/app/_components/navbar/AdminPanel.tsx +++ b/apps/dispatch/app/_components/navbar/AdminPanel.tsx @@ -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 { - ArrowLeftRight, Eye, LockKeyhole, Plane, RedoDot, Shield, ShieldAlert, + Speaker, User, UserCheck, Workflow, } from "lucide-react"; -import Link from "next/link"; -import { usePathname } from "next/navigation"; import { useRef } from "react"; +import toast from "react-hot-toast"; 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(null); return ( @@ -29,7 +110,7 @@ export default function AdminPanel() { > Admin Panel - +
@@ -50,203 +131,171 @@ export default function AdminPanel() { Name Station Voice - Dispatch Actions - - VAR0124 - Max Mustermann - Christoph 31 - - Nicht verbunden - - - Verbunden - - - - - - - - - VAR0124 - Max Mustermann - Christoph 31 - - Nicht verbunden - - - Verbunden - - - - - - - - - VAR0124 - Max Mustermann - Christoph 31 - - Nicht verbunden - - - Verbunden - - - - - - - - - VAR0124 - Max Mustermann - Christoph 31 - - Nicht verbunden - - - Verbunden - - - - - - - - - VAR0124 - Max Mustermann - Christoph 31 - - Nicht verbunden - - - Verbunden - - - - - - - - - VAR0124 - Max Mustermann - Christoph 31 - - Nicht verbunden - - - Verbunden - - - - - - - + {pilots?.map((p) => { + const publicUser = p.publicUser as unknown as PublicUser; + const livekitParticipant = participants.find( + (p) => p.participant.identity === publicUser.publicId, + ); + return ( + + + {publicUser.publicId} + + {publicUser.fullName} + {p.Station.bosCallsign} + + {!livekitParticipant ? ( + Nicht verbunden + ) : ( + {livekitParticipant.room} + )} + + + + + + + + + + ); + })} + {dispatcher?.map((d) => { + const publicUser = d.publicUser as unknown as PublicUser; + const livekitParticipant = participants.find( + (p) => p.participant.identity === publicUser.publicId, + ); + return ( + + + {publicUser.publicId} + + {publicUser.fullName} + {d.zone} + + {!livekitParticipant ? ( + Nicht verbunden + ) : ( + {livekitParticipant.room} + )} + + + + + + + + + + ); + })} + {livekitUserNotConnected.map((p) => { + const publicUser = JSON.parse( + p.participant.attributes.publicUser || "{}", + ) as PublicUser; + return ( + + + {p.participant.identity} + + {publicUser?.fullName} + + Nicht verbunden + + + {p.room} + + + + + + + + + + ); + })}
-
+ {/*
Allgemeine Befehle @@ -266,7 +315,7 @@ export default function AdminPanel() {
-
+
*/} diff --git a/apps/dispatch/app/_components/navbar/Settings.tsx b/apps/dispatch/app/_components/navbar/Settings.tsx index df26b2f1..f8a4ec57 100644 --- a/apps/dispatch/app/_components/navbar/Settings.tsx +++ b/apps/dispatch/app/_components/navbar/Settings.tsx @@ -19,8 +19,7 @@ export const SettingsBtn = () => { const testSoundRef = useRef(null); const editUserMutation = useMutation({ - mutationFn: ({ user }: { user: Prisma.UserUpdateInput }) => - editUserAPI(session.data!.user.id, user), + mutationFn: editUserAPI, }); useEffect(() => { @@ -201,7 +200,8 @@ export const SettingsBtn = () => { onSubmit={() => false} onClick={async () => { testSoundRef.current?.pause(); - const res = await editUserMutation.mutateAsync({ + await editUserMutation.mutateAsync({ + id: session.data!.user.id, user: { settingsMicDevice: selectedDevice, settingsMicVolume: micVol, diff --git a/apps/dispatch/app/_helpers/LivekitRoomManager.ts b/apps/dispatch/app/_helpers/LivekitRoomManager.ts new file mode 100644 index 00000000..96890f7a --- /dev/null +++ b/apps/dispatch/app/_helpers/LivekitRoomManager.ts @@ -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, +); diff --git a/apps/dispatch/app/_querys/aircrafts.ts b/apps/dispatch/app/_querys/aircrafts.ts index fad9d727..bb89a9a7 100644 --- a/apps/dispatch/app/_querys/aircrafts.ts +++ b/apps/dispatch/app/_querys/aircrafts.ts @@ -27,3 +27,14 @@ export const getConnectedAircraftPositionLogAPI = async ({ id }: { id: number }) } 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; +}; diff --git a/apps/dispatch/app/_querys/connected-user.ts b/apps/dispatch/app/_querys/connected-user.ts index b3d86ab7..7380be9c 100644 --- a/apps/dispatch/app/_querys/connected-user.ts +++ b/apps/dispatch/app/_querys/connected-user.ts @@ -35,3 +35,14 @@ export const getConnectedDispatcherAPI = async (filter?: Prisma.ConnectedDispatc } 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; +}; diff --git a/apps/dispatch/app/_querys/livekit.ts b/apps/dispatch/app/_querys/livekit.ts new file mode 100644 index 00000000..031b7d12 --- /dev/null +++ b/apps/dispatch/app/_querys/livekit.ts @@ -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; +}; diff --git a/apps/dispatch/app/_querys/user.ts b/apps/dispatch/app/_querys/user.ts index 6e4cb8cf..55524cab 100644 --- a/apps/dispatch/app/_querys/user.ts +++ b/apps/dispatch/app/_querys/user.ts @@ -1,7 +1,7 @@ import { Prisma, User } from "@repo/db"; 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(`/api/user?id=${id}`, user); return response.data; }; diff --git a/apps/dispatch/app/api/livekit-participant/route.ts b/apps/dispatch/app/api/livekit-participant/route.ts new file mode 100644 index 00000000..e337f025 --- /dev/null +++ b/apps/dispatch/app/api/livekit-participant/route.ts @@ -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 }); + } +}; diff --git a/apps/dispatch/app/api/livekit-token/route.ts b/apps/dispatch/app/api/livekit-token/route.ts index 2f3e72b2..23e5219f 100644 --- a/apps/dispatch/app/api/livekit-token/route.ts +++ b/apps/dispatch/app/api/livekit-token/route.ts @@ -27,8 +27,7 @@ export const GET = async (request: NextRequest) => { const at = new AccessToken(process.env.LIVEKIT_API_KEY, process.env.LIVEKIT_API_SECRET, { identity: user.publicId, - // Token to expire after 10 minutes - ttl: "1d", + ttl: "1h", }); at.addGrant({ @@ -41,6 +40,8 @@ export const GET = async (request: NextRequest) => { at.attributes = { publicId: user.publicId, + publicUser: JSON.stringify(getPublicUser(user)), + userId: user.id, }; const token = await at.toJwt(); diff --git a/apps/dispatch/app/dispatch/_components/navbar/Navbar.tsx b/apps/dispatch/app/dispatch/_components/navbar/Navbar.tsx index cf7cad7d..9568f968 100644 --- a/apps/dispatch/app/dispatch/_components/navbar/Navbar.tsx +++ b/apps/dispatch/app/dispatch/_components/navbar/Navbar.tsx @@ -1,5 +1,3 @@ -"use client"; - import { Connection } from "./_components/Connection"; /* import { ThemeSwap } from "./_components/ThemeSwap"; */ import { Audio } from "../../../_components/Audio/Audio"; @@ -9,24 +7,16 @@ import Link from "next/link"; import { Settings } from "_components/navbar/Settings"; import ModeSwitchDropdown from "_components/navbar/ModeSwitchDropdown"; import AdminPanel from "_components/navbar/AdminPanel"; +import { getServerSession } from "api/auth/[...nextauth]/auth"; -export default function Navbar() { - /* const [isDark, setIsDark] = useState(false); - - const toggleTheme = () => { - const newTheme = !isDark; - setIsDark(newTheme); - document.documentElement.setAttribute( - "data-theme", - newTheme ? "nord" : "dark", - ); - }; */ +export default async function Navbar() { + const session = await getServerSession(); return (
- + {session?.user.permissions.includes("ADMIN_KICK") && }
diff --git a/package.json b/package.json index 2b147137..95163b5d 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "migrate": "turbo migrate", "lint": "turbo lint", "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}\"" }, "devDependencies": { diff --git a/packages/database/prisma/json/SocketEvents.ts b/packages/database/prisma/json/SocketEvents.ts index a2eb854f..161162c8 100644 --- a/packages/database/prisma/json/SocketEvents.ts +++ b/packages/database/prisma/json/SocketEvents.ts @@ -1,6 +1,7 @@ import { Mission } from "../../generated/client"; +import { PublicUser } from "./User"; -interface ValidationFailed { +export interface ValidationFailed { type: "hpg-validation"; status: "failed"; message: string; @@ -9,7 +10,7 @@ interface ValidationFailed { }; } -interface ValidationSuccess { +export interface ValidationSuccess { type: "hpg-validation"; status: "success"; 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; diff --git a/packages/database/prisma/schema/user.prisma b/packages/database/prisma/schema/user.prisma index 84e99a33..c68be9b0 100644 --- a/packages/database/prisma/schema/user.prisma +++ b/packages/database/prisma/schema/user.prisma @@ -15,6 +15,7 @@ enum PERMISSION { ADMIN_STATION ADMIN_KEYWORD ADMIN_MESSAGE + ADMIN_KICK AUDIO PILOT DISPO diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5df1c930..5b01b4f5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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)) '@next-auth/prisma-adapter': 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': specifier: ^1.3.2 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) next-auth: 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: specifier: ^11.4.1 version: 11.4.1 @@ -164,6 +164,9 @@ importers: '@socket.io/redis-adapter': specifier: ^8.3.0 version: 8.3.0(socket.io-adapter@2.5.5) + '@types/jsonwebtoken': + specifier: ^9.0.9 + version: 9.0.9 axios: specifier: ^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)) '@next-auth/prisma-adapter': 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': specifier: ^1.3.2 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) next-auth: 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: specifier: ^1.0.12 version: 1.0.12(webpack@5.99.9) @@ -5164,10 +5167,10 @@ snapshots: '@tybys/wasm-util': 0.9.0 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: '@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': {} @@ -8046,7 +8049,7 @@ snapshots: 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: '@babel/runtime': 7.27.4 '@panva/hkdf': 1.2.1