v2.0.8 #156

Merged
PxlLoewe merged 24 commits from staging into release 2026-01-31 22:08:50 +00:00
8 changed files with 215 additions and 64 deletions
Showing only changes of commit 2154684223 - Show all commits

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

@@ -11,6 +11,7 @@ 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;
@@ -175,44 +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">
<PaginatedTable
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 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<Log>[]
}
/>
<AccountLog sameIPLogs={sameIpLogs} userId={user.id} />
</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">
<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

@@ -3,6 +3,7 @@ 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();
@@ -33,9 +34,44 @@ export const logAction = async (
const headersList = await headers();
const user = await getServerSession();
console.log("headers");
const ip =
headersList.get("X-Forwarded-For") ||
headersList.get("Forwarded") ||
headersList.get("X-Real-IP");
const deviceId = await getOrSetDeviceId();
if (type == "LOGIN") {
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: {
@@ -43,10 +79,7 @@ export const logAction = async (
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"),
ip,
...otherValues,
},
});

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} />
<Pagination totalPages={Math.ceil(total / rowsPerPage)} page={page} setPage={setPage} />
</>
<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

@@ -41,6 +41,7 @@ export const getPublicUser = (
user: User,
options = {
ignorePrivacy: false,
fullLastName: false,
},
): PublicUser => {
const lastName = user.lastname
@@ -49,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

@@ -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)
@@ -19,13 +18,9 @@ model AuditLog {
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
}