Merge pull request #154 from VAR-Virtual-Air-Rescue/Enhanced-Audit-log-for-user-Profiles

Enhanced audit log for user profiles
This commit was merged in pull request #154.
This commit is contained in:
PxlLoewe
2026-01-30 17:01:50 +01:00
committed by GitHub
24 changed files with 442 additions and 40 deletions

View File

@@ -12,9 +12,9 @@ export const reportColumns: ColumnDef<Report & { Sender?: User; Reported: User }
return (
<div className="text-center">
{row.getValue("reviewed") ? (
<Check className="text-green-500 w-5 h-5" />
<Check className="h-5 w-5 text-green-500" />
) : (
<X className="text-red-500 w-5 h-5" />
<X className="h-5 w-5 text-red-500" />
)}
</div>
);
@@ -31,13 +31,13 @@ export const reportColumns: ColumnDef<Report & { Sender?: User; Reported: User }
},
{
accessorKey: "reportedUserRole",
header: "Rolle des gemeldeten Nutzers",
header: "Rolle",
cell: ({ row }) => {
const role = row.getValue("reportedUserRole") as string | undefined;
const Icon = role ? (role.startsWith("LST") ? Workflow : Plane) : ShieldQuestion;
return (
<span className="flex items-center gap-2">
<Icon className="w-4 h-4" />
<Icon className="h-4 w-4" />
{role || "Unbekannt"}
</span>
);
@@ -62,7 +62,7 @@ export const reportColumns: ColumnDef<Report & { Sender?: User; Reported: User }
cell: ({ row }) => (
<Link href={`/admin/report/${row.original.id}`}>
<button className="btn btn-sm btn-outline btn-info flex items-center gap-2">
<Eye className="w-4 h-4" /> Anzeigen
<Eye className="h-4 w-4" /> Anzeigen
</button>
</Link>
),

View File

@@ -0,0 +1,137 @@
"use client";
import { Log, Prisma, User } from "@repo/db";
import { cn } from "@repo/shared-components";
import { ColumnDef } from "@tanstack/react-table";
import { PaginatedTable, PaginatedTableRef } from "_components/PaginatedTable";
import { Printer } from "lucide-react";
import Link from "next/link";
import { useRef, useState } from "react";
export const AccountLog = ({ sameIPLogs, userId }: { sameIPLogs: Log[]; userId: string }) => {
const [onlyImportant, setOnlyImportant] = useState(true);
const tableRef = useRef<PaginatedTableRef>(null);
return (
<div className="card-body">
<div className="card-title flex justify-between">
<h2 className="flex items-center gap-2">
<Printer className="h-5 w-5" /> Account Log
</h2>
<p className="text-end text-sm text-gray-500">
Hier werden Logs angezeigt, die dem Nutzer zugeordnet sind oder von der selben IP stammen.
</p>
</div>
<PaginatedTable
ref={tableRef}
rightOfPagination={
<div className="ml-4 flex items-center gap-2">
<input
type="checkbox"
id="ImportantOnly"
checked={onlyImportant}
onChange={() => {
setOnlyImportant(!onlyImportant);
tableRef.current?.refresh();
}}
className="checkbox checkbox-sm"
/>
<label
htmlFor="ImportantOnly"
className="min-w-[210px] cursor-pointer select-none text-sm"
>
Unauffällige Einträge ausblenden
</label>
</div>
}
getFilter={(searchTerm) => {
return {
AND: [
{
OR: [
{ ip: { contains: searchTerm } },
{ browser: { contains: searchTerm } },
{
id: {
in: sameIPLogs
.filter((log) => log.id.toString().includes(searchTerm))
.map((log) => log.id),
},
},
],
},
onlyImportant
? {
OR: [
{
id: {
in: sameIPLogs
.filter((log) => log.id.toString().includes(searchTerm))
.map((log) => log.id),
},
},
{
type: {
in: ["REGISTER", "PROFILE_CHANGE"],
},
},
],
}
: {},
],
} as Prisma.LogWhereInput;
}}
include={{
User: true,
}}
prismaModel={"log"}
columns={
[
{
header: "Zeitstempel",
accessorKey: "timestamp",
cell: (info) => new Date(info.getValue<string>()).toLocaleString("de-DE"),
},
{
header: "Aktion",
accessorKey: "action",
cell: ({ row }) => {
const action = row.original.type;
if (action !== "PROFILE_CHANGE") {
return <span className="text-blue-500">{action}</span>;
} else {
return (
<span className="text-yellow-500">{`${row.original.field} von "${row.original.oldValue}" zu "${row.original.newValue}"`}</span>
);
}
},
},
{
header: "IP-Adresse",
accessorKey: "ip",
},
{
header: "Browser",
accessorKey: "browser",
},
{
header: "Benutzer",
accessorKey: "userId",
cell: ({ row }) => {
return (
<Link
href={`/admin/user/${row.original.userId}`}
className={cn("link", userId !== row.original.userId && "text-red-400")}
>
{row.original.User
? `${row.original.User.firstname} ${row.original.User.lastname} - ${row.original.User.publicId}`
: "Unbekannt"}
</Link>
);
},
},
] as ColumnDef<Log & { User: User }>[]
}
/>
</div>
);
};

View File

@@ -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,9 @@ 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";
import { AccountLog } from "./_components/AccountLog";
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
@@ -35,6 +38,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,
@@ -153,9 +176,12 @@ export default async function Page({ params }: { params: Promise<{ id: string }>
/>
</div>
<div className="card bg-base-200 col-span-6 mb-4 shadow-xl xl:col-span-6">
<AccountLog sameIPLogs={sameIpLogs} userId={user.id} />
</div>
<div className="card bg-base-200 col-span-6 mb-4 shadow-xl xl:col-span-3">
<UserReports user={user} />
</div>
<div className="card bg-base-200 col-span-6 mb-4 shadow-xl xl:col-span-6">
<div className="card bg-base-200 col-span-6 mb-4 shadow-xl xl:col-span-3">
<UserPenalties user={user} />
</div>
<div className="card bg-base-200 col-span-6 mb-4 shadow-xl xl:col-span-6">

View File

@@ -58,10 +58,13 @@ export const deletePilotHistory = async (id: number) => {
});
};
export const deleteUser = async (id: string) => {
return await prisma.user.delete({
return await prisma.user.update({
where: {
id: id,
},
data: {
isDeleted: true,
},
});
};

View File

@@ -10,6 +10,9 @@ const page = async () => {
if (!user) return null;
const events = await prisma.event.findMany({
orderBy: {
id: "desc",
},
where: {
hidden: false,
},
@@ -20,10 +23,6 @@ const page = async () => {
},
},
},
orderBy: {
id: "desc",
},
});
return (

View File

@@ -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);

View File

@@ -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);

View File

@@ -0,0 +1,87 @@
"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";
import { sendReportEmbed } from "../../../../helper/discord";
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;
userId?: string;
},
) => {
const headersList = await headers();
const user = await getServerSession();
const ip =
headersList.get("X-Forwarded-For") ||
headersList.get("Forwarded") ||
headersList.get("X-Real-IP");
const deviceId = await getOrSetDeviceId();
if (type == "LOGIN" || type == "REGISTER") {
const existingLogs = await prisma.log.findMany({
where: {
type: "LOGIN",
userId: {
not: user?.user.id,
},
OR: [
{
ip: ip,
},
{
deviceId: deviceId,
},
],
},
});
if (existingLogs.length > 0 && user?.user.id) {
// Möglicherweise ein doppelter Account, Report erstellen
const report = await prisma.report.create({
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: ${log.timestamp}`)
.join("\n")}`,
reportedUserId: user?.user.id,
reportedUserRole: "LOGIN - Doppelter Account Verdacht",
},
});
await sendReportEmbed(report.id);
}
}
await prisma.log.create({
data: {
type,
browser: headersList.get("user-agent") || "unknown",
userId: user?.user.id || otherValues?.userId,
deviceId: deviceId,
ip,
...otherValues,
},
});
};

View File

@@ -9,6 +9,7 @@ import { useState } from "react";
import { Button } from "../../../_components/ui/Button";
import { sendVerificationLink } from "(app)/admin/user/action";
import toast from "react-hot-toast";
import { logAction } from "(auth)/login/_components/action";
export const Register = () => {
const schema = z
@@ -93,6 +94,9 @@ export const Register = () => {
return;
}
await sendVerificationLink(user.id);
await logAction("REGISTER", {
userId: user.id,
});
await signIn("credentials", {
callbackUrl: "/",
email: user.email,

View File

@@ -53,5 +53,6 @@ export const register = async ({ password, ...user }: Omit<Prisma.UserCreateInpu
password: hashedPassword,
},
});
return newUser;
};

View File

@@ -21,6 +21,7 @@ interface PaginatedTableProps<TData, TWhere extends object>
leftOfSearch?: React.ReactNode;
rightOfSearch?: React.ReactNode;
leftOfPagination?: React.ReactNode;
rightOfPagination?: React.ReactNode;
supressQuery?: boolean;
ref?: Ref<PaginatedTableRef>;
}
@@ -37,6 +38,7 @@ export function PaginatedTable<TData, TWhere extends object>({
leftOfSearch,
rightOfSearch,
leftOfPagination,
rightOfPagination,
supressQuery,
...restProps
}: PaginatedTableProps<TData, TWhere>) {
@@ -159,10 +161,9 @@ export function PaginatedTable<TData, TWhere extends object>({
<SortableTable data={data} prismaModel={prismaModel} setOrderBy={setOrderBy} {...restProps} />
<div className="items-between flex">
{leftOfPagination}
<>
<RowsPerPage rowsPerPage={rowsPerPage} setRowsPerPage={setRowsPerPage} />
{rightOfPagination}
<Pagination totalPages={Math.ceil(total / rowsPerPage)} page={page} setPage={setPage} />
</>
</div>
</div>
);

View File

@@ -95,7 +95,7 @@ export const RowsPerPage = ({
}) => {
return (
<select
className="select w-32"
className="select select-sm w-32"
value={rowsPerPage}
onChange={(e) => setRowsPerPage(Number(e.target.value))}
>
@@ -122,11 +122,15 @@ export const Pagination = ({
if (totalPages === 0) return null;
return (
<div className="join w-full justify-end">
<button className="join-item btn" disabled={page === 0} onClick={() => setPage(page - 1)}>
<ArrowLeft size={16} />
<button
className="join-item btn btn-sm"
disabled={page === 0}
onClick={() => setPage(page - 1)}
>
<ArrowLeft size={14} />
</button>
<select
className="select join-item w-16"
className="select select-sm join-item w-16"
value={page}
onChange={(e) => setPage(Number(e.target.value))}
>
@@ -137,11 +141,11 @@ export const Pagination = ({
))}
</select>
<button
className="join-item btn"
className="join-item btn btn-sm"
disabled={page === totalPages - 1}
onClick={() => page < totalPages && setPage(page + 1)}
>
<ArrowRight size={16} />
<ArrowRight size={14} />
</button>
</div>
);

View File

@@ -23,6 +23,7 @@ export const options: AuthOptions = {
contains: credentials.email,
mode: "insensitive",
},
isDeleted: false,
},
});
const v1User = (oldUser as OldUser[]).find(
@@ -87,6 +88,7 @@ export const options: AuthOptions = {
const dbUser = await prisma.user.findUnique({
where: {
id: token?.sub,
isDeleted: false,
},
});
if (!dbUser) {

View File

@@ -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,

View File

@@ -1,5 +1,27 @@
import { User } from "../../generated/client";
// USer History
interface UserDeletedEvent {
type: "USER_DELETED";
reason: string;
date: string;
by: string;
}
interface UserProfileUpdatedEvent {
type: "USER_PROFILE_UPDATED";
changes: {
field: string;
oldValue: string;
newValue: string;
};
date: string;
by: string;
}
export type UserHistoryEvent = UserDeletedEvent | UserProfileUpdatedEvent;
export interface PublicUser {
firstname: string;
lastname: string;
@@ -19,6 +41,7 @@ export const getPublicUser = (
user: User,
options = {
ignorePrivacy: false,
fullLastName: false,
},
): PublicUser => {
const lastName = user.lastname
@@ -27,6 +50,17 @@ export const getPublicUser = (
.map((part) => `${part[0] || ""}.`)
.join(" ");
if (options.fullLastName) {
return {
firstname: user.firstname,
lastname: user.settingsHideLastname && !options.ignorePrivacy ? "" : user.lastname,
fullName:
`${user.firstname} ${user.settingsHideLastname && !options.ignorePrivacy ? "" : user.lastname}`.trim(),
publicId: user.publicId,
badges: user.badges,
};
}
return {
firstname: user.firstname,
lastname: user.settingsHideLastname && !options.ignorePrivacy ? "" : lastName.trim(), // Only take the first letter of each section of the last name

View File

@@ -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
}

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "users" ADD COLUMN "is_deleted" BOOLEAN NOT NULL DEFAULT false;

View File

@@ -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;

View File

@@ -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";

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "logs" ADD COLUMN "deviceId" TEXT;

View File

@@ -0,0 +1,4 @@
-- AlterTable
ALTER TABLE "logs" ADD COLUMN "field" TEXT,
ADD COLUMN "newValue" TEXT,
ADD COLUMN "oldValue" TEXT;

View File

@@ -13,7 +13,7 @@ model Penalty {
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])
}

View File

@@ -13,5 +13,5 @@ model Report {
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])
Penalty Penalty[]
Penalties Penalty[]
}

View File

@@ -66,6 +66,9 @@ model User {
Duplicates User[] @relation("CanonicalUser")
duplicateDetectedAt DateTime? @map(name: "duplicate_detected_at")
duplicateReason String? @map(name: "duplicate_reason")
isDeleted Boolean @default(false) @map(name: "is_deleted")
// relations:
oauthTokens OAuthToken[]
participants Participant[]
@@ -79,10 +82,11 @@ model User {
ConnectedDispatcher ConnectedDispatcher[]
ConnectedAircraft ConnectedAircraft[]
PositionLog PositionLog[]
Penaltys Penalty[]
Penaltys Penalty[] @relation("User")
CreatedPenalties Penalty[] @relation("CreatedPenalties")
Bookings Booking[]
Logs Log[]
Bookings Booking[]
DiscordAccount DiscordAccount?
FormerDiscordAccounts FormerDiscordAccount[]