diff --git a/apps/hub/app/(app)/admin/user/[id]/page.tsx b/apps/hub/app/(app)/admin/user/[id]/page.tsx index 76e57b3d..ca619cb6 100644 --- a/apps/hub/app/(app)/admin/user/[id]/page.tsx +++ b/apps/hub/app/(app)/admin/user/[id]/page.tsx @@ -1,5 +1,5 @@ import { PersonIcon } from "@radix-ui/react-icons"; -import { prisma } from "@repo/db"; +import { Log, prisma } from "@repo/db"; import { AdminForm, ConnectionHistory, @@ -9,6 +9,8 @@ import { } from "./_components/forms"; import { Error } from "../../../../_components/Error"; import { getUserPenaltys } from "@repo/shared-components"; +import { PaginatedTable } from "_components/PaginatedTable"; +import { ColumnDef } from "@tanstack/react-table"; export default async function Page({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; @@ -35,6 +37,26 @@ export default async function Page({ params }: { params: Promise<{ id: string }> }); } + const userLog = await prisma.log.findMany({ + where: { + userId: user?.id, + }, + }); + + const sameIpLogs = await prisma.log.findMany({ + where: { + ip: { + in: userLog.map((log) => log.ip).filter((ip): ip is string => ip !== null), + }, + userId: { + not: user?.id, + }, + }, + include: { + User: true, + }, + }); + const formerDiscordAccounts = await prisma.formerDiscordAccount.findMany({ where: { userId: user?.id, @@ -152,6 +174,41 @@ export default async function Page({ params }: { params: Promise<{ id: string }> openTimebans={openTimeban} /> +
+ new Date(info.getValue()).toLocaleString("de-DE"), + }, + { + header: "Aktion", + accessorKey: "action", + cell: ({ row }) => { + const action = row.original.type; + + if (action !== "PROFILE_CHANGE") { + return action; + } else { + return `${row.original.field} von "${row.original.oldValue}" zu "${row.original.newValue}"`; + } + }, + }, + { + header: "IP-Adresse", + accessorKey: "ip", + }, + { + header: "Gerät", + accessorKey: "browser", + }, + ] as ColumnDef[] + } + /> +
diff --git a/apps/hub/app/(app)/events/page.tsx b/apps/hub/app/(app)/events/page.tsx index fb67df39..2aa8a479 100644 --- a/apps/hub/app/(app)/events/page.tsx +++ b/apps/hub/app/(app)/events/page.tsx @@ -23,10 +23,6 @@ const page = async () => { }, }, }, - - orderBy: { - id: "desc", - }, }); return ( diff --git a/apps/hub/app/(app)/settings/_components/forms.tsx b/apps/hub/app/(app)/settings/_components/forms.tsx index 64feabec..15399821 100644 --- a/apps/hub/app/(app)/settings/_components/forms.tsx +++ b/apps/hub/app/(app)/settings/_components/forms.tsx @@ -23,6 +23,7 @@ import toast from "react-hot-toast"; import { CircleAlert, Trash2 } from "lucide-react"; import { deleteUser, sendVerificationLink } from "(app)/admin/user/action"; import { setStandardName } from "../../../../helper/discord"; +import { logAction } from "(auth)/login/_components/action"; export const ProfileForm = ({ user, @@ -101,6 +102,28 @@ export const ProfileForm = ({ userId: user.id, }); } + if (user.firstname !== values.firstname) { + await logAction("PROFILE_CHANGE", { + field: "firstname", + oldValue: user.firstname, + newValue: values.firstname, + }); + } + if (user.lastname !== values.lastname) { + await logAction("PROFILE_CHANGE", { + field: "lastname", + oldValue: user.lastname, + newValue: values.lastname, + }); + } + if (user.email !== values.email) { + await logAction("PROFILE_CHANGE", { + field: "email", + oldValue: user.email, + newValue: values.email, + }); + } + form.reset(values); if (user.email !== values.email) { await sendVerificationLink(user.id); diff --git a/apps/hub/app/(auth)/login/_components/Login.tsx b/apps/hub/app/(auth)/login/_components/Login.tsx index df02a2c2..f22db1d0 100644 --- a/apps/hub/app/(auth)/login/_components/Login.tsx +++ b/apps/hub/app/(auth)/login/_components/Login.tsx @@ -9,6 +9,7 @@ import { Toaster, toast } from "react-hot-toast"; import { z } from "zod"; import { Button } from "../../../_components/ui/Button"; import { useErrorBoundary } from "react-error-boundary"; +import { logAction } from "./action"; export const Login = () => { const { showBoundary } = useErrorBoundary(); @@ -46,6 +47,10 @@ export const Login = () => { }); return; } + + console.log("data", data); + + await logAction("LOGIN"); redirect(searchParams.get("redirect") || "/"); } catch (error) { showBoundary(error); diff --git a/apps/hub/app/(auth)/login/_components/action.ts b/apps/hub/app/(auth)/login/_components/action.ts new file mode 100644 index 00000000..fa38dd87 --- /dev/null +++ b/apps/hub/app/(auth)/login/_components/action.ts @@ -0,0 +1,53 @@ +"use server"; +import { LOG_TYPE, prisma } from "@repo/db"; +import { getServerSession } from "api/auth/[...nextauth]/auth"; +import { randomUUID } from "crypto"; +import { cookies, headers } from "next/headers"; + +export async function getOrSetDeviceId() { + const store = await cookies(); + let deviceId = store.get("device_id")?.value; + + if (!deviceId) { + deviceId = randomUUID(); + store.set("device_id", deviceId, { + httpOnly: true, + secure: true, + sameSite: "lax", + path: "/", + maxAge: 60 * 60 * 24 * 365, // 1 Jahr + }); + } + + return deviceId; +} + +export const logAction = async ( + type: LOG_TYPE, + otherValues?: { + field: string; + oldValue: string; + newValue: string; + }, +) => { + const headersList = await headers(); + const user = await getServerSession(); + + console.log("headers"); + + const deviceId = await getOrSetDeviceId(); + + await prisma.log.create({ + data: { + type, + browser: headersList.get("user-agent") || "unknown", + userId: user?.user.id, + deviceId: deviceId, + ip: + headersList.get("X-Forwarded-For") || + headersList.get("Forwarded") || + headersList.get("X-Real-IP"), + ...otherValues, + }, + }); +}; diff --git a/apps/hub/helper/discord.ts b/apps/hub/helper/discord.ts index a9a6b9d4..b432906b 100644 --- a/apps/hub/helper/discord.ts +++ b/apps/hub/helper/discord.ts @@ -38,6 +38,16 @@ export const removeRolesFromMember = async (memberId: string, roleIds: string[]) }); }; +export const sendReportEmbed = async (reportId: number) => { + discordAxiosClient + .post("/report/admin-embed", { + reportId, + }) + .catch((error) => { + console.error("Error sending report embed:", error); + }); +}; + export const setStandardName = async ({ memberId, userId, diff --git a/packages/database/prisma/schema/log.prisma b/packages/database/prisma/schema/log.prisma new file mode 100644 index 00000000..59911faf --- /dev/null +++ b/packages/database/prisma/schema/log.prisma @@ -0,0 +1,22 @@ +model Log { + id Int @id @default(autoincrement()) + type LOG_TYPE + userId String? + browser String? + deviceId String? + ip String? + field String? + oldValue String? + newValue String? + timestamp DateTime @default(now()) + + + User User? @relation(fields: [userId], references: [id], onDelete: Cascade) + @@map(name: "logs") +} + +enum LOG_TYPE { + LOGIN + PROFILE_CHANGE + REGISTER +} \ No newline at end of file diff --git a/packages/database/prisma/schema/migrations/20260129214442_fix_penalty_user_relation/migration.sql b/packages/database/prisma/schema/migrations/20260129214442_fix_penalty_user_relation/migration.sql new file mode 100644 index 00000000..3360abb2 --- /dev/null +++ b/packages/database/prisma/schema/migrations/20260129214442_fix_penalty_user_relation/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "users" ADD COLUMN "is_deleted" BOOLEAN NOT NULL DEFAULT false; diff --git a/packages/database/prisma/schema/migrations/20260129221239_user_log/migration.sql b/packages/database/prisma/schema/migrations/20260129221239_user_log/migration.sql new file mode 100644 index 00000000..4cd2e37e --- /dev/null +++ b/packages/database/prisma/schema/migrations/20260129221239_user_log/migration.sql @@ -0,0 +1,16 @@ +-- CreateEnum +CREATE TYPE "LOG_TYLE" AS ENUM ('LOGIN', 'PROFILE_CHANGE'); + +-- CreateTable +CREATE TABLE "logs" ( + "id" SERIAL NOT NULL, + "type" "LOG_TYLE" NOT NULL, + "userId" TEXT, + "ip" TEXT, + "timestamp" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "logs_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "logs" ADD CONSTRAINT "logs_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/database/prisma/schema/migrations/20260129222950_add_browser/migration.sql b/packages/database/prisma/schema/migrations/20260129222950_add_browser/migration.sql new file mode 100644 index 00000000..3a0795ea --- /dev/null +++ b/packages/database/prisma/schema/migrations/20260129222950_add_browser/migration.sql @@ -0,0 +1,16 @@ +/* + Warnings: + + - Changed the type of `type` on the `logs` table. No cast exists, the column would be dropped and recreated, which cannot be done if there is data, since the column is required. + +*/ +-- CreateEnum +CREATE TYPE "LOG_TYPE" AS ENUM ('LOGIN', 'PROFILE_CHANGE', 'REGISTER'); + +-- AlterTable +ALTER TABLE "logs" ADD COLUMN "browser" TEXT, +DROP COLUMN "type", +ADD COLUMN "type" "LOG_TYPE" NOT NULL; + +-- DropEnum +DROP TYPE "LOG_TYLE"; diff --git a/packages/database/prisma/schema/migrations/20260129224931_ce_id/migration.sql b/packages/database/prisma/schema/migrations/20260129224931_ce_id/migration.sql new file mode 100644 index 00000000..a29ac825 --- /dev/null +++ b/packages/database/prisma/schema/migrations/20260129224931_ce_id/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "logs" ADD COLUMN "deviceId" TEXT; diff --git a/packages/database/prisma/schema/migrations/20260129230237_profile_values/migration.sql b/packages/database/prisma/schema/migrations/20260129230237_profile_values/migration.sql new file mode 100644 index 00000000..fdeea722 --- /dev/null +++ b/packages/database/prisma/schema/migrations/20260129230237_profile_values/migration.sql @@ -0,0 +1,4 @@ +-- AlterTable +ALTER TABLE "logs" ADD COLUMN "field" TEXT, +ADD COLUMN "newValue" TEXT, +ADD COLUMN "oldValue" TEXT; diff --git a/packages/database/prisma/schema/penalty.prisma b/packages/database/prisma/schema/penalty.prisma index 8a208b9a..743d16c2 100644 --- a/packages/database/prisma/schema/penalty.prisma +++ b/packages/database/prisma/schema/penalty.prisma @@ -1,12 +1,11 @@ -model AuditLog { +model Penalty { id Int @id @default(autoincrement()) userId String createdUserId String? reportId Int? - // Generalized action type to cover penalties and user history events - action AuditLogAction? - reason String? + type PenaltyType + reason String until DateTime? suspended Boolean @default(false) @@ -14,18 +13,14 @@ model AuditLog { timestamp DateTime @default(now()) // relations: - User User @relation(fields: [userId], references: [id], onDelete: Cascade) + User User @relation("User", fields: [userId], references: [id], onDelete: Cascade) CreatedUser User? @relation("CreatedPenalties", fields: [createdUserId], references: [id]) Report Report? @relation(fields: [reportId], references: [id]) } -enum AuditLogAction { - // Penalty actions +enum PenaltyType { KICK TIME_BAN PERMISSIONS_REVOCED BAN - // User history events - USER_DELETED - USER_PROFILE_UPDATED } diff --git a/packages/database/prisma/schema/report.prisma b/packages/database/prisma/schema/report.prisma index 33c0e9a8..9264f1e6 100644 --- a/packages/database/prisma/schema/report.prisma +++ b/packages/database/prisma/schema/report.prisma @@ -10,8 +10,8 @@ model Report { reviewerUserId String? // relations: - Sender User? @relation("SentReports", fields: [senderUserId], references: [id]) - Reported User @relation("ReceivedReports", fields: [reportedUserId], references: [id], onDelete: Cascade) - Reviewer User? @relation("ReviewedReports", fields: [reviewerUserId], references: [id]) - AuditLog AuditLog[] + Sender User? @relation("SentReports", fields: [senderUserId], references: [id]) + Reported User @relation("ReceivedReports", fields: [reportedUserId], references: [id], onDelete: Cascade) + Reviewer User? @relation("ReviewedReports", fields: [reviewerUserId], references: [id]) + Penalties Penalty[] } diff --git a/packages/database/prisma/schema/user.prisma b/packages/database/prisma/schema/user.prisma index f8023cb3..45cf2206 100644 --- a/packages/database/prisma/schema/user.prisma +++ b/packages/database/prisma/schema/user.prisma @@ -82,14 +82,13 @@ model User { ConnectedDispatcher ConnectedDispatcher[] ConnectedAircraft ConnectedAircraft[] PositionLog PositionLog[] - Penaltys AuditLog[] - CreatedAuditLogEntrys AuditLog[] @relation("CreatedAuditLogEntrys") - Bookings Booking[] - auditLogs AuditLog[] + Penaltys Penalty[] @relation("User") + CreatedPenalties Penalty[] @relation("CreatedPenalties") + Logs Log[] + Bookings Booking[] DiscordAccount DiscordAccount? FormerDiscordAccounts FormerDiscordAccount[] - auditLogs AuditLog[] @@map(name: "users") }