Enhanced audit log for user profiles #154
@@ -12,9 +12,9 @@ export const reportColumns: ColumnDef<Report & { Sender?: User; Reported: User }
|
|||||||
return (
|
return (
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
{row.getValue("reviewed") ? (
|
{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>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -31,13 +31,13 @@ export const reportColumns: ColumnDef<Report & { Sender?: User; Reported: User }
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "reportedUserRole",
|
accessorKey: "reportedUserRole",
|
||||||
header: "Rolle des gemeldeten Nutzers",
|
header: "Rolle",
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const role = row.getValue("reportedUserRole") as string | undefined;
|
const role = row.getValue("reportedUserRole") as string | undefined;
|
||||||
const Icon = role ? (role.startsWith("LST") ? Workflow : Plane) : ShieldQuestion;
|
const Icon = role ? (role.startsWith("LST") ? Workflow : Plane) : ShieldQuestion;
|
||||||
return (
|
return (
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<Icon className="w-4 h-4" />
|
<Icon className="h-4 w-4" />
|
||||||
{role || "Unbekannt"}
|
{role || "Unbekannt"}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
@@ -62,7 +62,7 @@ export const reportColumns: ColumnDef<Report & { Sender?: User; Reported: User }
|
|||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Link href={`/admin/report/${row.original.id}`}>
|
<Link href={`/admin/report/${row.original.id}`}>
|
||||||
<button className="btn btn-sm btn-outline btn-info flex items-center gap-2">
|
<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>
|
</button>
|
||||||
</Link>
|
</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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { PersonIcon } from "@radix-ui/react-icons";
|
import { PersonIcon } from "@radix-ui/react-icons";
|
||||||
import { prisma } from "@repo/db";
|
import { Log, prisma } from "@repo/db";
|
||||||
import {
|
import {
|
||||||
AdminForm,
|
AdminForm,
|
||||||
ConnectionHistory,
|
ConnectionHistory,
|
||||||
@@ -9,6 +9,9 @@ import {
|
|||||||
} from "./_components/forms";
|
} from "./_components/forms";
|
||||||
import { Error } from "../../../../_components/Error";
|
import { Error } from "../../../../_components/Error";
|
||||||
import { getUserPenaltys } from "@repo/shared-components";
|
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 }> }) {
|
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
|
||||||
const { id } = await params;
|
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({
|
const formerDiscordAccounts = await prisma.formerDiscordAccount.findMany({
|
||||||
where: {
|
where: {
|
||||||
userId: user?.id,
|
userId: user?.id,
|
||||||
@@ -153,9 +176,12 @@ export default async function Page({ params }: { params: Promise<{ id: string }>
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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-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} />
|
<UserReports user={user} />
|
||||||
</div>
|
</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} />
|
<UserPenalties user={user} />
|
||||||
</div>
|
</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-6">
|
||||||
|
|||||||
@@ -58,10 +58,13 @@ export const deletePilotHistory = async (id: number) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
export const deleteUser = async (id: string) => {
|
export const deleteUser = async (id: string) => {
|
||||||
return await prisma.user.delete({
|
return await prisma.user.update({
|
||||||
where: {
|
where: {
|
||||||
id: id,
|
id: id,
|
||||||
},
|
},
|
||||||
|
data: {
|
||||||
|
isDeleted: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ const page = async () => {
|
|||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
|
|
||||||
const events = await prisma.event.findMany({
|
const events = await prisma.event.findMany({
|
||||||
|
orderBy: {
|
||||||
|
id: "desc",
|
||||||
|
},
|
||||||
where: {
|
where: {
|
||||||
hidden: false,
|
hidden: false,
|
||||||
},
|
},
|
||||||
@@ -20,10 +23,6 @@ const page = async () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
orderBy: {
|
|
||||||
id: "desc",
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import toast from "react-hot-toast";
|
|||||||
import { CircleAlert, Trash2 } from "lucide-react";
|
import { CircleAlert, Trash2 } from "lucide-react";
|
||||||
import { deleteUser, sendVerificationLink } from "(app)/admin/user/action";
|
import { deleteUser, sendVerificationLink } from "(app)/admin/user/action";
|
||||||
import { setStandardName } from "../../../../helper/discord";
|
import { setStandardName } from "../../../../helper/discord";
|
||||||
|
import { logAction } from "(auth)/login/_components/action";
|
||||||
|
|
||||||
export const ProfileForm = ({
|
export const ProfileForm = ({
|
||||||
user,
|
user,
|
||||||
@@ -101,6 +102,28 @@ export const ProfileForm = ({
|
|||||||
userId: user.id,
|
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);
|
form.reset(values);
|
||||||
if (user.email !== values.email) {
|
if (user.email !== values.email) {
|
||||||
await sendVerificationLink(user.id);
|
await sendVerificationLink(user.id);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { Toaster, toast } from "react-hot-toast";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { Button } from "../../../_components/ui/Button";
|
import { Button } from "../../../_components/ui/Button";
|
||||||
import { useErrorBoundary } from "react-error-boundary";
|
import { useErrorBoundary } from "react-error-boundary";
|
||||||
|
import { logAction } from "./action";
|
||||||
|
|
||||||
export const Login = () => {
|
export const Login = () => {
|
||||||
const { showBoundary } = useErrorBoundary();
|
const { showBoundary } = useErrorBoundary();
|
||||||
@@ -46,6 +47,10 @@ export const Login = () => {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log("data", data);
|
||||||
|
|
||||||
|
await logAction("LOGIN");
|
||||||
redirect(searchParams.get("redirect") || "/");
|
redirect(searchParams.get("redirect") || "/");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showBoundary(error);
|
showBoundary(error);
|
||||||
|
|||||||
87
apps/hub/app/(auth)/login/_components/action.ts
Normal file
87
apps/hub/app/(auth)/login/_components/action.ts
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -9,6 +9,7 @@ import { useState } from "react";
|
|||||||
import { Button } from "../../../_components/ui/Button";
|
import { Button } from "../../../_components/ui/Button";
|
||||||
import { sendVerificationLink } from "(app)/admin/user/action";
|
import { sendVerificationLink } from "(app)/admin/user/action";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
|
import { logAction } from "(auth)/login/_components/action";
|
||||||
|
|
||||||
export const Register = () => {
|
export const Register = () => {
|
||||||
const schema = z
|
const schema = z
|
||||||
@@ -93,6 +94,9 @@ export const Register = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await sendVerificationLink(user.id);
|
await sendVerificationLink(user.id);
|
||||||
|
await logAction("REGISTER", {
|
||||||
|
userId: user.id,
|
||||||
|
});
|
||||||
await signIn("credentials", {
|
await signIn("credentials", {
|
||||||
callbackUrl: "/",
|
callbackUrl: "/",
|
||||||
email: user.email,
|
email: user.email,
|
||||||
|
|||||||
@@ -53,5 +53,6 @@ export const register = async ({ password, ...user }: Omit<Prisma.UserCreateInpu
|
|||||||
password: hashedPassword,
|
password: hashedPassword,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return newUser;
|
return newUser;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ interface PaginatedTableProps<TData, TWhere extends object>
|
|||||||
leftOfSearch?: React.ReactNode;
|
leftOfSearch?: React.ReactNode;
|
||||||
rightOfSearch?: React.ReactNode;
|
rightOfSearch?: React.ReactNode;
|
||||||
leftOfPagination?: React.ReactNode;
|
leftOfPagination?: React.ReactNode;
|
||||||
|
rightOfPagination?: React.ReactNode;
|
||||||
supressQuery?: boolean;
|
supressQuery?: boolean;
|
||||||
ref?: Ref<PaginatedTableRef>;
|
ref?: Ref<PaginatedTableRef>;
|
||||||
}
|
}
|
||||||
@@ -37,6 +38,7 @@ export function PaginatedTable<TData, TWhere extends object>({
|
|||||||
leftOfSearch,
|
leftOfSearch,
|
||||||
rightOfSearch,
|
rightOfSearch,
|
||||||
leftOfPagination,
|
leftOfPagination,
|
||||||
|
rightOfPagination,
|
||||||
supressQuery,
|
supressQuery,
|
||||||
...restProps
|
...restProps
|
||||||
}: PaginatedTableProps<TData, TWhere>) {
|
}: PaginatedTableProps<TData, TWhere>) {
|
||||||
@@ -159,10 +161,9 @@ export function PaginatedTable<TData, TWhere extends object>({
|
|||||||
<SortableTable data={data} prismaModel={prismaModel} setOrderBy={setOrderBy} {...restProps} />
|
<SortableTable data={data} prismaModel={prismaModel} setOrderBy={setOrderBy} {...restProps} />
|
||||||
<div className="items-between flex">
|
<div className="items-between flex">
|
||||||
{leftOfPagination}
|
{leftOfPagination}
|
||||||
<>
|
<RowsPerPage rowsPerPage={rowsPerPage} setRowsPerPage={setRowsPerPage} />
|
||||||
<RowsPerPage rowsPerPage={rowsPerPage} setRowsPerPage={setRowsPerPage} />
|
{rightOfPagination}
|
||||||
<Pagination totalPages={Math.ceil(total / rowsPerPage)} page={page} setPage={setPage} />
|
<Pagination totalPages={Math.ceil(total / rowsPerPage)} page={page} setPage={setPage} />
|
||||||
</>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ export const RowsPerPage = ({
|
|||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<select
|
<select
|
||||||
className="select w-32"
|
className="select select-sm w-32"
|
||||||
value={rowsPerPage}
|
value={rowsPerPage}
|
||||||
onChange={(e) => setRowsPerPage(Number(e.target.value))}
|
onChange={(e) => setRowsPerPage(Number(e.target.value))}
|
||||||
>
|
>
|
||||||
@@ -122,11 +122,15 @@ export const Pagination = ({
|
|||||||
if (totalPages === 0) return null;
|
if (totalPages === 0) return null;
|
||||||
return (
|
return (
|
||||||
<div className="join w-full justify-end">
|
<div className="join w-full justify-end">
|
||||||
<button className="join-item btn" disabled={page === 0} onClick={() => setPage(page - 1)}>
|
<button
|
||||||
<ArrowLeft size={16} />
|
className="join-item btn btn-sm"
|
||||||
|
disabled={page === 0}
|
||||||
|
onClick={() => setPage(page - 1)}
|
||||||
|
>
|
||||||
|
<ArrowLeft size={14} />
|
||||||
</button>
|
</button>
|
||||||
<select
|
<select
|
||||||
className="select join-item w-16"
|
className="select select-sm join-item w-16"
|
||||||
value={page}
|
value={page}
|
||||||
onChange={(e) => setPage(Number(e.target.value))}
|
onChange={(e) => setPage(Number(e.target.value))}
|
||||||
>
|
>
|
||||||
@@ -137,11 +141,11 @@ export const Pagination = ({
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<button
|
<button
|
||||||
className="join-item btn"
|
className="join-item btn btn-sm"
|
||||||
disabled={page === totalPages - 1}
|
disabled={page === totalPages - 1}
|
||||||
onClick={() => page < totalPages && setPage(page + 1)}
|
onClick={() => page < totalPages && setPage(page + 1)}
|
||||||
>
|
>
|
||||||
<ArrowRight size={16} />
|
<ArrowRight size={14} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export const options: AuthOptions = {
|
|||||||
contains: credentials.email,
|
contains: credentials.email,
|
||||||
mode: "insensitive",
|
mode: "insensitive",
|
||||||
},
|
},
|
||||||
|
isDeleted: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const v1User = (oldUser as OldUser[]).find(
|
const v1User = (oldUser as OldUser[]).find(
|
||||||
@@ -87,6 +88,7 @@ export const options: AuthOptions = {
|
|||||||
const dbUser = await prisma.user.findUnique({
|
const dbUser = await prisma.user.findUnique({
|
||||||
where: {
|
where: {
|
||||||
id: token?.sub,
|
id: token?.sub,
|
||||||
|
isDeleted: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!dbUser) {
|
if (!dbUser) {
|
||||||
|
|||||||
@@ -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 ({
|
export const setStandardName = async ({
|
||||||
memberId,
|
memberId,
|
||||||
userId,
|
userId,
|
||||||
|
|||||||
@@ -1,5 +1,27 @@
|
|||||||
import { User } from "../../generated/client";
|
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 {
|
export interface PublicUser {
|
||||||
firstname: string;
|
firstname: string;
|
||||||
lastname: string;
|
lastname: string;
|
||||||
@@ -19,6 +41,7 @@ export const getPublicUser = (
|
|||||||
user: User,
|
user: User,
|
||||||
options = {
|
options = {
|
||||||
ignorePrivacy: false,
|
ignorePrivacy: false,
|
||||||
|
fullLastName: false,
|
||||||
},
|
},
|
||||||
): PublicUser => {
|
): PublicUser => {
|
||||||
const lastName = user.lastname
|
const lastName = user.lastname
|
||||||
@@ -27,6 +50,17 @@ export const getPublicUser = (
|
|||||||
.map((part) => `${part[0] || ""}.`)
|
.map((part) => `${part[0] || ""}.`)
|
||||||
.join(" ");
|
.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 {
|
return {
|
||||||
firstname: user.firstname,
|
firstname: user.firstname,
|
||||||
lastname: user.settingsHideLastname && !options.ignorePrivacy ? "" : lastName.trim(), // Only take the first letter of each section of the last name
|
lastname: user.settingsHideLastname && !options.ignorePrivacy ? "" : lastName.trim(), // Only take the first letter of each section of the last name
|
||||||
|
|||||||
22
packages/database/prisma/schema/log.prisma
Normal file
22
packages/database/prisma/schema/log.prisma
Normal 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "users" ADD COLUMN "is_deleted" BOOLEAN NOT NULL DEFAULT false;
|
||||||
@@ -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;
|
||||||
@@ -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";
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "logs" ADD COLUMN "deviceId" TEXT;
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "logs" ADD COLUMN "field" TEXT,
|
||||||
|
ADD COLUMN "newValue" TEXT,
|
||||||
|
ADD COLUMN "oldValue" TEXT;
|
||||||
@@ -13,7 +13,7 @@ model Penalty {
|
|||||||
timestamp DateTime @default(now())
|
timestamp DateTime @default(now())
|
||||||
|
|
||||||
// relations:
|
// 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])
|
CreatedUser User? @relation("CreatedPenalties", fields: [createdUserId], references: [id])
|
||||||
Report Report? @relation(fields: [reportId], references: [id])
|
Report Report? @relation(fields: [reportId], references: [id])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ model Report {
|
|||||||
reviewerUserId String?
|
reviewerUserId String?
|
||||||
|
|
||||||
// relations:
|
// relations:
|
||||||
Sender User? @relation("SentReports", fields: [senderUserId], references: [id])
|
Sender User? @relation("SentReports", fields: [senderUserId], references: [id])
|
||||||
Reported User @relation("ReceivedReports", fields: [reportedUserId], references: [id], onDelete: Cascade)
|
Reported User @relation("ReceivedReports", fields: [reportedUserId], references: [id], onDelete: Cascade)
|
||||||
Reviewer User? @relation("ReviewedReports", fields: [reviewerUserId], references: [id])
|
Reviewer User? @relation("ReviewedReports", fields: [reviewerUserId], references: [id])
|
||||||
Penalty Penalty[]
|
Penalties Penalty[]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,18 +54,21 @@ model User {
|
|||||||
emailVerificationExpiresAt DateTime? @map(name: "email_verification_expires_at")
|
emailVerificationExpiresAt DateTime? @map(name: "email_verification_expires_at")
|
||||||
emailVerified Boolean @default(false)
|
emailVerified Boolean @default(false)
|
||||||
|
|
||||||
image String?
|
image String?
|
||||||
badges BADGES[] @default([])
|
badges BADGES[] @default([])
|
||||||
permissions PERMISSION[] @default([])
|
permissions PERMISSION[] @default([])
|
||||||
createdAt DateTime @default(now()) @map(name: "created_at")
|
createdAt DateTime @default(now()) @map(name: "created_at")
|
||||||
updatedAt DateTime @default(now()) @map(name: "updated_at")
|
updatedAt DateTime @default(now()) @map(name: "updated_at")
|
||||||
isBanned Boolean @default(false) @map(name: "is_banned")
|
isBanned Boolean @default(false) @map(name: "is_banned")
|
||||||
// Duplicate handling:
|
// Duplicate handling:
|
||||||
canonicalUserId String? @map(name: "canonical_user_id")
|
canonicalUserId String? @map(name: "canonical_user_id")
|
||||||
CanonicalUser User? @relation("CanonicalUser", fields: [canonicalUserId], references: [id])
|
CanonicalUser User? @relation("CanonicalUser", fields: [canonicalUserId], references: [id])
|
||||||
Duplicates User[] @relation("CanonicalUser")
|
Duplicates User[] @relation("CanonicalUser")
|
||||||
duplicateDetectedAt DateTime? @map(name: "duplicate_detected_at")
|
duplicateDetectedAt DateTime? @map(name: "duplicate_detected_at")
|
||||||
duplicateReason String? @map(name: "duplicate_reason")
|
duplicateReason String? @map(name: "duplicate_reason")
|
||||||
|
|
||||||
|
isDeleted Boolean @default(false) @map(name: "is_deleted")
|
||||||
|
|
||||||
// relations:
|
// relations:
|
||||||
oauthTokens OAuthToken[]
|
oauthTokens OAuthToken[]
|
||||||
participants Participant[]
|
participants Participant[]
|
||||||
@@ -79,10 +82,11 @@ model User {
|
|||||||
ConnectedDispatcher ConnectedDispatcher[]
|
ConnectedDispatcher ConnectedDispatcher[]
|
||||||
ConnectedAircraft ConnectedAircraft[]
|
ConnectedAircraft ConnectedAircraft[]
|
||||||
PositionLog PositionLog[]
|
PositionLog PositionLog[]
|
||||||
Penaltys Penalty[]
|
Penaltys Penalty[] @relation("User")
|
||||||
CreatedPenalties Penalty[] @relation("CreatedPenalties")
|
CreatedPenalties Penalty[] @relation("CreatedPenalties")
|
||||||
Bookings Booking[]
|
Logs Log[]
|
||||||
|
|
||||||
|
Bookings Booking[]
|
||||||
DiscordAccount DiscordAccount?
|
DiscordAccount DiscordAccount?
|
||||||
FormerDiscordAccounts FormerDiscordAccount[]
|
FormerDiscordAccounts FormerDiscordAccount[]
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user