Added Account Dublicate fucntion, improved default sorts

This commit is contained in:
PxlLoewe
2025-12-26 01:23:32 +01:00
parent 51ef9cd90c
commit 17208eded9
18 changed files with 486 additions and 139 deletions

View File

@@ -1,7 +1,5 @@
{ {
"recommendations": [ "recommendations": [
"EthanSK.restore-terminals",
"dbaeumer.vscode-eslint", "dbaeumer.vscode-eslint",
"VisualStudioExptTeam.vscodeintellicode"
] ]
} }

View File

@@ -64,7 +64,7 @@ export const AppointmentModal = ({
</div> </div>
<div> <div>
<PaginatedTable <PaginatedTable
hide={appointmentForm.watch("id") === undefined} supressQuery={appointmentForm.watch("id") === undefined}
ref={participantTableRef} ref={participantTableRef}
columns={ columns={
[ [

View File

@@ -250,6 +250,7 @@ export const Form = ({ event }: { event?: Event }) => {
{!form.watch("hasPresenceEvents") ? ( {!form.watch("hasPresenceEvents") ? (
<div className="card bg-base-200 col-span-6 shadow-xl"> <div className="card bg-base-200 col-span-6 shadow-xl">
<div className="card-body"> <div className="card-body">
{
<PaginatedTable <PaginatedTable
leftOfSearch={ leftOfSearch={
<h2 className="card-title"> <h2 className="card-title">
@@ -265,6 +266,7 @@ export const Form = ({ event }: { event?: Event }) => {
include={{ include={{
User: true, User: true,
}} }}
supressQuery={!event}
columns={ columns={
[ [
{ {
@@ -336,6 +338,7 @@ export const Form = ({ event }: { event?: Event }) => {
] as ColumnDef<Participant & { User: User }>[] ] as ColumnDef<Participant & { User: User }>[]
} }
/> />
}
</div> </div>
</div> </div>
) : null} ) : null}

View File

@@ -84,7 +84,8 @@ export const ProfileForm: React.FC<ProfileFormProps> = ({ user }: ProfileFormPro
toast.error("Du kannst Berechtigungen nicht hinzufügen, die du selbst nicht besitzt."); toast.error("Du kannst Berechtigungen nicht hinzufügen, die du selbst nicht besitzt.");
return; return;
} }
const removedPermissions = user.permissions?.filter((perm) => !values.permissions?.includes(perm)) || []; const removedPermissions =
user.permissions?.filter((perm) => !values.permissions?.includes(perm)) || [];
if (removedPermissions.some((perm) => !session.data?.user.permissions.includes(perm))) { if (removedPermissions.some((perm) => !session.data?.user.permissions.includes(perm))) {
toast.error("Du kannst Berechtigungen nicht entfernen, die du selbst nicht besitzt."); toast.error("Du kannst Berechtigungen nicht entfernen, die du selbst nicht besitzt.");
return; return;
@@ -284,6 +285,12 @@ export const ConnectionHistory: React.FC<{ user: User }> = ({ user }: { user: Us
userId: user.id, userId: user.id,
}} }}
prismaModel={"connectedDispatcher"} prismaModel={"connectedDispatcher"}
initialOrderBy={[
{
id: "loginTime",
desc: true,
},
]}
columns={ columns={
[ [
{ {
@@ -347,6 +354,12 @@ export const ConnectionHistory: React.FC<{ user: User }> = ({ user }: { user: Us
}} }}
prismaModel={"connectedAircraft"} prismaModel={"connectedAircraft"}
include={{ Station: true }} include={{ Station: true }}
initialOrderBy={[
{
id: "loginTime",
desc: true,
},
]}
columns={ columns={
[ [
{ {
@@ -519,6 +532,12 @@ export const UserReports = ({ user }: { user: User }) => {
filter={{ filter={{
reportedUserId: user.id, reportedUserId: user.id,
}} }}
initialOrderBy={[
{
id: "timestamp",
desc: true,
},
]}
include={{ include={{
Sender: true, Sender: true,
Reported: true, Reported: true,
@@ -531,7 +550,7 @@ export const UserReports = ({ user }: { user: User }) => {
interface AdminFormProps { interface AdminFormProps {
discordAccount?: DiscordAccount; discordAccount?: DiscordAccount;
user: User; user: User & { CanonicalUser?: User | null; Duplicates?: User[] | null };
dispoTime: { dispoTime: {
hours: number; hours: number;
minutes: number; minutes: number;
@@ -653,7 +672,59 @@ export const AdminForm = ({
</div> </div>
)} )}
</div> </div>
{session?.user.permissions.includes("ADMIN_USER_ADVANCED") && (
<div className="mt-2 space-y-1">
<Link href={`/admin/user/${user.id}/duplicate`}>
<Button className="btn-sm btn-outline w-full">
<LockKeyhole className="h-4 w-4" /> Duplikat markieren & sperren
</Button>
</Link>
</div> </div>
)}
</div>
{(user.CanonicalUser || (user.Duplicates && user.Duplicates.length > 0)) && (
<div role="alert" className="alert alert-error alert-outline flex flex-col">
<div className="flex items-center gap-2">
<TriangleAlert />
<div>
{user.CanonicalUser && (
<div>
<h3 className="text-lg font-semibold">Als Duplikat markiert</h3>
<p>
Dieser Account wurde als Duplikat von{" "}
<Link
href={`/admin/user/${user.CanonicalUser.id}`}
className="link link-hover font-semibold"
>
{user.CanonicalUser.firstname} {user.CanonicalUser.lastname} (
{user.CanonicalUser.publicId})
</Link>{" "}
markiert.
</p>
</div>
)}
{user.Duplicates && user.Duplicates.length > 0 && (
<div className="mt-2">
<h3 className="text-lg font-semibold">Duplikate erkannt</h3>
<p>Folgende Accounts wurden als Duplikate dieses Accounts markiert:</p>
<ul className="ml-4 mt-1 list-inside list-disc">
{user.Duplicates.map((duplicate) => (
<li key={duplicate.id}>
<Link href={`/admin/user/${duplicate.id}`} className="link link-hover">
{duplicate.firstname} {duplicate.lastname} ({duplicate.publicId})
</Link>
</li>
))}
</ul>
</div>
)}
</div>
</div>
<p className="text-sm text-gray-400">
Achtung! Dieser Account ist als Duplikat markiert oder hat Duplikate!
</p>
</div>
)}
{(!!openBans.length || !!openTimebans.length) && ( {(!!openBans.length || !!openTimebans.length) && (
<div role="alert" className="alert alert-warning alert-outline flex flex-col"> <div role="alert" className="alert alert-warning alert-outline flex flex-col">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -681,6 +752,7 @@ export const AdminForm = ({
</p> </p>
</div> </div>
)} )}
<h2 className="card-title"> <h2 className="card-title">
<ChartBarBigIcon className="h-5 w-5" /> Aktivität <ChartBarBigIcon className="h-5 w-5" /> Aktivität
</h2> </h2>

View File

@@ -0,0 +1,109 @@
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { useQuery } from "@tanstack/react-query";
import { getUser, markDuplicate } from "(app)/admin/user/action";
import { Button } from "@repo/shared-components";
import { Select } from "_components/ui/Select";
import toast from "react-hot-toast";
import { TriangleAlert } from "lucide-react";
import { useState } from "react";
const DuplicateSchema = z.object({
canonicalUserId: z.string().min(1, "Bitte Nutzer auswählen"),
reason: z.string().max(500).optional(),
});
export const DuplicateForm = ({ duplicateUserId }: { duplicateUserId: string }) => {
const form = useForm<z.infer<typeof DuplicateSchema>>({
resolver: zodResolver(DuplicateSchema),
defaultValues: { canonicalUserId: "", reason: "" },
});
const [search, setSearch] = useState("");
const { data: users } = useQuery({
queryKey: ["duplicate-search"],
queryFn: async () =>
getUser({
OR: [
{ firstname: { contains: search, mode: "insensitive" } },
{ lastname: { contains: search, mode: "insensitive" } },
{ publicId: { contains: search, mode: "insensitive" } },
{ email: { contains: search, mode: "insensitive" } },
],
}),
enabled: search.length > 0,
refetchOnWindowFocus: false,
});
return (
<form
className="flex flex-wrap gap-3"
onSubmit={form.handleSubmit(async (values) => {
try {
// find selected canonical user by id to obtain publicId
const canonical = (users || []).find((u) => u.id === values.canonicalUserId);
if (!canonical) {
toast.error("Bitte wähle einen Original-Account aus.");
return;
}
await markDuplicate({
duplicateUserId,
canonicalPublicId: canonical.publicId,
reason: values.reason,
});
toast.success("Duplikat verknüpft und Nutzer gesperrt.");
} catch (e: unknown) {
const message =
typeof e === "object" && e && "message" in e
? (e as { message?: string }).message || "Fehler beim Verknüpfen"
: "Fehler beim Verknüpfen";
toast.error(message);
}
})}
>
<div className="card bg-base-200 flex-1 basis-[800px] shadow-xl">
<div className="card-body">
<h2 className="card-title">
<TriangleAlert /> Duplikat markieren & sperren
</h2>
<Select
form={form}
name="canonicalUserId"
label="Original-Nutzer suchen & auswählen"
onInputChange={(v) => setSearch(String(v))}
options={
users?.map((u) => ({
label: `${u.firstname} ${u.lastname} (${u.publicId})`,
value: u.id,
})) || [{ label: "Kein Nutzer gefunden", value: "", disabled: true }]
}
/>
<label className="floating-label w-full">
<span className="flex items-center gap-2 text-lg">Grund (optional)</span>
<input
{...form.register("reason")}
type="text"
className="input input-bordered w-full"
placeholder="Begründung/Audit-Hinweis"
/>
</label>
</div>
</div>
<div className="card bg-base-200 flex-1 basis-[800px] shadow-xl">
<div className="card-body">
<div className="flex w-full gap-4">
<Button
isLoading={form.formState.isSubmitting}
type="submit"
className="btn btn-primary flex-1"
>
Als Duplikat verknüpfen & sperren
</Button>
</div>
</div>
</div>
</form>
);
};

View File

@@ -0,0 +1,37 @@
import { prisma } from "@repo/db";
import { DuplicateForm } from "./_components/DuplicateForm";
import { PersonIcon } from "@radix-ui/react-icons";
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const user = await prisma.user.findUnique({
where: { id },
select: { id: true, firstname: true, lastname: true, publicId: true },
});
if (!user) {
return (
<div className="card bg-base-200 shadow-xl">
<div className="card-body">Nutzer nicht gefunden</div>
</div>
);
}
return (
<>
<div className="my-3">
<div className="text-left">
<Link href={`/admin/user/${user.id}`} className="link-hover l-0 text-gray-500">
<ArrowLeft className="mb-1 mr-1 inline h-4 w-4" />
Zurück zum Nutzer
</Link>
</div>
<p className="text-left text-2xl font-semibold">
<PersonIcon className="mr-2 inline h-5 w-5" /> Duplikat für {user.firstname}{" "}
{user.lastname} #{user.publicId}
</p>
</div>
<DuplicateForm duplicateUserId={user.id} />
</>
);
}

View File

@@ -18,6 +18,8 @@ export default async function Page({ params }: { params: Promise<{ id: string }>
}, },
include: { include: {
discordAccounts: true, discordAccounts: true,
CanonicalUser: true,
Duplicates: true,
}, },
}); });
if (!user) return <Error statusCode={404} title="User not found" />; if (!user) return <Error statusCode={404} title="User not found" />;

View File

@@ -2,6 +2,7 @@
import { prisma, Prisma } from "@repo/db"; import { prisma, Prisma } from "@repo/db";
import bcrypt from "bcryptjs"; import bcrypt from "bcryptjs";
import { sendMailByTemplate } from "../../../../helper/mail"; import { sendMailByTemplate } from "../../../../helper/mail";
import { getServerSession } from "api/auth/[...nextauth]/auth";
export const getUser = async (where: Prisma.UserWhereInput) => { export const getUser = async (where: Prisma.UserWhereInput) => {
return await prisma.user.findMany({ return await prisma.user.findMany({
@@ -82,3 +83,52 @@ export const sendVerificationLink = async (userId: string) => {
code, code,
}); });
}; };
export const markDuplicate = async (params: {
duplicateUserId: string;
canonicalPublicId: string;
reason?: string;
}) => {
// Then in your function:
const session = await getServerSession();
if (!session?.user) throw new Error("Nicht authentifiziert");
const canonical = await prisma.user.findUnique({
where: { publicId: params.canonicalPublicId },
select: { id: true },
});
if (!canonical) throw new Error("Original-Account (canonical) nicht gefunden");
if (canonical.id === params.duplicateUserId)
throw new Error("Duplikat und Original dürfen nicht identisch sein");
const updated = await prisma.user.update({
where: { id: params.duplicateUserId },
data: {
canonicalUserId: canonical.id,
isBanned: true,
duplicateDetectedAt: new Date(),
duplicateReason: params.reason ?? undefined,
},
});
await prisma.penalty.create({
data: {
userId: params.duplicateUserId,
type: "BAN",
reason: `Account als Duplikat von #${params.canonicalPublicId} markiert.`,
createdUserId: session.user.id,
},
});
return updated;
};
export const clearDuplicateLink = async (duplicateUserId: string) => {
const updated = await prisma.user.update({
where: { id: duplicateUserId },
data: {
canonicalUserId: null,
duplicateDetectedAt: null,
duplicateReason: null,
},
});
return updated;
};

View File

@@ -3,7 +3,7 @@ import { User2 } from "lucide-react";
import { PaginatedTable } from "../../../_components/PaginatedTable"; import { PaginatedTable } from "../../../_components/PaginatedTable";
import Link from "next/link"; import Link from "next/link";
import { ColumnDef } from "@tanstack/react-table"; import { ColumnDef } from "@tanstack/react-table";
import { User } from "@repo/db"; import { DiscordAccount, User } from "@repo/db";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
const AdminUserPage = () => { const AdminUserPage = () => {
@@ -13,7 +13,10 @@ const AdminUserPage = () => {
<PaginatedTable <PaginatedTable
stickyHeaders stickyHeaders
prismaModel="user" prismaModel="user"
searchFields={["publicId", "firstname", "lastname", "email"]} searchFields={["publicId", "firstname", "lastname", "email", "discordAccounts.username"]}
include={{
discordAccounts: true,
}}
initialOrderBy={[ initialOrderBy={[
{ {
id: "publicId", id: "publicId",
@@ -51,6 +54,16 @@ const AdminUserPage = () => {
); );
}, },
}, },
{
header: "Discord",
cell(props) {
const discord = props.row.original.discordAccounts;
if (discord.length === 0) {
return <span className="text-gray-700">Nicht verbunden</span>;
}
return <span>{discord.map((d) => d.username).join(", ")}</span>;
},
},
...(session?.user.permissions.includes("ADMIN_USER_ADVANCED") ...(session?.user.permissions.includes("ADMIN_USER_ADVANCED")
? [ ? [
{ {
@@ -69,7 +82,7 @@ const AdminUserPage = () => {
</div> </div>
), ),
}, },
] as ColumnDef<User>[] ] as ColumnDef<User & { discordAccounts: DiscordAccount[] }>[]
} // Define the columns for the user table } // Define the columns for the user table
leftOfSearch={ leftOfSearch={
<p className="flex items-center gap-2 text-left text-2xl font-semibold"> <p className="flex items-center gap-2 text-left text-2xl font-semibold">

View File

@@ -27,6 +27,10 @@ const page = async () => {
}, },
}, },
}, },
orderBy: {
id: "desc",
},
}); });
const appointments = await prisma.eventAppointment.findMany({ const appointments = await prisma.eventAppointment.findMany({
where: { where: {

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { DiscordAccount, Penalty, User } from "@repo/db"; import { DiscordAccount, Penalty, Report, User } from "@repo/db";
import { useState } from "react"; import { useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
@@ -326,9 +326,17 @@ export const SocialForm = ({
); );
}; };
export const DeleteForm = ({ user, penaltys }: { user: User; penaltys: Penalty[] }) => { export const DeleteForm = ({
user,
penaltys,
reports,
}: {
user: User;
penaltys: Penalty[];
reports: Report[];
}) => {
const router = useRouter(); const router = useRouter();
const userCanDelete = penaltys.length === 0 && !user.isBanned; const userCanDelete = penaltys.length === 0 && !user.isBanned && reports.length === 0;
return ( return (
<div className="card-body"> <div className="card-body">
<h2 className="card-title mb-5"> <h2 className="card-title mb-5">
@@ -338,8 +346,9 @@ export const DeleteForm = ({ user, penaltys }: { user: User; penaltys: Penalty[]
<div className="text-left"> <div className="text-left">
<h2 className="text-warning text-lg">Du kannst dein Konto zurzeit nicht löschen!</h2> <h2 className="text-warning text-lg">Du kannst dein Konto zurzeit nicht löschen!</h2>
<p className="text-sm text-gray-400"> <p className="text-sm text-gray-400">
Scheinbar hast du aktuell zurzeit aktive Strafen. Um unsere Community zu schützen kannst Scheinbar hast du Strafen oder Reports in deinem Profil hinterlegt. Um unsere Community
du einen Account erst löschen wenn deine Strafe nicht mehr aktiv ist zu schützen kannst du deinen Account nicht löschen. Bitte erstelle ein Support-Ticket,
wenn du Fragen dazu hast.
</p> </p>
</div> </div>
)} )}

View File

@@ -20,36 +20,35 @@ export default async function Page() {
const userPenaltys = await prisma.penalty.findMany({ const userPenaltys = await prisma.penalty.findMany({
where: { where: {
userId: session.user.id, userId: session.user.id,
until: {
gte: new Date(),
},
type: {
in: ["TIME_BAN", "BAN"],
},
suspended: false,
}, },
}); });
const userReports = await prisma.report.findMany({
where: {
reportedUserId: session.user.id,
},
});
if (!user) return <Error statusCode={401} title="Dein Account wurde nicht gefunden" />; if (!user) return <Error statusCode={401} title="Dein Account wurde nicht gefunden" />;
const discordAccount = user?.discordAccounts[0]; const discordAccount = user?.discordAccounts[0];
return ( return (
<div className="grid grid-cols-6 gap-4"> <div className="grid grid-cols-6 gap-4">
<div className="col-span-full"> <div className="col-span-full">
<p className="text-2xl font-semibold text-left flex items-center gap-2"> <p className="flex items-center gap-2 text-left text-2xl font-semibold">
<GearIcon className="w-5 h-5" /> Einstellungen <GearIcon className="h-5 w-5" /> Einstellungen
</p> </p>
</div> </div>
<div className="card bg-base-200 shadow-xl mb-4 col-span-6 xl:col-span-3"> <div className="card bg-base-200 col-span-6 mb-4 shadow-xl xl:col-span-3">
<ProfileForm user={user} penaltys={userPenaltys} discordAccount={discordAccount} /> <ProfileForm user={user} penaltys={userPenaltys} discordAccount={discordAccount} />
</div> </div>
<div className="card bg-base-200 shadow-xl mb-4 col-span-6 xl:col-span-3"> <div className="card bg-base-200 col-span-6 mb-4 shadow-xl xl:col-span-3">
<SocialForm discordAccount={discordAccount} user={user} /> <SocialForm discordAccount={discordAccount} user={user} />
</div> </div>
<div className="card bg-base-200 shadow-xl mb-4 col-span-6 xl:col-span-3"> <div className="card bg-base-200 col-span-6 mb-4 shadow-xl xl:col-span-3">
<PasswordForm /> <PasswordForm />
</div> </div>
<div className="card bg-base-200 shadow-xl mb-4 col-span-6 xl:col-span-3"> <div className="card bg-base-200 col-span-6 mb-4 shadow-xl xl:col-span-3">
<DeleteForm user={user} penaltys={userPenaltys} /> <DeleteForm user={user} penaltys={userPenaltys} reports={userReports} />
</div> </div>
</div> </div>
); );

View File

@@ -20,7 +20,7 @@ interface PaginatedTableProps<TData> extends Omit<SortableTableProps<TData>, "da
leftOfSearch?: React.ReactNode; leftOfSearch?: React.ReactNode;
rightOfSearch?: React.ReactNode; rightOfSearch?: React.ReactNode;
leftOfPagination?: React.ReactNode; leftOfPagination?: React.ReactNode;
hide?: boolean; supressQuery?: boolean;
ref?: Ref<PaginatedTableRef>; ref?: Ref<PaginatedTableRef>;
} }
@@ -36,7 +36,7 @@ export function PaginatedTable<TData>({
leftOfSearch, leftOfSearch,
rightOfSearch, rightOfSearch,
leftOfPagination, leftOfPagination,
hide, supressQuery,
...restProps ...restProps
}: PaginatedTableProps<TData>) { }: PaginatedTableProps<TData>) {
const [data, setData] = useState<TData[]>([]); const [data, setData] = useState<TData[]>([]);
@@ -58,6 +58,10 @@ export function PaginatedTable<TData>({
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const refreshTableData = useCallback(async () => { const refreshTableData = useCallback(async () => {
if (supressQuery) {
setLoading(false);
return;
}
setLoading(true); setLoading(true);
getData( getData(
prismaModel, prismaModel,
@@ -91,6 +95,7 @@ export function PaginatedTable<TData>({
setLoading(false); setLoading(false);
}); });
}, [ }, [
supressQuery,
prismaModel, prismaModel,
rowsPerPage, rowsPerPage,
page, page,
@@ -111,8 +116,10 @@ export function PaginatedTable<TData>({
// useEffect to show loading spinner // useEffect to show loading spinner
useEffect(() => { useEffect(() => {
if (supressQuery) return;
setLoading(true); setLoading(true);
}, [searchTerm, page, rowsPerPage, orderBy, filter, setLoading]); }, [searchTerm, page, rowsPerPage, orderBy, filter, setLoading, supressQuery]);
useDebounce( useDebounce(
() => { () => {
@@ -123,15 +130,15 @@ export function PaginatedTable<TData>({
); );
return ( return (
<div className="space-y-4 m-4"> <div className="m-4 space-y-4">
{(rightOfSearch || leftOfSearch || searchFields.length > 0) && ( {(rightOfSearch || leftOfSearch || searchFields.length > 0) && (
<div <div
className={cn( className={cn(
"flex items-center gap-2 sticky py-2 z-20", "sticky z-20 flex items-center gap-2 py-2",
stickyHeaders && "sticky top-0 bg-base-100/80 backdrop-blur border-b", stickyHeaders && "bg-base-100/80 sticky top-0 border-b backdrop-blur",
)} )}
> >
<div className="flex-1 flex gap-2"> <div className="flex flex-1 gap-2">
<div>{leftOfSearch}</div> <div>{leftOfSearch}</div>
<div>{loading && <span className="loading loading-dots loading-md" />}</div> <div>{loading && <span className="loading loading-dots loading-md" />}</div>
</div> </div>
@@ -150,22 +157,14 @@ export function PaginatedTable<TData>({
<div className="flex justify-center">{rightOfSearch}</div> <div className="flex justify-center">{rightOfSearch}</div>
</div> </div>
)} )}
{!hide && (
<SortableTable <SortableTable data={data} prismaModel={prismaModel} setOrderBy={setOrderBy} {...restProps} />
data={data} <div className="items-between flex">
prismaModel={prismaModel}
setOrderBy={setOrderBy}
{...restProps}
/>
)}
<div className="flex items-between">
{leftOfPagination} {leftOfPagination}
{!hide && (
<> <>
<RowsPerPage rowsPerPage={rowsPerPage} setRowsPerPage={setRowsPerPage} /> <RowsPerPage rowsPerPage={rowsPerPage} setRowsPerPage={setRowsPerPage} />
<Pagination totalPages={Math.ceil(total / rowsPerPage)} page={page} setPage={setPage} /> <Pagination totalPages={Math.ceil(total / rowsPerPage)} page={page} setPage={setPage} />
</> </>
)}
</div> </div>
</div> </div>
); );

View File

@@ -31,6 +31,7 @@ const customStyles: StylesConfig<OptionType, false> = {
backgroundColor: state.isSelected ? "hsl(var(--p))" : "hsl(var(--b1))", backgroundColor: state.isSelected ? "hsl(var(--p))" : "hsl(var(--b1))",
color: "var(--color-primary-content)", color: "var(--color-primary-content)",
"&:hover": { backgroundColor: "var(--color-base-200)" }, // DaisyUI secondary color "&:hover": { backgroundColor: "var(--color-base-200)" }, // DaisyUI secondary color
cursor: "pointer",
}), }),
multiValueLabel: (provided) => ({ multiValueLabel: (provided) => ({
...provided, ...provided,
@@ -49,6 +50,11 @@ const customStyles: StylesConfig<OptionType, false> = {
backgroundColor: "var(--color-base-100)", backgroundColor: "var(--color-base-100)",
borderRadius: "0.5rem", borderRadius: "0.5rem",
}), }),
input: (provided) => ({
...provided,
color: "var(--color-primary-content)",
cursor: "text",
}),
}; };
const SelectCom = <T extends FieldValues>({ const SelectCom = <T extends FieldValues>({
@@ -61,7 +67,7 @@ const SelectCom = <T extends FieldValues>({
}: SelectProps<T>) => { }: SelectProps<T>) => {
return ( return (
<div> <div>
<span className="label-text text-lg flex items-center gap-2">{label}</span> <span className="label-text flex items-center gap-2 text-lg">{label}</span>
<SelectTemplate <SelectTemplate
onChange={(newValue: any) => { onChange={(newValue: any) => {
if (Array.isArray(newValue)) { if (Array.isArray(newValue)) {

View File

@@ -0,0 +1,33 @@
import { NextResponse } from "next/server";
import { prisma } from "@repo/db";
export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
const publicId = searchParams.get("publicId")?.trim();
const email = searchParams.get("email")?.trim()?.toLowerCase();
if (!publicId && !email) {
return NextResponse.json({ error: "Missing query" }, { status: 400 });
}
try {
const user = await prisma.user.findFirst({
where: {
OR: [publicId ? { publicId } : undefined, email ? { email } : undefined].filter(
Boolean,
) as any,
},
select: {
id: true,
publicId: true,
firstname: true,
lastname: true,
isBanned: true,
},
});
if (!user) return NextResponse.json({ user: null }, { status: 200 });
return NextResponse.json({ user }, { status: 200 });
} catch (e) {
return NextResponse.json({ error: "Server error" }, { status: 500 });
}
}

View File

@@ -11,7 +11,7 @@ export default function () {
prismaModel={"user"} prismaModel={"user"}
filter={{ filter={{
vatsimCid: { vatsimCid: {
gt: 1, not: "",
}, },
}} }}
leftOfSearch={<h1 className="text-2xl font-bold">Vatsim-Nutzer</h1>} leftOfSearch={<h1 className="text-2xl font-bold">Vatsim-Nutzer</h1>}

View File

@@ -0,0 +1,7 @@
-- AlterTable
ALTER TABLE "users" ADD COLUMN "canonical_user_id" TEXT,
ADD COLUMN "duplicate_detected_at" TIMESTAMP(3),
ADD COLUMN "duplicate_reason" TEXT;
-- AddForeignKey
ALTER TABLE "users" ADD CONSTRAINT "users_canonical_user_id_fkey" FOREIGN KEY ("canonical_user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -60,6 +60,12 @@ model User {
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:
canonicalUserId String? @map(name: "canonical_user_id")
CanonicalUser User? @relation("CanonicalUser", fields: [canonicalUserId], references: [id])
Duplicates User[] @relation("CanonicalUser")
duplicateDetectedAt DateTime? @map(name: "duplicate_detected_at")
duplicateReason String? @map(name: "duplicate_reason")
// relations: // relations:
oauthTokens OAuthToken[] oauthTokens OAuthToken[]
discordAccounts DiscordAccount[] discordAccounts DiscordAccount[]