Added Account Dublicate fucntion, improved default sorts
This commit is contained in:
2
.vscode/extensions.json
vendored
2
.vscode/extensions.json
vendored
@@ -1,7 +1,5 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"EthanSK.restore-terminals",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"VisualStudioExptTeam.vscodeintellicode"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ export const AppointmentModal = ({
|
||||
</div>
|
||||
<div>
|
||||
<PaginatedTable
|
||||
hide={appointmentForm.watch("id") === undefined}
|
||||
supressQuery={appointmentForm.watch("id") === undefined}
|
||||
ref={participantTableRef}
|
||||
columns={
|
||||
[
|
||||
|
||||
@@ -250,6 +250,7 @@ export const Form = ({ event }: { event?: Event }) => {
|
||||
{!form.watch("hasPresenceEvents") ? (
|
||||
<div className="card bg-base-200 col-span-6 shadow-xl">
|
||||
<div className="card-body">
|
||||
{
|
||||
<PaginatedTable
|
||||
leftOfSearch={
|
||||
<h2 className="card-title">
|
||||
@@ -265,6 +266,7 @@ export const Form = ({ event }: { event?: Event }) => {
|
||||
include={{
|
||||
User: true,
|
||||
}}
|
||||
supressQuery={!event}
|
||||
columns={
|
||||
[
|
||||
{
|
||||
@@ -336,6 +338,7 @@ export const Form = ({ event }: { event?: Event }) => {
|
||||
] as ColumnDef<Participant & { User: User }>[]
|
||||
}
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -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.");
|
||||
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))) {
|
||||
toast.error("Du kannst Berechtigungen nicht entfernen, die du selbst nicht besitzt.");
|
||||
return;
|
||||
@@ -284,6 +285,12 @@ export const ConnectionHistory: React.FC<{ user: User }> = ({ user }: { user: Us
|
||||
userId: user.id,
|
||||
}}
|
||||
prismaModel={"connectedDispatcher"}
|
||||
initialOrderBy={[
|
||||
{
|
||||
id: "loginTime",
|
||||
desc: true,
|
||||
},
|
||||
]}
|
||||
columns={
|
||||
[
|
||||
{
|
||||
@@ -347,6 +354,12 @@ export const ConnectionHistory: React.FC<{ user: User }> = ({ user }: { user: Us
|
||||
}}
|
||||
prismaModel={"connectedAircraft"}
|
||||
include={{ Station: true }}
|
||||
initialOrderBy={[
|
||||
{
|
||||
id: "loginTime",
|
||||
desc: true,
|
||||
},
|
||||
]}
|
||||
columns={
|
||||
[
|
||||
{
|
||||
@@ -519,6 +532,12 @@ export const UserReports = ({ user }: { user: User }) => {
|
||||
filter={{
|
||||
reportedUserId: user.id,
|
||||
}}
|
||||
initialOrderBy={[
|
||||
{
|
||||
id: "timestamp",
|
||||
desc: true,
|
||||
},
|
||||
]}
|
||||
include={{
|
||||
Sender: true,
|
||||
Reported: true,
|
||||
@@ -531,7 +550,7 @@ export const UserReports = ({ user }: { user: User }) => {
|
||||
|
||||
interface AdminFormProps {
|
||||
discordAccount?: DiscordAccount;
|
||||
user: User;
|
||||
user: User & { CanonicalUser?: User | null; Duplicates?: User[] | null };
|
||||
dispoTime: {
|
||||
hours: number;
|
||||
minutes: number;
|
||||
@@ -653,7 +672,59 @@ export const AdminForm = ({
|
||||
</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>
|
||||
{(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) && (
|
||||
<div role="alert" className="alert alert-warning alert-outline flex flex-col">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -681,6 +752,7 @@ export const AdminForm = ({
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h2 className="card-title">
|
||||
<ChartBarBigIcon className="h-5 w-5" /> Aktivität
|
||||
</h2>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
37
apps/hub/app/(app)/admin/user/[id]/duplicate/page.tsx
Normal file
37
apps/hub/app/(app)/admin/user/[id]/duplicate/page.tsx
Normal 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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,8 @@ export default async function Page({ params }: { params: Promise<{ id: string }>
|
||||
},
|
||||
include: {
|
||||
discordAccounts: true,
|
||||
CanonicalUser: true,
|
||||
Duplicates: true,
|
||||
},
|
||||
});
|
||||
if (!user) return <Error statusCode={404} title="User not found" />;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { prisma, Prisma } from "@repo/db";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { sendMailByTemplate } from "../../../../helper/mail";
|
||||
import { getServerSession } from "api/auth/[...nextauth]/auth";
|
||||
|
||||
export const getUser = async (where: Prisma.UserWhereInput) => {
|
||||
return await prisma.user.findMany({
|
||||
@@ -82,3 +83,52 @@ export const sendVerificationLink = async (userId: string) => {
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -3,7 +3,7 @@ import { User2 } from "lucide-react";
|
||||
import { PaginatedTable } from "../../../_components/PaginatedTable";
|
||||
import Link from "next/link";
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { User } from "@repo/db";
|
||||
import { DiscordAccount, User } from "@repo/db";
|
||||
import { useSession } from "next-auth/react";
|
||||
|
||||
const AdminUserPage = () => {
|
||||
@@ -13,7 +13,10 @@ const AdminUserPage = () => {
|
||||
<PaginatedTable
|
||||
stickyHeaders
|
||||
prismaModel="user"
|
||||
searchFields={["publicId", "firstname", "lastname", "email"]}
|
||||
searchFields={["publicId", "firstname", "lastname", "email", "discordAccounts.username"]}
|
||||
include={{
|
||||
discordAccounts: true,
|
||||
}}
|
||||
initialOrderBy={[
|
||||
{
|
||||
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")
|
||||
? [
|
||||
{
|
||||
@@ -69,7 +82,7 @@ const AdminUserPage = () => {
|
||||
</div>
|
||||
),
|
||||
},
|
||||
] as ColumnDef<User>[]
|
||||
] as ColumnDef<User & { discordAccounts: DiscordAccount[] }>[]
|
||||
} // Define the columns for the user table
|
||||
leftOfSearch={
|
||||
<p className="flex items-center gap-2 text-left text-2xl font-semibold">
|
||||
|
||||
@@ -27,6 +27,10 @@ const page = async () => {
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
orderBy: {
|
||||
id: "desc",
|
||||
},
|
||||
});
|
||||
const appointments = await prisma.eventAppointment.findMany({
|
||||
where: {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
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 { useForm } from "react-hook-form";
|
||||
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 userCanDelete = penaltys.length === 0 && !user.isBanned;
|
||||
const userCanDelete = penaltys.length === 0 && !user.isBanned && reports.length === 0;
|
||||
return (
|
||||
<div className="card-body">
|
||||
<h2 className="card-title mb-5">
|
||||
@@ -338,8 +346,9 @@ export const DeleteForm = ({ user, penaltys }: { user: User; penaltys: Penalty[]
|
||||
<div className="text-left">
|
||||
<h2 className="text-warning text-lg">Du kannst dein Konto zurzeit nicht löschen!</h2>
|
||||
<p className="text-sm text-gray-400">
|
||||
Scheinbar hast du aktuell zurzeit aktive Strafen. Um unsere Community zu schützen kannst
|
||||
du einen Account erst löschen wenn deine Strafe nicht mehr aktiv ist
|
||||
Scheinbar hast du Strafen oder Reports in deinem Profil hinterlegt. Um unsere Community
|
||||
zu schützen kannst du deinen Account nicht löschen. Bitte erstelle ein Support-Ticket,
|
||||
wenn du Fragen dazu hast.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -20,36 +20,35 @@ export default async function Page() {
|
||||
const userPenaltys = await prisma.penalty.findMany({
|
||||
where: {
|
||||
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" />;
|
||||
const discordAccount = user?.discordAccounts[0];
|
||||
return (
|
||||
<div className="grid grid-cols-6 gap-4">
|
||||
<div className="col-span-full">
|
||||
<p className="text-2xl font-semibold text-left flex items-center gap-2">
|
||||
<GearIcon className="w-5 h-5" /> Einstellungen
|
||||
<p className="flex items-center gap-2 text-left text-2xl font-semibold">
|
||||
<GearIcon className="h-5 w-5" /> Einstellungen
|
||||
</p>
|
||||
</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} />
|
||||
</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} />
|
||||
</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 />
|
||||
</div>
|
||||
<div className="card bg-base-200 shadow-xl mb-4 col-span-6 xl:col-span-3">
|
||||
<DeleteForm user={user} penaltys={userPenaltys} />
|
||||
<div className="card bg-base-200 col-span-6 mb-4 shadow-xl xl:col-span-3">
|
||||
<DeleteForm user={user} penaltys={userPenaltys} reports={userReports} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -20,7 +20,7 @@ interface PaginatedTableProps<TData> extends Omit<SortableTableProps<TData>, "da
|
||||
leftOfSearch?: React.ReactNode;
|
||||
rightOfSearch?: React.ReactNode;
|
||||
leftOfPagination?: React.ReactNode;
|
||||
hide?: boolean;
|
||||
supressQuery?: boolean;
|
||||
ref?: Ref<PaginatedTableRef>;
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ export function PaginatedTable<TData>({
|
||||
leftOfSearch,
|
||||
rightOfSearch,
|
||||
leftOfPagination,
|
||||
hide,
|
||||
supressQuery,
|
||||
...restProps
|
||||
}: PaginatedTableProps<TData>) {
|
||||
const [data, setData] = useState<TData[]>([]);
|
||||
@@ -58,6 +58,10 @@ export function PaginatedTable<TData>({
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const refreshTableData = useCallback(async () => {
|
||||
if (supressQuery) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
getData(
|
||||
prismaModel,
|
||||
@@ -91,6 +95,7 @@ export function PaginatedTable<TData>({
|
||||
setLoading(false);
|
||||
});
|
||||
}, [
|
||||
supressQuery,
|
||||
prismaModel,
|
||||
rowsPerPage,
|
||||
page,
|
||||
@@ -111,8 +116,10 @@ export function PaginatedTable<TData>({
|
||||
|
||||
// useEffect to show loading spinner
|
||||
useEffect(() => {
|
||||
if (supressQuery) return;
|
||||
|
||||
setLoading(true);
|
||||
}, [searchTerm, page, rowsPerPage, orderBy, filter, setLoading]);
|
||||
}, [searchTerm, page, rowsPerPage, orderBy, filter, setLoading, supressQuery]);
|
||||
|
||||
useDebounce(
|
||||
() => {
|
||||
@@ -123,15 +130,15 @@ export function PaginatedTable<TData>({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4 m-4">
|
||||
<div className="m-4 space-y-4">
|
||||
{(rightOfSearch || leftOfSearch || searchFields.length > 0) && (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 sticky py-2 z-20",
|
||||
stickyHeaders && "sticky top-0 bg-base-100/80 backdrop-blur border-b",
|
||||
"sticky z-20 flex items-center gap-2 py-2",
|
||||
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>{loading && <span className="loading loading-dots loading-md" />}</div>
|
||||
</div>
|
||||
@@ -150,22 +157,14 @@ export function PaginatedTable<TData>({
|
||||
<div className="flex justify-center">{rightOfSearch}</div>
|
||||
</div>
|
||||
)}
|
||||
{!hide && (
|
||||
<SortableTable
|
||||
data={data}
|
||||
prismaModel={prismaModel}
|
||||
setOrderBy={setOrderBy}
|
||||
{...restProps}
|
||||
/>
|
||||
)}
|
||||
<div className="flex items-between">
|
||||
|
||||
<SortableTable data={data} prismaModel={prismaModel} setOrderBy={setOrderBy} {...restProps} />
|
||||
<div className="items-between flex">
|
||||
{leftOfPagination}
|
||||
{!hide && (
|
||||
<>
|
||||
<RowsPerPage rowsPerPage={rowsPerPage} setRowsPerPage={setRowsPerPage} />
|
||||
<Pagination totalPages={Math.ceil(total / rowsPerPage)} page={page} setPage={setPage} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -31,6 +31,7 @@ const customStyles: StylesConfig<OptionType, false> = {
|
||||
backgroundColor: state.isSelected ? "hsl(var(--p))" : "hsl(var(--b1))",
|
||||
color: "var(--color-primary-content)",
|
||||
"&:hover": { backgroundColor: "var(--color-base-200)" }, // DaisyUI secondary color
|
||||
cursor: "pointer",
|
||||
}),
|
||||
multiValueLabel: (provided) => ({
|
||||
...provided,
|
||||
@@ -49,6 +50,11 @@ const customStyles: StylesConfig<OptionType, false> = {
|
||||
backgroundColor: "var(--color-base-100)",
|
||||
borderRadius: "0.5rem",
|
||||
}),
|
||||
input: (provided) => ({
|
||||
...provided,
|
||||
color: "var(--color-primary-content)",
|
||||
cursor: "text",
|
||||
}),
|
||||
};
|
||||
|
||||
const SelectCom = <T extends FieldValues>({
|
||||
@@ -61,7 +67,7 @@ const SelectCom = <T extends FieldValues>({
|
||||
}: SelectProps<T>) => {
|
||||
return (
|
||||
<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
|
||||
onChange={(newValue: any) => {
|
||||
if (Array.isArray(newValue)) {
|
||||
|
||||
33
apps/hub/app/api/admin/user/search/route.ts
Normal file
33
apps/hub/app/api/admin/user/search/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ export default function () {
|
||||
prismaModel={"user"}
|
||||
filter={{
|
||||
vatsimCid: {
|
||||
gt: 1,
|
||||
not: "",
|
||||
},
|
||||
}}
|
||||
leftOfSearch={<h1 className="text-2xl font-bold">Vatsim-Nutzer</h1>}
|
||||
|
||||
@@ -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;
|
||||
@@ -60,6 +60,12 @@ model User {
|
||||
createdAt DateTime @default(now()) @map(name: "created_at")
|
||||
updatedAt DateTime @default(now()) @map(name: "updated_at")
|
||||
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:
|
||||
oauthTokens OAuthToken[]
|
||||
discordAccounts DiscordAccount[]
|
||||
|
||||
Reference in New Issue
Block a user