completed Account Log
This commit is contained in:
@@ -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>
|
||||
),
|
||||
|
||||
137
apps/hub/app/(app)/admin/user/[id]/_components/AccountLog.tsx
Normal file
137
apps/hub/app/(app)/admin/user/[id]/_components/AccountLog.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user