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[]