From 434154e26d82020d6b89a6cff97b12fa34eb1bca Mon Sep 17 00:00:00 2001 From: PxlLoewe <72106766+PxlLoewe@users.noreply.github.com> Date: Mon, 15 Dec 2025 02:55:44 +0100 Subject: [PATCH 1/5] Security Fixes --- .../(app)/admin/user/[id]/_components/forms.tsx | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/apps/hub/app/(app)/admin/user/[id]/_components/forms.tsx b/apps/hub/app/(app)/admin/user/[id]/_components/forms.tsx index 8772a1a4..1ba128c8 100644 --- a/apps/hub/app/(app)/admin/user/[id]/_components/forms.tsx +++ b/apps/hub/app/(app)/admin/user/[id]/_components/forms.tsx @@ -76,6 +76,20 @@ export const ProfileForm: React.FC = ({ user }: ProfileFormPro className="card-body" onSubmit={form.handleSubmit(async (values) => { if (!values.id) return; + if (values.id === session.data?.user.id && values.permissions !== user.permissions){ + toast.error("Du kannst deine eigenen Berechtigungen nicht ändern."); + return; + } + if ( values.permissions?.some((perm) => !session.data?.user.permissions.includes(perm)) ){ + toast.error("Du kannst Berechtigungen nicht hinzufügen, die du selbst nicht besitzt."); + return; + } + const removedPermissions = user.permissions?.filter((perm) => !values.permissions?.includes(perm)) || []; + if ( removedPermissions.some((perm) => !session.data?.user.permissions.includes(perm)) ){ + toast.error("Du kannst Berechtigungen nicht entfernen, die du selbst nicht besitzt."); + return; + } + await editUser(values.id, { ...values, email: values.email.toLowerCase(), From 51ef9cd90cbfd50ba9f16088975768252a7fc621 Mon Sep 17 00:00:00 2001 From: PxlLoewe <72106766+PxlLoewe@users.noreply.github.com> Date: Mon, 15 Dec 2025 21:19:39 +0100 Subject: [PATCH 2/5] use fetch to get Aircraft Marker --- .../app/_components/map/AircraftMarker.tsx | 28 +++++++++++++++---- 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/apps/dispatch/app/_components/map/AircraftMarker.tsx b/apps/dispatch/app/_components/map/AircraftMarker.tsx index 6d635675..9c0adaf3 100644 --- a/apps/dispatch/app/_components/map/AircraftMarker.tsx +++ b/apps/dispatch/app/_components/map/AircraftMarker.tsx @@ -2,7 +2,7 @@ import { Marker, Polyline, useMap } from "react-leaflet"; import { DivIcon, Marker as LMarker, Popup as LPopup } from "leaflet"; import { useMapStore } from "_store/mapStore"; import { Fragment, useCallback, useEffect, useRef, useState, useMemo } from "react"; -import { cn } from "@repo/shared-components"; +import { checkSimulatorConnected, cn } from "@repo/shared-components"; import { ChevronsRightLeft, House, MessageSquareText, Minimize2 } from "lucide-react"; import { SmartPopup, calculateAnchor, useSmartPopup } from "_components/SmartPopup"; import FMSStatusHistory, { @@ -396,11 +396,27 @@ const AircraftMarker = ({ aircraft }: { aircraft: ConnectedAircraft & { Station: }; export const AircraftLayer = () => { - const { data: aircrafts } = useQuery({ - queryKey: ["aircrafts-map"], - queryFn: () => getConnectedAircraftsAPI(), - refetchInterval: 10_000, - }); + const [aircrafts, setAircrafts] = useState<(ConnectedAircraft & { Station: Station })[]>([]); + + useEffect(() => { + const fetchAircrafts = async () => { + try { + const res = await fetch("/api/aircrafts"); + if (!res.ok) { + throw new Error("Failed to fetch aircrafts"); + } + const data: (ConnectedAircraft & { Station: Station })[] = await res.json(); + setAircrafts(data.filter((a) => checkSimulatorConnected(a))); + } catch (error) { + console.error("Failed to fetch aircrafts:", error); + } + }; + + fetchAircrafts(); + const interval = setInterval(fetchAircrafts, 10_000); + + return () => clearInterval(interval); + }, []); const { setMap } = useMapStore((state) => state); const map = useMap(); const { From 17208eded9b6a003890d0e11b06abe9c721e6a79 Mon Sep 17 00:00:00 2001 From: PxlLoewe <72106766+PxlLoewe@users.noreply.github.com> Date: Fri, 26 Dec 2025 01:23:32 +0100 Subject: [PATCH 3/5] Added Account Dublicate fucntion, improved default sorts --- .vscode/extensions.json | 2 - .../event/_components/AppointmentModal.tsx | 2 +- .../(app)/admin/event/_components/Form.tsx | 171 +++++++++--------- .../admin/user/[id]/_components/forms.tsx | 82 ++++++++- .../duplicate/_components/DuplicateForm.tsx | 109 +++++++++++ .../(app)/admin/user/[id]/duplicate/page.tsx | 37 ++++ apps/hub/app/(app)/admin/user/[id]/page.tsx | 2 + apps/hub/app/(app)/admin/user/action.ts | 50 +++++ apps/hub/app/(app)/admin/user/page.tsx | 19 +- apps/hub/app/(app)/events/page.tsx | 4 + .../app/(app)/settings/_components/forms.tsx | 19 +- apps/hub/app/(app)/settings/page.tsx | 29 ++- apps/hub/app/_components/PaginatedTable.tsx | 43 +++-- apps/hub/app/_components/ui/Select.tsx | 8 +- apps/hub/app/api/admin/user/search/route.ts | 33 ++++ apps/hub/app/vatsim/page.tsx | 2 +- .../migration.sql | 7 + packages/database/prisma/schema/user.prisma | 6 + 18 files changed, 486 insertions(+), 139 deletions(-) create mode 100644 apps/hub/app/(app)/admin/user/[id]/duplicate/_components/DuplicateForm.tsx create mode 100644 apps/hub/app/(app)/admin/user/[id]/duplicate/page.tsx create mode 100644 apps/hub/app/api/admin/user/search/route.ts create mode 100644 packages/database/prisma/schema/migrations/20251225225508_added_multi_account_fields/migration.sql diff --git a/.vscode/extensions.json b/.vscode/extensions.json index ee4d8ed1..d9fa4320 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,7 +1,5 @@ { "recommendations": [ - "EthanSK.restore-terminals", "dbaeumer.vscode-eslint", - "VisualStudioExptTeam.vscodeintellicode" ] } diff --git a/apps/hub/app/(app)/admin/event/_components/AppointmentModal.tsx b/apps/hub/app/(app)/admin/event/_components/AppointmentModal.tsx index 2a307895..37c37b49 100644 --- a/apps/hub/app/(app)/admin/event/_components/AppointmentModal.tsx +++ b/apps/hub/app/(app)/admin/event/_components/AppointmentModal.tsx @@ -64,7 +64,7 @@ export const AppointmentModal = ({
{ {!form.watch("hasPresenceEvents") ? (
- - Teilnehmer - - } - searchFields={["User.firstname", "User.lastname", "User.publicId"]} - ref={appointmentsTableRef} - prismaModel={"participant"} - filter={{ - eventId: event?.id, - }} - include={{ - User: true, - }} - columns={ - [ - { - header: "Vorname", - accessorKey: "User.firstname", - cell: ({ row }) => { - return ( - - {row.original.User.firstname} - - ); - }, - }, - { - header: "Nachname", - accessorKey: "User.lastname", - cell: ({ row }) => { - return ( - - {row.original.User.lastname} - - ); - }, - }, - { - header: "VAR-Nummer", - accessorKey: "User.publicId", - cell: ({ row }) => { - return ( - - {row.original.User.publicId} - - ); - }, - }, - { - header: "Moodle Kurs abgeschlossen", - accessorKey: "finisherMoodleCurseCompleted", - }, - { - header: "Aktionen", - cell: ({ row }) => { - return ( -
- -
- ); + {row.original.User.firstname} + + ); + }, }, - }, - ] as ColumnDef[] - } - /> + { + header: "Nachname", + accessorKey: "User.lastname", + cell: ({ row }) => { + return ( + + {row.original.User.lastname} + + ); + }, + }, + { + header: "VAR-Nummer", + accessorKey: "User.publicId", + cell: ({ row }) => { + return ( + + {row.original.User.publicId} + + ); + }, + }, + { + header: "Moodle Kurs abgeschlossen", + accessorKey: "finisherMoodleCurseCompleted", + }, + { + header: "Aktionen", + cell: ({ row }) => { + return ( +
+ +
+ ); + }, + }, + ] as ColumnDef[] + } + /> + }
) : null} diff --git a/apps/hub/app/(app)/admin/user/[id]/_components/forms.tsx b/apps/hub/app/(app)/admin/user/[id]/_components/forms.tsx index 1ba128c8..6829b137 100644 --- a/apps/hub/app/(app)/admin/user/[id]/_components/forms.tsx +++ b/apps/hub/app/(app)/admin/user/[id]/_components/forms.tsx @@ -76,16 +76,17 @@ export const ProfileForm: React.FC = ({ user }: ProfileFormPro className="card-body" onSubmit={form.handleSubmit(async (values) => { if (!values.id) return; - if (values.id === session.data?.user.id && values.permissions !== user.permissions){ + if (values.id === session.data?.user.id && values.permissions !== user.permissions) { toast.error("Du kannst deine eigenen Berechtigungen nicht ändern."); return; } - if ( values.permissions?.some((perm) => !session.data?.user.permissions.includes(perm)) ){ + if (values.permissions?.some((perm) => !session.data?.user.permissions.includes(perm))) { toast.error("Du kannst Berechtigungen nicht hinzufügen, die du selbst nicht besitzt."); return; } - const removedPermissions = user.permissions?.filter((perm) => !values.permissions?.includes(perm)) || []; - if ( removedPermissions.some((perm) => !session.data?.user.permissions.includes(perm)) ){ + const removedPermissions = + user.permissions?.filter((perm) => !values.permissions?.includes(perm)) || []; + if (removedPermissions.some((perm) => !session.data?.user.permissions.includes(perm))) { toast.error("Du kannst Berechtigungen nicht entfernen, die du selbst nicht besitzt."); return; } @@ -284,6 +285,12 @@ export const ConnectionHistory: React.FC<{ user: User }> = ({ user }: { user: Us userId: user.id, }} prismaModel={"connectedDispatcher"} + initialOrderBy={[ + { + id: "loginTime", + desc: true, + }, + ]} columns={ [ { @@ -347,6 +354,12 @@ export const ConnectionHistory: React.FC<{ user: User }> = ({ user }: { user: Us }} prismaModel={"connectedAircraft"} include={{ Station: true }} + initialOrderBy={[ + { + id: "loginTime", + desc: true, + }, + ]} columns={ [ { @@ -519,6 +532,12 @@ export const UserReports = ({ user }: { user: User }) => { filter={{ reportedUserId: user.id, }} + initialOrderBy={[ + { + id: "timestamp", + desc: true, + }, + ]} include={{ Sender: true, Reported: true, @@ -531,7 +550,7 @@ export const UserReports = ({ user }: { user: User }) => { interface AdminFormProps { discordAccount?: DiscordAccount; - user: User; + user: User & { CanonicalUser?: User | null; Duplicates?: User[] | null }; dispoTime: { hours: number; minutes: number; @@ -653,7 +672,59 @@ export const AdminForm = ({
)} + {session?.user.permissions.includes("ADMIN_USER_ADVANCED") && ( +
+ + + +
+ )} + {(user.CanonicalUser || (user.Duplicates && user.Duplicates.length > 0)) && ( +
+
+ +
+ {user.CanonicalUser && ( +
+

Als Duplikat markiert

+

+ Dieser Account wurde als Duplikat von{" "} + + {user.CanonicalUser.firstname} {user.CanonicalUser.lastname} ( + {user.CanonicalUser.publicId}) + {" "} + markiert. +

+
+ )} + {user.Duplicates && user.Duplicates.length > 0 && ( +
+

Duplikate erkannt

+

Folgende Accounts wurden als Duplikate dieses Accounts markiert:

+
    + {user.Duplicates.map((duplicate) => ( +
  • + + {duplicate.firstname} {duplicate.lastname} ({duplicate.publicId}) + +
  • + ))} +
+
+ )} +
+
+

+ Achtung! Dieser Account ist als Duplikat markiert oder hat Duplikate! +

+
+ )} {(!!openBans.length || !!openTimebans.length) && (
@@ -681,6 +752,7 @@ export const AdminForm = ({

)} +

Aktivität

diff --git a/apps/hub/app/(app)/admin/user/[id]/duplicate/_components/DuplicateForm.tsx b/apps/hub/app/(app)/admin/user/[id]/duplicate/_components/DuplicateForm.tsx new file mode 100644 index 00000000..60a423f9 --- /dev/null +++ b/apps/hub/app/(app)/admin/user/[id]/duplicate/_components/DuplicateForm.tsx @@ -0,0 +1,109 @@ +"use client"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { useQuery } from "@tanstack/react-query"; +import { getUser, markDuplicate } from "(app)/admin/user/action"; +import { Button } from "@repo/shared-components"; +import { Select } from "_components/ui/Select"; +import toast from "react-hot-toast"; +import { TriangleAlert } from "lucide-react"; +import { useState } from "react"; + +const DuplicateSchema = z.object({ + canonicalUserId: z.string().min(1, "Bitte Nutzer auswählen"), + reason: z.string().max(500).optional(), +}); + +export const DuplicateForm = ({ duplicateUserId }: { duplicateUserId: string }) => { + const form = useForm>({ + resolver: zodResolver(DuplicateSchema), + defaultValues: { canonicalUserId: "", reason: "" }, + }); + + const [search, setSearch] = useState(""); + const { data: users } = useQuery({ + queryKey: ["duplicate-search"], + queryFn: async () => + getUser({ + OR: [ + { firstname: { contains: search, mode: "insensitive" } }, + { lastname: { contains: search, mode: "insensitive" } }, + { publicId: { contains: search, mode: "insensitive" } }, + { email: { contains: search, mode: "insensitive" } }, + ], + }), + enabled: search.length > 0, + refetchOnWindowFocus: false, + }); + + return ( +
{ + try { + // find selected canonical user by id to obtain publicId + const canonical = (users || []).find((u) => u.id === values.canonicalUserId); + if (!canonical) { + toast.error("Bitte wähle einen Original-Account aus."); + return; + } + await markDuplicate({ + duplicateUserId, + canonicalPublicId: canonical.publicId, + reason: values.reason, + }); + toast.success("Duplikat verknüpft und Nutzer gesperrt."); + } catch (e: unknown) { + const message = + typeof e === "object" && e && "message" in e + ? (e as { message?: string }).message || "Fehler beim Verknüpfen" + : "Fehler beim Verknüpfen"; + toast.error(message); + } + })} + > +
+
+

+ Duplikat markieren & sperren +

+ + +
+
+
+
+
+ +
+
+
+
+ ); +}; diff --git a/apps/hub/app/(app)/admin/user/[id]/duplicate/page.tsx b/apps/hub/app/(app)/admin/user/[id]/duplicate/page.tsx new file mode 100644 index 00000000..f902bc71 --- /dev/null +++ b/apps/hub/app/(app)/admin/user/[id]/duplicate/page.tsx @@ -0,0 +1,37 @@ +import { prisma } from "@repo/db"; +import { DuplicateForm } from "./_components/DuplicateForm"; +import { PersonIcon } from "@radix-ui/react-icons"; +import Link from "next/link"; +import { ArrowLeft } from "lucide-react"; + +export default async function Page({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const user = await prisma.user.findUnique({ + where: { id }, + select: { id: true, firstname: true, lastname: true, publicId: true }, + }); + if (!user) { + return ( +
+
Nutzer nicht gefunden
+
+ ); + } + return ( + <> +
+
+ + + Zurück zum Nutzer + +
+

+ Duplikat für {user.firstname}{" "} + {user.lastname} #{user.publicId} +

+
+ + + ); +} diff --git a/apps/hub/app/(app)/admin/user/[id]/page.tsx b/apps/hub/app/(app)/admin/user/[id]/page.tsx index 1c74253d..8998a5a7 100644 --- a/apps/hub/app/(app)/admin/user/[id]/page.tsx +++ b/apps/hub/app/(app)/admin/user/[id]/page.tsx @@ -18,6 +18,8 @@ export default async function Page({ params }: { params: Promise<{ id: string }> }, include: { discordAccounts: true, + CanonicalUser: true, + Duplicates: true, }, }); if (!user) return ; diff --git a/apps/hub/app/(app)/admin/user/action.ts b/apps/hub/app/(app)/admin/user/action.ts index 8de43981..5b623291 100644 --- a/apps/hub/app/(app)/admin/user/action.ts +++ b/apps/hub/app/(app)/admin/user/action.ts @@ -2,6 +2,7 @@ import { prisma, Prisma } from "@repo/db"; import bcrypt from "bcryptjs"; import { sendMailByTemplate } from "../../../../helper/mail"; +import { getServerSession } from "api/auth/[...nextauth]/auth"; export const getUser = async (where: Prisma.UserWhereInput) => { return await prisma.user.findMany({ @@ -82,3 +83,52 @@ export const sendVerificationLink = async (userId: string) => { code, }); }; + +export const markDuplicate = async (params: { + duplicateUserId: string; + canonicalPublicId: string; + reason?: string; +}) => { + // Then in your function: + const session = await getServerSession(); + if (!session?.user) throw new Error("Nicht authentifiziert"); + const canonical = await prisma.user.findUnique({ + where: { publicId: params.canonicalPublicId }, + select: { id: true }, + }); + if (!canonical) throw new Error("Original-Account (canonical) nicht gefunden"); + if (canonical.id === params.duplicateUserId) + throw new Error("Duplikat und Original dürfen nicht identisch sein"); + + const updated = await prisma.user.update({ + where: { id: params.duplicateUserId }, + data: { + canonicalUserId: canonical.id, + isBanned: true, + duplicateDetectedAt: new Date(), + duplicateReason: params.reason ?? undefined, + }, + }); + + await prisma.penalty.create({ + data: { + userId: params.duplicateUserId, + type: "BAN", + reason: `Account als Duplikat von #${params.canonicalPublicId} markiert.`, + createdUserId: session.user.id, + }, + }); + return updated; +}; + +export const clearDuplicateLink = async (duplicateUserId: string) => { + const updated = await prisma.user.update({ + where: { id: duplicateUserId }, + data: { + canonicalUserId: null, + duplicateDetectedAt: null, + duplicateReason: null, + }, + }); + return updated; +}; diff --git a/apps/hub/app/(app)/admin/user/page.tsx b/apps/hub/app/(app)/admin/user/page.tsx index 16c46718..3cd76d96 100644 --- a/apps/hub/app/(app)/admin/user/page.tsx +++ b/apps/hub/app/(app)/admin/user/page.tsx @@ -3,7 +3,7 @@ import { User2 } from "lucide-react"; import { PaginatedTable } from "../../../_components/PaginatedTable"; import Link from "next/link"; import { ColumnDef } from "@tanstack/react-table"; -import { User } from "@repo/db"; +import { DiscordAccount, User } from "@repo/db"; import { useSession } from "next-auth/react"; const AdminUserPage = () => { @@ -13,7 +13,10 @@ const AdminUserPage = () => { { ); }, }, + { + header: "Discord", + cell(props) { + const discord = props.row.original.discordAccounts; + if (discord.length === 0) { + return Nicht verbunden; + } + return {discord.map((d) => d.username).join(", ")}; + }, + }, ...(session?.user.permissions.includes("ADMIN_USER_ADVANCED") ? [ { @@ -69,7 +82,7 @@ const AdminUserPage = () => {
), }, - ] as ColumnDef[] + ] as ColumnDef[] } // Define the columns for the user table leftOfSearch={

diff --git a/apps/hub/app/(app)/events/page.tsx b/apps/hub/app/(app)/events/page.tsx index fbd72617..be2e660e 100644 --- a/apps/hub/app/(app)/events/page.tsx +++ b/apps/hub/app/(app)/events/page.tsx @@ -27,6 +27,10 @@ const page = async () => { }, }, }, + + orderBy: { + id: "desc", + }, }); const appointments = await prisma.eventAppointment.findMany({ where: { diff --git a/apps/hub/app/(app)/settings/_components/forms.tsx b/apps/hub/app/(app)/settings/_components/forms.tsx index d378b84b..93268439 100644 --- a/apps/hub/app/(app)/settings/_components/forms.tsx +++ b/apps/hub/app/(app)/settings/_components/forms.tsx @@ -1,6 +1,6 @@ "use client"; import { zodResolver } from "@hookform/resolvers/zod"; -import { DiscordAccount, Penalty, User } from "@repo/db"; +import { DiscordAccount, Penalty, Report, User } from "@repo/db"; import { useState } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; @@ -326,9 +326,17 @@ export const SocialForm = ({ ); }; -export const DeleteForm = ({ user, penaltys }: { user: User; penaltys: Penalty[] }) => { +export const DeleteForm = ({ + user, + penaltys, + reports, +}: { + user: User; + penaltys: Penalty[]; + reports: Report[]; +}) => { const router = useRouter(); - const userCanDelete = penaltys.length === 0 && !user.isBanned; + const userCanDelete = penaltys.length === 0 && !user.isBanned && reports.length === 0; return (

@@ -338,8 +346,9 @@ export const DeleteForm = ({ user, penaltys }: { user: User; penaltys: Penalty[]

Du kannst dein Konto zurzeit nicht löschen!

- Scheinbar hast du aktuell zurzeit aktive Strafen. Um unsere Community zu schützen kannst - du einen Account erst löschen wenn deine Strafe nicht mehr aktiv ist + Scheinbar hast du Strafen oder Reports in deinem Profil hinterlegt. Um unsere Community + zu schützen kannst du deinen Account nicht löschen. Bitte erstelle ein Support-Ticket, + wenn du Fragen dazu hast.

)} diff --git a/apps/hub/app/(app)/settings/page.tsx b/apps/hub/app/(app)/settings/page.tsx index 240e60db..c29d8c62 100644 --- a/apps/hub/app/(app)/settings/page.tsx +++ b/apps/hub/app/(app)/settings/page.tsx @@ -20,36 +20,35 @@ export default async function Page() { const userPenaltys = await prisma.penalty.findMany({ where: { userId: session.user.id, - until: { - gte: new Date(), - }, - type: { - in: ["TIME_BAN", "BAN"], - }, - - suspended: false, }, }); + + const userReports = await prisma.report.findMany({ + where: { + reportedUserId: session.user.id, + }, + }); + if (!user) return ; const discordAccount = user?.discordAccounts[0]; return (
-

- Einstellungen +

+ Einstellungen

-
+
-
+
-
+
-
- +
+
); diff --git a/apps/hub/app/_components/PaginatedTable.tsx b/apps/hub/app/_components/PaginatedTable.tsx index a3af6918..87bc9fff 100644 --- a/apps/hub/app/_components/PaginatedTable.tsx +++ b/apps/hub/app/_components/PaginatedTable.tsx @@ -20,7 +20,7 @@ interface PaginatedTableProps extends Omit, "da leftOfSearch?: React.ReactNode; rightOfSearch?: React.ReactNode; leftOfPagination?: React.ReactNode; - hide?: boolean; + supressQuery?: boolean; ref?: Ref; } @@ -36,7 +36,7 @@ export function PaginatedTable({ leftOfSearch, rightOfSearch, leftOfPagination, - hide, + supressQuery, ...restProps }: PaginatedTableProps) { const [data, setData] = useState([]); @@ -58,6 +58,10 @@ export function PaginatedTable({ const [loading, setLoading] = useState(false); const refreshTableData = useCallback(async () => { + if (supressQuery) { + setLoading(false); + return; + } setLoading(true); getData( prismaModel, @@ -91,6 +95,7 @@ export function PaginatedTable({ setLoading(false); }); }, [ + supressQuery, prismaModel, rowsPerPage, page, @@ -111,8 +116,10 @@ export function PaginatedTable({ // useEffect to show loading spinner useEffect(() => { + if (supressQuery) return; + setLoading(true); - }, [searchTerm, page, rowsPerPage, orderBy, filter, setLoading]); + }, [searchTerm, page, rowsPerPage, orderBy, filter, setLoading, supressQuery]); useDebounce( () => { @@ -123,15 +130,15 @@ export function PaginatedTable({ ); return ( -
+
{(rightOfSearch || leftOfSearch || searchFields.length > 0) && (
-
+
{leftOfSearch}
{loading && }
@@ -150,22 +157,14 @@ export function PaginatedTable({
{rightOfSearch}
)} - {!hide && ( - - )} -
+ + +
{leftOfPagination} - {!hide && ( - <> - - - - )} + <> + + +
); diff --git a/apps/hub/app/_components/ui/Select.tsx b/apps/hub/app/_components/ui/Select.tsx index edbcbd9d..b3163667 100644 --- a/apps/hub/app/_components/ui/Select.tsx +++ b/apps/hub/app/_components/ui/Select.tsx @@ -31,6 +31,7 @@ const customStyles: StylesConfig = { backgroundColor: state.isSelected ? "hsl(var(--p))" : "hsl(var(--b1))", color: "var(--color-primary-content)", "&:hover": { backgroundColor: "var(--color-base-200)" }, // DaisyUI secondary color + cursor: "pointer", }), multiValueLabel: (provided) => ({ ...provided, @@ -49,6 +50,11 @@ const customStyles: StylesConfig = { backgroundColor: "var(--color-base-100)", borderRadius: "0.5rem", }), + input: (provided) => ({ + ...provided, + color: "var(--color-primary-content)", + cursor: "text", + }), }; const SelectCom = ({ @@ -61,7 +67,7 @@ const SelectCom = ({ }: SelectProps) => { return (
- {label} + {label} { if (Array.isArray(newValue)) { diff --git a/apps/hub/app/api/admin/user/search/route.ts b/apps/hub/app/api/admin/user/search/route.ts new file mode 100644 index 00000000..73571057 --- /dev/null +++ b/apps/hub/app/api/admin/user/search/route.ts @@ -0,0 +1,33 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@repo/db"; + +export async function GET(req: Request) { + const { searchParams } = new URL(req.url); + const publicId = searchParams.get("publicId")?.trim(); + const email = searchParams.get("email")?.trim()?.toLowerCase(); + + if (!publicId && !email) { + return NextResponse.json({ error: "Missing query" }, { status: 400 }); + } + + try { + const user = await prisma.user.findFirst({ + where: { + OR: [publicId ? { publicId } : undefined, email ? { email } : undefined].filter( + Boolean, + ) as any, + }, + select: { + id: true, + publicId: true, + firstname: true, + lastname: true, + isBanned: true, + }, + }); + if (!user) return NextResponse.json({ user: null }, { status: 200 }); + return NextResponse.json({ user }, { status: 200 }); + } catch (e) { + return NextResponse.json({ error: "Server error" }, { status: 500 }); + } +} diff --git a/apps/hub/app/vatsim/page.tsx b/apps/hub/app/vatsim/page.tsx index 0dfff310..83d885ca 100644 --- a/apps/hub/app/vatsim/page.tsx +++ b/apps/hub/app/vatsim/page.tsx @@ -11,7 +11,7 @@ export default function () { prismaModel={"user"} filter={{ vatsimCid: { - gt: 1, + not: "", }, }} leftOfSearch={

Vatsim-Nutzer

} diff --git a/packages/database/prisma/schema/migrations/20251225225508_added_multi_account_fields/migration.sql b/packages/database/prisma/schema/migrations/20251225225508_added_multi_account_fields/migration.sql new file mode 100644 index 00000000..9b984863 --- /dev/null +++ b/packages/database/prisma/schema/migrations/20251225225508_added_multi_account_fields/migration.sql @@ -0,0 +1,7 @@ +-- AlterTable +ALTER TABLE "users" ADD COLUMN "canonical_user_id" TEXT, +ADD COLUMN "duplicate_detected_at" TIMESTAMP(3), +ADD COLUMN "duplicate_reason" TEXT; + +-- AddForeignKey +ALTER TABLE "users" ADD CONSTRAINT "users_canonical_user_id_fkey" FOREIGN KEY ("canonical_user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/packages/database/prisma/schema/user.prisma b/packages/database/prisma/schema/user.prisma index 1f09f3f0..820115e1 100644 --- a/packages/database/prisma/schema/user.prisma +++ b/packages/database/prisma/schema/user.prisma @@ -60,6 +60,12 @@ model User { createdAt DateTime @default(now()) @map(name: "created_at") updatedAt DateTime @default(now()) @map(name: "updated_at") isBanned Boolean @default(false) @map(name: "is_banned") + // Duplicate handling: + canonicalUserId String? @map(name: "canonical_user_id") + CanonicalUser User? @relation("CanonicalUser", fields: [canonicalUserId], references: [id]) + Duplicates User[] @relation("CanonicalUser") + duplicateDetectedAt DateTime? @map(name: "duplicate_detected_at") + duplicateReason String? @map(name: "duplicate_reason") // relations: oauthTokens OAuthToken[] discordAccounts DiscordAccount[] From e9a4c50a12c9518a36ffc883182871100f853325 Mon Sep 17 00:00:00 2001 From: PxlLoewe <72106766+PxlLoewe@users.noreply.github.com> Date: Fri, 26 Dec 2025 01:25:17 +0100 Subject: [PATCH 4/5] fixed admin search --- apps/hub/app/(app)/admin/user/page.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/hub/app/(app)/admin/user/page.tsx b/apps/hub/app/(app)/admin/user/page.tsx index 3cd76d96..91faa09d 100644 --- a/apps/hub/app/(app)/admin/user/page.tsx +++ b/apps/hub/app/(app)/admin/user/page.tsx @@ -8,12 +8,13 @@ import { useSession } from "next-auth/react"; const AdminUserPage = () => { const { data: session } = useSession(); + return ( <> Date: Sat, 27 Dec 2025 15:33:00 +0100 Subject: [PATCH 5/5] Redesigned Search, removed Unused Admin Route --- .../app/(app)/_components/RecentFlights.tsx | 20 +++--- apps/hub/app/(app)/admin/changelog/page.tsx | 12 +++- .../event/_components/AppointmentModal.tsx | 10 +-- .../(app)/admin/event/_components/Form.tsx | 30 +++++--- apps/hub/app/(app)/admin/heliport/page.tsx | 14 +++- apps/hub/app/(app)/admin/keyword/page.tsx | 17 +++-- .../(app)/admin/report/_components/form.tsx | 10 +-- .../(app)/admin/station/_components/Form.tsx | 17 +++-- apps/hub/app/(app)/admin/station/page.tsx | 16 +++-- .../admin/user/[id]/_components/forms.tsx | 33 +++++---- apps/hub/app/(app)/admin/user/page.tsx | 18 ++++- apps/hub/app/(app)/logbook/page.tsx | 22 +++--- apps/hub/app/_components/PaginatedTable.tsx | 42 ++++++------ .../app/_components/pagiantedTableActions.ts | 68 ++++++------------- apps/hub/app/api/admin/user/search/route.ts | 33 --------- apps/hub/app/vatsim/page.tsx | 25 +++++-- 16 files changed, 209 insertions(+), 178 deletions(-) delete mode 100644 apps/hub/app/api/admin/user/search/route.ts diff --git a/apps/hub/app/(app)/_components/RecentFlights.tsx b/apps/hub/app/(app)/_components/RecentFlights.tsx index 9999c414..d9be2342 100644 --- a/apps/hub/app/(app)/_components/RecentFlights.tsx +++ b/apps/hub/app/(app)/_components/RecentFlights.tsx @@ -1,5 +1,5 @@ "use client"; -import { Mission, MissionAlertLog, MissionLog, Station } from "@repo/db"; +import { Mission, MissionAlertLog, MissionLog, Prisma, Station } from "@repo/db"; import { ColumnDef } from "@tanstack/react-table"; import { PaginatedTable } from "_components/PaginatedTable"; import { ArrowRight, NotebookText } from "lucide-react"; @@ -12,20 +12,22 @@ export const RecentFlights = () => {

- Logbook + Logbook - Zum vollständigen Logbook + Zum vollständigen Logbook

+ ({ + User: { id: session.data?.user.id }, + Mission: { + state: { in: ["finished", "archived"] }, + }, + }) as Prisma.MissionOnStationUsersWhereInput + } include={{ Station: true, User: true, diff --git a/apps/hub/app/(app)/admin/changelog/page.tsx b/apps/hub/app/(app)/admin/changelog/page.tsx index b768e357..f24bc0e3 100644 --- a/apps/hub/app/(app)/admin/changelog/page.tsx +++ b/apps/hub/app/(app)/admin/changelog/page.tsx @@ -3,7 +3,7 @@ import { DatabaseBackupIcon } from "lucide-react"; import { PaginatedTable } from "../../../_components/PaginatedTable"; import Link from "next/link"; import { ColumnDef } from "@tanstack/react-table"; -import { Keyword } from "@repo/db"; +import { Keyword, Prisma } from "@repo/db"; export default () => { return ( @@ -12,7 +12,15 @@ export default () => { stickyHeaders initialOrderBy={[{ id: "title", desc: true }]} prismaModel="changelog" - searchFields={["title"]} + showSearch + getFilter={(search) => + ({ + OR: [ + { title: { contains: search, mode: "insensitive" } }, + { description: { contains: search, mode: "insensitive" } }, + ], + }) as Prisma.ChangelogWhereInput + } columns={ [ { diff --git a/apps/hub/app/(app)/admin/event/_components/AppointmentModal.tsx b/apps/hub/app/(app)/admin/event/_components/AppointmentModal.tsx index 37c37b49..f2e69dcf 100644 --- a/apps/hub/app/(app)/admin/event/_components/AppointmentModal.tsx +++ b/apps/hub/app/(app)/admin/event/_components/AppointmentModal.tsx @@ -1,4 +1,4 @@ -import { Event, Participant } from "@repo/db"; +import { Event, Participant, Prisma } from "@repo/db"; import { EventAppointmentOptionalDefaults, InputJsonValueType } from "@repo/db/zod"; import { ColumnDef } from "@tanstack/react-table"; import { useSession } from "next-auth/react"; @@ -167,9 +167,11 @@ export const AppointmentModal = ({ ] as ColumnDef[] } prismaModel={"participant"} - filter={{ - eventAppointmentId: appointmentForm.watch("id"), - }} + getFilter={() => + ({ + eventAppointmentId: appointmentForm.watch("id")!, + }) as Prisma.ParticipantWhereInput + } include={{ User: true }} leftOfPagination={
diff --git a/apps/hub/app/(app)/admin/event/_components/Form.tsx b/apps/hub/app/(app)/admin/event/_components/Form.tsx index aab1c5b5..0059d196 100644 --- a/apps/hub/app/(app)/admin/event/_components/Form.tsx +++ b/apps/hub/app/(app)/admin/event/_components/Form.tsx @@ -1,6 +1,6 @@ "use client"; import { zodResolver } from "@hookform/resolvers/zod"; -import { BADGES, Event, EVENT_TYPE, Participant, PERMISSION, User } from "@repo/db"; +import { BADGES, Event, EVENT_TYPE, Participant, PERMISSION, Prisma, User } from "@repo/db"; import { EventAppointmentOptionalDefaults, EventAppointmentOptionalDefaultsSchema, @@ -159,9 +159,11 @@ export const Form = ({ event }: { event?: Event }) => { + ({ + eventId: event?.id, + }) as Prisma.EventAppointmentWhereInput + } include={{ Presenter: true, Participants: true, @@ -257,12 +259,24 @@ export const Form = ({ event }: { event?: Event }) => { Teilnehmer

} - searchFields={["User.firstname", "User.lastname", "User.publicId"]} ref={appointmentsTableRef} prismaModel={"participant"} - filter={{ - eventId: event?.id, - }} + showSearch + getFilter={(searchTerm) => + ({ + OR: [ + { + User: { + OR: [ + { firstname: { contains: searchTerm, mode: "insensitive" } }, + { lastname: { contains: searchTerm, mode: "insensitive" } }, + { publicId: { contains: searchTerm, mode: "insensitive" } }, + ], + }, + }, + ], + }) as Prisma.ParticipantWhereInput + } include={{ User: true, }} diff --git a/apps/hub/app/(app)/admin/heliport/page.tsx b/apps/hub/app/(app)/admin/heliport/page.tsx index 4d805e6c..08717fe5 100644 --- a/apps/hub/app/(app)/admin/heliport/page.tsx +++ b/apps/hub/app/(app)/admin/heliport/page.tsx @@ -3,7 +3,7 @@ import { DatabaseBackupIcon } from "lucide-react"; import { PaginatedTable } from "../../../_components/PaginatedTable"; import Link from "next/link"; import { ColumnDef } from "@tanstack/react-table"; -import { Heliport } from "@repo/db"; +import { Heliport, Prisma } from "@repo/db"; const page = () => { return ( @@ -11,7 +11,17 @@ const page = () => { + ({ + OR: [ + { siteName: { contains: searchTerm, mode: "insensitive" } }, + { info: { contains: searchTerm, mode: "insensitive" } }, + { hospital: { contains: searchTerm, mode: "insensitive" } }, + { designator: { contains: searchTerm, mode: "insensitive" } }, + ], + }) as Prisma.HeliportWhereInput + } + showSearch columns={ [ { diff --git a/apps/hub/app/(app)/admin/keyword/page.tsx b/apps/hub/app/(app)/admin/keyword/page.tsx index 73a75e0c..8d673a21 100644 --- a/apps/hub/app/(app)/admin/keyword/page.tsx +++ b/apps/hub/app/(app)/admin/keyword/page.tsx @@ -3,7 +3,7 @@ import { DatabaseBackupIcon } from "lucide-react"; import { PaginatedTable } from "../../../_components/PaginatedTable"; import Link from "next/link"; import { ColumnDef } from "@tanstack/react-table"; -import { Keyword } from "@repo/db"; +import { Keyword, Prisma } from "@repo/db"; export default () => { return ( @@ -12,7 +12,16 @@ export default () => { stickyHeaders initialOrderBy={[{ id: "category", desc: true }]} prismaModel="keyword" - searchFields={["name", "abreviation", "description"]} + showSearch + getFilter={(searchTerm) => + ({ + OR: [ + { name: { contains: searchTerm, mode: "insensitive" } }, + { abreviation: { contains: searchTerm, mode: "insensitive" } }, + { category: { contains: searchTerm, mode: "insensitive" } }, + ], + }) as Prisma.KeywordWhereInput + } columns={ [ { @@ -41,11 +50,11 @@ export default () => { } leftOfSearch={ - Stichwörter + Stichwörter } rightOfSearch={ -

+

diff --git a/apps/hub/app/(app)/admin/report/_components/form.tsx b/apps/hub/app/(app)/admin/report/_components/form.tsx index 407c02e0..38e3ca81 100644 --- a/apps/hub/app/(app)/admin/report/_components/form.tsx +++ b/apps/hub/app/(app)/admin/report/_components/form.tsx @@ -2,7 +2,7 @@ import { penaltyColumns as penaltyColumns } from "(app)/admin/penalty/columns"; import { editReport } from "(app)/admin/report/actions"; import { zodResolver } from "@hookform/resolvers/zod"; -import { Report as IReport, User } from "@repo/db"; +import { Report as IReport, Prisma, User } from "@repo/db"; import { ReportSchema, Report as IReportZod } from "@repo/db/zod"; import { PaginatedTable } from "_components/PaginatedTable"; import { Button } from "_components/ui/Button"; @@ -149,9 +149,11 @@ export const ReportPenalties = ({ CreatedUser: true, Report: true, }} - filter={{ - reportId: report.id, - }} + getFilter={() => + ({ + reportId: report.id, + }) as Prisma.PenaltyWhereInput + } columns={penaltyColumns} />

diff --git a/apps/hub/app/(app)/admin/station/_components/Form.tsx b/apps/hub/app/(app)/admin/station/_components/Form.tsx index 207fae6a..c9276919 100644 --- a/apps/hub/app/(app)/admin/station/_components/Form.tsx +++ b/apps/hub/app/(app)/admin/station/_components/Form.tsx @@ -2,7 +2,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { StationOptionalDefaultsSchema } from "@repo/db/zod"; import { useForm } from "react-hook-form"; -import { BosUse, ConnectedAircraft, Country, Station, User } from "@repo/db"; +import { BosUse, ConnectedAircraft, Country, Prisma, Station, User } from "@repo/db"; import { FileText, LocateIcon, PlaneIcon, UserIcon } from "lucide-react"; import { Input } from "../../../../_components/ui/Input"; import { deleteStation, upsertStation } from "../action"; @@ -198,10 +198,17 @@ export const StationForm = ({ station }: { station?: Station }) => { Verbundene Piloten } - filter={{ - stationId: station?.id, - }} - searchFields={["User.firstname", "User.lastname", "User.publicId"]} + getFilter={(searchField) => + ({ + stationId: station?.id, + OR: [ + { User: { firstname: { contains: searchField, mode: "insensitive" } } }, + { User: { lastname: { contains: searchField, mode: "insensitive" } } }, + { User: { publicId: { contains: searchField, mode: "insensitive" } } }, + ], + }) as Prisma.ConnectedAircraftWhereInput + } + showSearch prismaModel={"connectedAircraft"} include={{ Station: true, User: true }} columns={ diff --git a/apps/hub/app/(app)/admin/station/page.tsx b/apps/hub/app/(app)/admin/station/page.tsx index b8103b06..b4d8b3d9 100644 --- a/apps/hub/app/(app)/admin/station/page.tsx +++ b/apps/hub/app/(app)/admin/station/page.tsx @@ -3,14 +3,22 @@ import { DatabaseBackupIcon } from "lucide-react"; import { PaginatedTable } from "../../../_components/PaginatedTable"; import Link from "next/link"; import { ColumnDef } from "@tanstack/react-table"; -import { Station } from "@repo/db"; +import { Prisma, Station } from "@repo/db"; const page = () => { return ( <> + ({ + OR: [ + { bosCallsign: { contains: searchField, mode: "insensitive" } }, + { operator: { contains: searchField, mode: "insensitive" } }, + ], + }) as Prisma.StationWhereInput + } stickyHeaders columns={ [ @@ -44,11 +52,11 @@ const page = () => { } leftOfSearch={ - Stationen + Stationen } rightOfSearch={ -

+

diff --git a/apps/hub/app/(app)/admin/user/[id]/_components/forms.tsx b/apps/hub/app/(app)/admin/user/[id]/_components/forms.tsx index 6829b137..1b56101d 100644 --- a/apps/hub/app/(app)/admin/user/[id]/_components/forms.tsx +++ b/apps/hub/app/(app)/admin/user/[id]/_components/forms.tsx @@ -8,6 +8,7 @@ import { DiscordAccount, Penalty, PERMISSION, + Prisma, Station, User, } from "@repo/db"; @@ -281,9 +282,11 @@ export const ConnectionHistory: React.FC<{ user: User }> = ({ user }: { user: Us + ({ + userId: user.id, + }) as Prisma.ConnectedDispatcherWhereInput + } prismaModel={"connectedDispatcher"} initialOrderBy={[ { @@ -349,9 +352,11 @@ export const ConnectionHistory: React.FC<{ user: User }> = ({ user }: { user: Us + ({ + userId: user.id, + }) as Prisma.ConnectedAircraftWhereInput + } prismaModel={"connectedAircraft"} include={{ Station: true }} initialOrderBy={[ @@ -505,9 +510,7 @@ export const UserPenalties = ({ user }: { user: User }) => { CreatedUser: true, Report: true, }} - filter={{ - userId: user.id, - }} + getFilter={() => ({ userId: user.id }) as Prisma.PenaltyWhereInput} columns={penaltyColumns} /> @@ -529,9 +532,11 @@ export const UserReports = ({ user }: { user: User }) => { + ({ + reportedUserId: user.id, + }) as Prisma.ReportWhereInput + } initialOrderBy={[ { id: "timestamp", @@ -720,9 +725,7 @@ export const AdminForm = ({ )} -

- Achtung! Dieser Account ist als Duplikat markiert oder hat Duplikate! -

+

{user.duplicateReason || "Keine Grund angegeben"}

)} {(!!openBans.length || !!openTimebans.length) && ( diff --git a/apps/hub/app/(app)/admin/user/page.tsx b/apps/hub/app/(app)/admin/user/page.tsx index 91faa09d..cdeb8b28 100644 --- a/apps/hub/app/(app)/admin/user/page.tsx +++ b/apps/hub/app/(app)/admin/user/page.tsx @@ -3,7 +3,7 @@ import { User2 } from "lucide-react"; import { PaginatedTable } from "../../../_components/PaginatedTable"; import Link from "next/link"; import { ColumnDef } from "@tanstack/react-table"; -import { DiscordAccount, User } from "@repo/db"; +import { DiscordAccount, Prisma, User } from "@repo/db"; import { useSession } from "next-auth/react"; const AdminUserPage = () => { @@ -14,7 +14,21 @@ const AdminUserPage = () => { { + return { + OR: [ + { firstname: { contains: searchTerm, mode: "insensitive" } }, + { lastname: { contains: searchTerm, mode: "insensitive" } }, + { email: { contains: searchTerm, mode: "insensitive" } }, + { + discordAccounts: { + some: { username: { contains: searchTerm, mode: "insensitive" } }, + }, + }, + ], + } as Prisma.UserWhereInput; + }} include={{ discordAccounts: true, }} diff --git a/apps/hub/app/(app)/logbook/page.tsx b/apps/hub/app/(app)/logbook/page.tsx index 9418a114..fd4647f8 100644 --- a/apps/hub/app/(app)/logbook/page.tsx +++ b/apps/hub/app/(app)/logbook/page.tsx @@ -1,5 +1,5 @@ "use client"; -import { Mission, MissionAlertLog, MissionLog, Station } from "@repo/db"; +import { Mission, MissionAlertLog, MissionLog, Prisma, Station } from "@repo/db"; import { ColumnDef } from "@tanstack/react-table"; import { Error } from "_components/Error"; import { PaginatedTable } from "_components/PaginatedTable"; @@ -14,19 +14,21 @@ const Page = () => { return (
-

- Einsatzhistorie +

+ Einsatzhistorie

-
+
+ ({ + userId: session.data?.user?.id ?? "", + Mission: { + state: "finished", + }, + }) as Prisma.MissionOnStationUsersWhereInput + } include={{ Station: true, User: true, diff --git a/apps/hub/app/_components/PaginatedTable.tsx b/apps/hub/app/_components/PaginatedTable.tsx index 87bc9fff..af4aedc0 100644 --- a/apps/hub/app/_components/PaginatedTable.tsx +++ b/apps/hub/app/_components/PaginatedTable.tsx @@ -9,12 +9,13 @@ export interface PaginatedTableRef { refresh: () => void; } -interface PaginatedTableProps extends Omit, "data"> { +interface PaginatedTableProps + extends Omit, "data"> { prismaModel: keyof PrismaClient; stickyHeaders?: boolean; - filter?: Record; initialRowsPerPage?: number; - searchFields?: string[]; + showSearch?: boolean; + getFilter?: (searchTerm: string) => TWhere; include?: Record; strictQuery?: boolean; leftOfSearch?: React.ReactNode; @@ -24,11 +25,11 @@ interface PaginatedTableProps extends Omit, "da ref?: Ref; } -export function PaginatedTable({ +export function PaginatedTable({ prismaModel, initialRowsPerPage = 30, - searchFields = [], - filter, + getFilter, + showSearch = false, include, ref, strictQuery = false, @@ -38,7 +39,7 @@ export function PaginatedTable({ leftOfPagination, supressQuery, ...restProps -}: PaginatedTableProps) { +}: PaginatedTableProps) { const [data, setData] = useState([]); const [rowsPerPage, setRowsPerPage] = useState(initialRowsPerPage); const [page, setPage] = useState(0); @@ -63,16 +64,14 @@ export function PaginatedTable({ return; } setLoading(true); - getData( - prismaModel, - rowsPerPage, - page * rowsPerPage, - searchTerm, - searchFields, - filter, + getData({ + model: prismaModel, + limit: rowsPerPage, + offset: page * rowsPerPage, + where: getFilter ? getFilter(searchTerm) : undefined, include, orderBy, - strictQuery + select: strictQuery ? restProps.columns .filter( (col): col is { accessorKey: string } => @@ -84,7 +83,7 @@ export function PaginatedTable({ return acc; }, {}) : undefined, - ) + }) .then((result) => { if (result) { setData(result.data); @@ -100,8 +99,7 @@ export function PaginatedTable({ rowsPerPage, page, searchTerm, - searchFields, - filter, + getFilter, include, orderBy, strictQuery, @@ -119,19 +117,19 @@ export function PaginatedTable({ if (supressQuery) return; setLoading(true); - }, [searchTerm, page, rowsPerPage, orderBy, filter, setLoading, supressQuery]); + }, [searchTerm, page, rowsPerPage, orderBy, getFilter, setLoading, supressQuery]); useDebounce( () => { refreshTableData(); }, 500, - [searchTerm, page, rowsPerPage, orderBy, filter], + [searchTerm, page, rowsPerPage, orderBy, getFilter], ); return (
- {(rightOfSearch || leftOfSearch || searchFields.length > 0) && ( + {(rightOfSearch || leftOfSearch || showSearch) && (
({
{leftOfSearch}
{loading && }
- {searchFields.length > 0 && ( + {showSearch && ( , - include?: Record, - orderBy?: Record, - select?: Record, -) { - if (!model || !prisma[model]) { +export async function getData({ + model, + limit, + offset, + where, + include, + orderBy, + select, +}: { + model: keyof PrismaClient; + limit: number; + offset: number; + where: Twhere; + include?: Record; + orderBy?: Record; + select?: Record; +}) { + if (!model || !(prisma as any)[model]) { return { data: [], total: 0 }; } - const formattedId = searchTerm.match(/^VAR(\d+)$/)?.[1]; + const delegate = (prisma as any)[model]; - const where = searchTerm - ? { - OR: [ - formattedId ? { id: formattedId } : undefined, - ...searchFields.map((field) => { - if (field.includes(".")) { - const parts: string[] = field.split("."); - - // Helper function to build nested object - const buildNestedFilter = (parts: string[], index = 0): any => { - if (index === parts.length - 1) { - // Reached the last part - add the contains filter - return { [parts[index] as string]: { contains: searchTerm } }; - } - - // For intermediate levels, nest the next level - return { [parts[index] as string]: buildNestedFilter(parts, index + 1) }; - }; - - return buildNestedFilter(parts); - } - - return { [field]: { contains: searchTerm } }; - }), - ].filter(Boolean), - ...filter, - } - : { ...filter }; - - if (!prisma[model]) { - return { data: [], total: 0 }; - } - const data = await (prisma[model] as any).findMany({ + const data = await delegate.findMany({ where, orderBy, take: limit, @@ -60,7 +34,7 @@ export async function getData( select, }); - const total = await (prisma[model] as any).count({ where }); + const total = await delegate.count({ where }); return { data, total }; } diff --git a/apps/hub/app/api/admin/user/search/route.ts b/apps/hub/app/api/admin/user/search/route.ts deleted file mode 100644 index 73571057..00000000 --- a/apps/hub/app/api/admin/user/search/route.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { NextResponse } from "next/server"; -import { prisma } from "@repo/db"; - -export async function GET(req: Request) { - const { searchParams } = new URL(req.url); - const publicId = searchParams.get("publicId")?.trim(); - const email = searchParams.get("email")?.trim()?.toLowerCase(); - - if (!publicId && !email) { - return NextResponse.json({ error: "Missing query" }, { status: 400 }); - } - - try { - const user = await prisma.user.findFirst({ - where: { - OR: [publicId ? { publicId } : undefined, email ? { email } : undefined].filter( - Boolean, - ) as any, - }, - select: { - id: true, - publicId: true, - firstname: true, - lastname: true, - isBanned: true, - }, - }); - if (!user) return NextResponse.json({ user: null }, { status: 200 }); - return NextResponse.json({ user }, { status: 200 }); - } catch (e) { - return NextResponse.json({ error: "Server error" }, { status: 500 }); - } -} diff --git a/apps/hub/app/vatsim/page.tsx b/apps/hub/app/vatsim/page.tsx index 83d885ca..1f6edb18 100644 --- a/apps/hub/app/vatsim/page.tsx +++ b/apps/hub/app/vatsim/page.tsx @@ -1,5 +1,5 @@ "use client"; -import { User } from "@repo/db"; +import { Prisma, User } from "@repo/db"; import { ColumnDef } from "@tanstack/react-table"; import { PaginatedTable } from "_components/PaginatedTable"; @@ -7,13 +7,24 @@ export default function () { return ( + ({ + AND: [ + { + vatsimCid: { + not: "", + }, + OR: [ + { firstname: { contains: searchTerm, mode: "insensitive" } }, + { lastname: { contains: searchTerm, mode: "insensitive" } }, + { vatsimCid: { contains: searchTerm, mode: "insensitive" } }, + ], + }, + ], + }) as Prisma.UserWhereInput + } leftOfSearch={

Vatsim-Nutzer

} columns={ [