Merge pull request #165 from VAR-Virtual-Air-Rescue/staging

This commit was merged in pull request #165.
This commit is contained in:
PxlLoewe
2026-02-08 23:46:22 +01:00
committed by GitHub
20 changed files with 249 additions and 95 deletions

View File

@@ -3,6 +3,7 @@ import { io } from "index";
import cron from "node-cron";
import { setUserStandardNamePermissions } from "routes/helper";
import { changeMemberRoles } from "routes/member";
import client from "./discord";
const removeMission = async (id: number, reason: string) => {
const log: MissionLog = {
@@ -121,6 +122,37 @@ const removeClosedMissions = async () => {
return removeMission(mission.id, "dem freimelden aller Stationen");
});
};
const syncDiscordImgUrls = async () => {
try {
const discordAccounts = await prisma.discordAccount.findMany({
where: {
updatedAt: {
lt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000),
},
User: {
isNot: null,
},
},
});
for (const account of discordAccounts) {
client.users.fetch(account.discordId).then((discordUser) => {
const nextAvatar = discordUser?.avatar ?? null;
if (typeof nextAvatar !== "string") return;
if (nextAvatar === account.avatar) return;
prisma.discordAccount.update({
where: {
id: account.id,
},
data: {
avatar: nextAvatar,
},
});
});
}
} catch (error) {}
};
const removeConnectedAircrafts = async () => {
const connectedAircrafts = await prisma.connectedAircraft.findMany({
where: {
@@ -216,7 +248,13 @@ const removePermissionsForBannedUsers = async () => {
}
};
removePermissionsForBannedUsers();
cron.schedule("0 0 * * *", async () => {
try {
await syncDiscordImgUrls();
} catch (error) {
console.error("Error on daily cron job:", error);
}
});
cron.schedule("*/1 * * * *", async () => {
try {

View File

@@ -45,7 +45,7 @@ router.post("/rename", async (req: Request, res: Response) => {
console.log(`Member ${member.id} renamed to ${newName}`);
res.status(200).json({ message: "Member renamed successfully" });
} catch (error) {
console.error("Error renaming member:", error);
console.error("Error renaming member:", (error as Error).message);
res.status(500).json({ error: "Failed to rename member" });
}
});
@@ -84,7 +84,7 @@ const handleRoleChange = (action: "add" | "remove") => async (req: Request, res:
const result = await changeMemberRoles(memberId, roleIds, action);
res.status(200).json(result);
} catch (error) {
console.error(`Error ${action}ing roles:`, error);
console.error(`Error ${action}ing roles:`, (error as Error).message);
res.status(500).json({ error: `Failed to ${action} roles` });
}
};

View File

@@ -43,14 +43,20 @@ router.post("/admin-embed", async (req, res) => {
{ name: "angemeldet als", value: report.reportedUserRole, inline: true },
{
name: "gemeldet von",
value: `${report.Sender?.firstname} ${report.Sender?.lastname} (${report.Sender?.publicId})`,
value: report.Sender
? `${report.Sender?.firstname} ${report.Sender?.lastname} (${report.Sender?.publicId})`
: "System",
},
)
.setFooter({
text: "Bitte reagiere mit 🫡, wenn du den Report bearbeitet hast, oder mit ✅, wenn er abgeschlossen ist.",
})
.setTimestamp(new Date(report.timestamp))
.setColor("DarkRed");
.setTimestamp(new Date(report.timestamp));
if (report.reviewed) {
embed.setColor("DarkGreen");
} else {
embed.setColor("DarkRed");
}
const reportsChannel = await client.channels.fetch(process.env.DISCORD_REPORT_CHANNEL!);
if (!reportsChannel || !reportsChannel.isSendable()) {
@@ -59,7 +65,9 @@ router.post("/admin-embed", async (req, res) => {
}
const message = await reportsChannel.send({ embeds: [embed] });
message.react("🫡").catch(console.error);
if (!report.reviewed) {
message.react("✅").catch(console.error);
}
res.json({
message: "Report embed sent to Discord channel",
});

View File

@@ -1,4 +1,4 @@
import axios from "axios";
import axios, { AxiosError } from "axios";
const discordAxiosClient = axios.create({
baseURL: process.env.CORE_SERVER_URL,
@@ -11,7 +11,10 @@ export const renameMember = async (memberId: string, newName: string) => {
newName,
})
.catch((error) => {
console.error("Error renaming member:", error);
console.error(
"Error renaming member:",
(error as AxiosError<{ error: string }>).response?.data.error || error.message,
);
});
};
@@ -22,7 +25,10 @@ export const addRolesToMember = async (memberId: string, roleIds: string[]) => {
roleIds,
})
.catch((error) => {
console.error("Error adding roles to member:", error);
console.error(
"Error adding roles to member:",
(error as AxiosError<{ error: string }>).response?.data.error || error.message,
);
});
};
@@ -33,7 +39,10 @@ export const removeRolesFromMember = async (memberId: string, roleIds: string[])
roleIds,
})
.catch((error) => {
console.error("Error removing roles from member:", error);
console.error(
"Error removing roles from member:",
(error as AxiosError<{ error: string }>).response?.data.error || error.message,
);
});
};
@@ -43,6 +52,9 @@ export const sendReportEmbed = async (reportId: number) => {
reportId,
})
.catch((error) => {
console.error("Error removing roles from member:", error);
console.error(
"Error removing roles from member:",
(error as AxiosError<{ error: string }>).response?.data.error || error.message,
);
});
};

View File

@@ -154,6 +154,15 @@ router.post("/:id/send-sds-message", async (req, res) => {
},
});
const user = await prisma.user.findFirst({
where: { publicId: sdsMessage.data.user.publicId, firstname: sdsMessage.data.user.firstname },
});
if (!user) {
res.status(404).json({ error: "User not found" });
return;
}
io.to(
sdsMessage.data.direction === "to-lst" ? "dispatchers" : `station:${sdsMessage.data.stationId}`,
).emit(sdsMessage.data.direction === "to-lst" ? "notification" : "sds-status", {
@@ -163,6 +172,7 @@ router.post("/:id/send-sds-message", async (req, res) => {
data: {
aircraftId: parseInt(id),
stationId: sdsMessage.data.stationId,
userId: user.id,
},
} as NotificationPayload);

View File

@@ -3,8 +3,11 @@ import Link from "next/link";
import { prisma } from "@repo/db";
import { ChangelogWrapper } from "_components/navbar/ChangelogWrapper";
import ModeSwitchDropdown from "_components/navbar/ModeSwitchDropdown";
import { getServerSession } from "api/auth/[...nextauth]/auth";
import AdminPanel from "_components/navbar/AdminPanel";
export default async function Navbar({ children }: { children: React.ReactNode }) {
const session = await getServerSession();
const latestChangelog = await prisma.changelog.findFirst({
orderBy: {
createdAt: "desc",
@@ -17,6 +20,7 @@ export default async function Navbar({ children }: { children: React.ReactNode }
<p className="text-xl font-semibold normal-case">VAR Operations Center</p>
<ChangelogWrapper latestChangelog={latestChangelog} />
</div>
{session?.user.permissions.includes("ADMIN_KICK") && <AdminPanel />}
</div>
<div className="flex items-center gap-2">
{children}

View File

@@ -1,7 +1,7 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { GearIcon } from "@radix-ui/react-icons";
import { SettingsIcon, Volume2 } from "lucide-react";
import { Info, SettingsIcon, Volume2 } from "lucide-react";
import MicVolumeBar from "_components/MicVolumeIndication";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { editUserAPI, getUserAPI } from "_querys/user";
@@ -27,7 +27,6 @@ export const SettingsBtn = () => {
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["user", session.data?.user.id] });
},
});
useEffect(() => {
@@ -82,10 +81,14 @@ export const SettingsBtn = () => {
useEffect(() => {
const setDevices = async () => {
if (typeof navigator !== "undefined" && navigator.mediaDevices?.enumerateDevices) {
try {
const stream = await navigator.mediaDevices.getUserMedia({ video: false, audio: true });
const devices = await navigator.mediaDevices.enumerateDevices();
setInputDevices(devices.filter((d) => d.kind === "audioinput"));
stream.getTracks().forEach((track) => track.stop());
} catch (error) {
console.error("Error accessing media devices.", error);
}
}
};
@@ -214,7 +217,18 @@ export const SettingsBtn = () => {
setSettingsPartial({ useHPGAsDispatcher: e.target.checked });
}}
/>
HPG als Disponent verwenden
HPG Validierung verwenden{" "}
<div
className="tooltip tooltip-warning"
data-tip="Achtung! Mit der Client Version v2.0.1.0 werden Einsätze auch ohne Validierung an die
HPG gesendet. Die Validierung über die HPG kann zu Verzögerungen bei der
Einsatzübermittlung führen, insbesondere wenn das HPG script den Einsatz nicht sofort
validieren kann. Es wird empfohlen, diese Option nur zu aktivieren, wenn es Probleme
mit der Einsatzübermittlung gibt oder wenn die HPG Validierung ausdrücklich gewünscht
wird."
>
<Info className="text-error" size={16} />
</div>
</div>
<div className="modal-action flex justify-between">

View File

@@ -78,6 +78,7 @@ export const StatusToast = ({ event, t }: { event: StationStatus; t: Toast }) =>
});
},
});
console.log("Audio Room:", audioRoom, participants, livekitUser, event);
useEffect(() => {
let soundRef: React.RefObject<HTMLAudioElement | null> | null = null;
@@ -113,7 +114,6 @@ export const StatusToast = ({ event, t }: { event: StationStatus; t: Toast }) =>
};
}, [event.status, livekitUser?.roomName, audioRoom, t.id]);
console.log(connectedAircraft, station);
if (!connectedAircraft || !station || !session.data) return null;
return (
<BaseNotification>

View File

@@ -201,11 +201,23 @@ export const useAudioStore = create<TalkState>((set, get) => ({
});
}
const inputStream = await navigator.mediaDevices.getUserMedia({
let inputStream = await navigator.mediaDevices
.getUserMedia({
audio: {
deviceId: get().settings.micDeviceId ?? undefined,
},
})
.catch((e) => {
console.error("Konnte das Audio-Gerät nicht öffnen:", e);
return null;
});
if (!inputStream) {
inputStream = await navigator.mediaDevices.getUserMedia({ audio: true }).catch((e) => {
console.error("Konnte das Audio-Gerät nicht öffnen:", e);
return null;
});
}
if (!inputStream) throw new Error("Konnte das Audio-Gerät nicht öffnen");
// Funk-Effekt anwenden
const radioStream = getRadioStream(inputStream, get().settings.micVolume);
if (!radioStream) throw new Error("Konnte Funkstream nicht erzeugen");

View File

@@ -1,6 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -84,6 +84,7 @@ const updateParticipantMoodleResults = async () => {
};
CronJob.from({ cronTime: "0 * * * *", onTick: syncMoodleIds, start: true });
CronJob.from({
cronTime: "*/1 * * * *",
onTick: async () => {

View File

@@ -5,6 +5,7 @@ import { renderPasswordChanged } from "./mail-templates/PasswordChanged";
import { renderVerificationCode } from "./mail-templates/ConfirmEmail";
import { renderBannNotice } from "modules/mail-templates/Bann";
import { renderTimeBanNotice } from "modules/mail-templates/TimeBann";
import Mail from "nodemailer/lib/mailer";
let transporter: nodemailer.Transporter | null = null;
@@ -23,8 +24,9 @@ const initTransporter = () => {
pass: process.env.MAIL_PASSWORD,
},
});
transporter.on("error", (err) => {
console.error("Mail occurred:", err);
console.error("Mail error:", err);
});
transporter.on("idle", () => {
console.log("Mail Idle");

View File

@@ -4,6 +4,7 @@ import { editReport } from "(app)/admin/report/actions";
import { zodResolver } from "@hookform/resolvers/zod";
import { Report as IReport, Prisma, User } from "@repo/db";
import { ReportSchema, Report as IReportZod } from "@repo/db/zod";
import { cn } from "@repo/shared-components";
import { PaginatedTable } from "_components/PaginatedTable";
import { Button } from "_components/ui/Button";
import { Switch } from "_components/ui/Switch";
@@ -31,14 +32,23 @@ export const ReportSenderInfo = ({
</Link>
<span className="text-primary">{report.reportedUserRole}</span>
</h2>
<div className="textarea w-full text-left">{report.text}</div>
<div className="textarea w-full whitespace-pre-wrap text-left">{report.text}</div>
{Sender ? (
<Link
href={`/admin/user/${Sender?.id}`}
className="link link-hover text-right text-sm text-gray-600"
className={cn(
"link link-hover text-right text-sm text-gray-600",
!Sender && "text-green-300",
)}
>
gemeldet von Nutzer {Sender?.firstname} {Sender?.lastname} ({Sender?.publicId}) am{" "}
gemeldet von Nutzer {Sender.firstname} {Sender.lastname} ({Sender.publicId}) am{" "}
{new Date(report.timestamp).toLocaleString()}
</Link>
) : (
<p className="text-right text-sm text-green-300">
gemeldet vom System am {new Date(report.timestamp).toLocaleString()}
</p>
)}
</div>
);
};

View File

@@ -25,8 +25,12 @@ export const reportColumns: ColumnDef<Report & { Sender?: User; Reported: User }
header: "Sender",
cell: ({ row }) => {
const user = row.original.Sender;
if (!user) return "Unbekannt";
return `${user.firstname} ${user.lastname} (${user.publicId})`;
if (!user) return <p className="text-green-300">System</p>;
return (
<Link
href={`/admin/user/${user.id}`}
>{`${user.firstname} ${user.lastname} (${user.publicId})`}</Link>
);
},
},
{

View File

@@ -47,15 +47,23 @@ export const AccountLog = ({ sameIPLogs, userId }: { sameIPLogs: Log[]; userId:
</div>
}
getFilter={(searchTerm) => {
if (onlyImportant) {
return {
AND: [
onlyImportant
? {
type: {
in: ["REGISTER", "PROFILE_CHANGE"],
{ ip: { contains: searchTerm } },
{ browser: { contains: searchTerm } },
{
id: {
in: sameIPLogs
.filter((log) => log.id.toString().includes(searchTerm))
.map((log) => log.id),
},
}
: {},
},
],
} as Prisma.LogWhereInput;
} else {
return {
OR: [
{
AND: [
{ ip: { contains: searchTerm } },
@@ -69,8 +77,12 @@ export const AccountLog = ({ sameIPLogs, userId }: { sameIPLogs: Log[]; userId:
},
],
},
{
userId: userId,
},
],
} as Prisma.LogWhereInput;
}
}}
include={{
User: true,

View File

@@ -704,23 +704,15 @@ export const AdminForm = ({
<div role="alert" className="alert alert-warning alert-outline flex flex-col">
<div className="flex items-center gap-2">
<TriangleAlert />
{openBans.map((ban) => (
<div key={ban.id}>
<div>
<h3 className="text-lg font-semibold">Account gelöscht</h3>
</div>
))}
{openTimebans.map((timeban) => (
<div key={timeban.id}>
<div>
<h3 className="text-lg font-semibold">
Dieser Account ist als gelöscht markiert, der Nutzer kann sich nicht mehr
anmelden.
Dieser Account ist als gelöscht markiert, der Nutzer kann sich nicht mehr anmelden.
</h3>
</div>
))}
</div>
<p className="text-sm text-gray-400">
Achtung! Die Strafe(n) sind aktiv, die Rechte des Nutzers müssen nicht angepasst werden!
</p>
</div>
)}
{(user.CanonicalUser || (user.Duplicates && user.Duplicates.length > 0)) && (

View File

@@ -407,8 +407,11 @@ export const DeleteForm = ({
export const PasswordForm = (): React.JSX.Element => {
const schema = z.object({
password: z.string().min(2).max(30),
newPassword: z.string().min(2).max(30),
newPasswordConfirm: z.string().min(2).max(30),
newPassword: z
.string()
.min(8, { message: "Das Passwort muss mindestens 8 Zeichen lang sein" })
.max(30),
newPasswordConfirm: z.string().min(8).max(30),
});
const [isLoading, setIsLoading] = useState(false);
type IFormInput = z.infer<typeof schema>;

View File

@@ -55,6 +55,16 @@ export const logAction = async (
},
});
if (existingLogs.length > 0 && user?.user.id) {
const existingReport = await prisma.report.findFirst({
where: {
reportedUserId: user.user.id,
reportedUserRole: {
contains: "Doppelter Account Verdacht",
},
},
});
// keine Doppel-Reports für denselben Nutzer erstellen
if (!existingReport) {
// Möglicherweise ein doppelter Account, Report erstellen
const report = await prisma.report.create({
data: {
@@ -67,6 +77,23 @@ export const logAction = async (
});
await sendReportEmbed(report.id);
} else {
// Update report and send it again to Discord
const updatedReport = await prisma.report.update({
where: {
id: existingReport.id,
},
data: {
text: `Möglicher doppelter Account erkannt bei Login-Versuch.\n\nÜbereinstimmende Logs:\n${existingLogs
.map(
(log) =>
`- Log ID: ${log.id}, IP: ${log.ip}, Zeitstempel: ${new Date(log.timestamp).toLocaleString("de-DE")}`,
)
.join("\n")}`,
},
});
await sendReportEmbed(updatedReport.id);
}
}
}

View File

@@ -49,8 +49,8 @@ export const Register = () => {
.refine((val) => val.length === 0 || val.includes(" ") || /^[A-ZÄÖÜ]/.test(val), {
message: "Der Nachname muss mit einem Großbuchstaben beginnen",
}),
password: z.string().min(12, {
message: "Das Passwort muss mindestens 12 Zeichen lang sein",
password: z.string().min(8, {
message: "Das Passwort muss mindestens 8 Zeichen lang sein",
}),
passwordConfirm: z.string(),
})

View File

@@ -1,6 +1,6 @@
import axios from "axios";
import { NextRequest, NextResponse } from "next/server";
import { DiscordAccount, prisma } from "@repo/db";
import { prisma } from "@repo/db";
import { getServerSession } from "../auth/[...nextauth]/auth";
import { setStandardName } from "../../../helper/discord";
import { getUserPenaltys } from "@repo/shared-components";
@@ -52,8 +52,7 @@ export const GET = async (req: NextRequest) => {
},
});
const discordObject = {
userId: session.user.id,
const discordData = {
accessToken: authData.access_token,
refreshToken: authData.refresh_token,
discordId: discordUser.id,
@@ -63,12 +62,19 @@ export const GET = async (req: NextRequest) => {
globalName: discordUser.global_name || discordUser.username,
verified: discordUser.verified,
tokenType: authData.token_type,
} as DiscordAccount;
};
await prisma.discordAccount.upsert({
where: { discordId: discordUser.id },
update: discordObject, // Updates if found
create: discordObject, // Creates if not found
update: discordData, // Updates if found
create: {
...discordData,
User: {
connect: {
id: session.user.id,
},
},
}, // Creates if not found
});
const user = await prisma.user.findUnique({
where: { id: session.user.id },