release v2.0.6

v2.0.6
This commit was merged in pull request #142.
This commit is contained in:
PxlLoewe
2026-01-06 13:58:24 +01:00
committed by GitHub
26 changed files with 613 additions and 145 deletions

View File

@@ -1,6 +1,7 @@
import { MissionLog, NotificationPayload, prisma } from "@repo/db"; import { DISCORD_ROLES, MissionLog, NotificationPayload, prisma } from "@repo/db";
import { io } from "index"; import { io } from "index";
import cron from "node-cron"; import cron from "node-cron";
import { changeMemberRoles } from "routes/member";
const removeMission = async (id: number, reason: string) => { const removeMission = async (id: number, reason: string) => {
const log: MissionLog = { const log: MissionLog = {
@@ -34,7 +35,6 @@ const removeMission = async (id: number, reason: string) => {
console.log(`Mission ${updatedMission.id} closed due to inactivity.`); console.log(`Mission ${updatedMission.id} closed due to inactivity.`);
}; };
const removeClosedMissions = async () => { const removeClosedMissions = async () => {
const oldMissions = await prisma.mission.findMany({ const oldMissions = await prisma.mission.findMany({
where: { where: {
@@ -140,6 +140,57 @@ const removeConnectedAircrafts = async () => {
} }
}); });
}; };
const removePermissionsForBannedUsers = async () => {
const activePenalties = await prisma.penalty.findMany({
where: {
OR: [
{
type: "BAN",
suspended: false,
},
{
type: "TIME_BAN",
suspended: false,
until: {
gt: new Date().toISOString(),
},
},
],
},
include: {
User: {
include: {
DiscordAccount: true,
FormerDiscordAccounts: true,
},
},
},
});
for (const penalty of activePenalties) {
const user = penalty.User;
if (user.DiscordAccount) {
await changeMemberRoles(
user.DiscordAccount.discordId,
[DISCORD_ROLES.PILOT, DISCORD_ROLES.DISPATCHER],
"remove",
);
}
for (const formerAccount of user.FormerDiscordAccounts) {
await changeMemberRoles(
formerAccount.discordId,
[DISCORD_ROLES.PILOT, DISCORD_ROLES.DISPATCHER],
"remove",
);
}
}
};
cron.schedule("*/5 * * * *", async () => {
await removePermissionsForBannedUsers();
});
cron.schedule("*/1 * * * *", async () => { cron.schedule("*/1 * * * *", async () => {
try { try {

View File

@@ -32,6 +32,25 @@ router.post("/set-standard-name", async (req, res) => {
}, },
}); });
const activePenaltys = await prisma.penalty.findMany({
where: {
userId: user.id,
OR: [
{
type: "BAN",
suspended: false,
},
{
type: "TIME_BAN",
suspended: false,
until: {
gt: new Date().toISOString(),
},
},
],
},
});
participant.forEach(async (p) => { participant.forEach(async (p) => {
if (!p.Event.discordRoleId) return; if (!p.Event.discordRoleId) return;
if (eventCompleted(p.Event, p)) { if (eventCompleted(p.Event, p)) {
@@ -48,8 +67,12 @@ router.post("/set-standard-name", async (req, res) => {
const isPilot = user.permissions.includes("PILOT"); const isPilot = user.permissions.includes("PILOT");
const isDispatcher = user.permissions.includes("DISPO"); const isDispatcher = user.permissions.includes("DISPO");
if (activePenaltys.length > 0) {
await changeMemberRoles(memberId, [DISCORD_ROLES.PILOT, DISCORD_ROLES.DISPATCHER], "remove");
} else {
await changeMemberRoles(memberId, [DISCORD_ROLES.PILOT], isPilot ? "add" : "remove"); await changeMemberRoles(memberId, [DISCORD_ROLES.PILOT], isPilot ? "add" : "remove");
await changeMemberRoles(memberId, [DISCORD_ROLES.DISPATCHER], isDispatcher ? "add" : "remove"); await changeMemberRoles(memberId, [DISCORD_ROLES.DISPATCHER], isDispatcher ? "add" : "remove");
}
}); });
export default router; export default router;

View File

@@ -20,7 +20,7 @@ router.post("/handle-participant-finished", async (req, res) => {
Event: true, Event: true,
User: { User: {
include: { include: {
discordAccounts: true, DiscordAccount: true,
}, },
}, },
}, },
@@ -94,7 +94,7 @@ router.post("/handle-participant-enrolled", async (req, res) => {
Event: true, Event: true,
User: { User: {
include: { include: {
discordAccounts: true, DiscordAccount: true,
}, },
}, },
}, },

View File

@@ -24,7 +24,7 @@ export const RecentFlights = () => {
({ ({
User: { id: session.data?.user.id }, User: { id: session.data?.user.id },
Mission: { Mission: {
state: { in: ["finished", "archived"] }, state: { in: ["finished"] },
}, },
}) as Prisma.MissionOnStationUsersWhereInput }) as Prisma.MissionOnStationUsersWhereInput
} }

View File

@@ -23,6 +23,7 @@ export const ChangelogForm = ({ changelog }: { changelog?: Changelog }) => {
title: changelog?.title || "", title: changelog?.title || "",
text: changelog?.text || "", text: changelog?.text || "",
previewImage: changelog?.previewImage || "", // Changed to accept a URL as a string previewImage: changelog?.previewImage || "", // Changed to accept a URL as a string
showOnChangelogPage: changelog?.showOnChangelogPage || true,
}, },
}); });
const [skipUserUpdate, setSkipUserUpdate] = useState(false); const [skipUserUpdate, setSkipUserUpdate] = useState(false);
@@ -84,6 +85,7 @@ export const ChangelogForm = ({ changelog }: { changelog?: Changelog }) => {
placeholder="Titel (vX.X.X)" placeholder="Titel (vX.X.X)"
className="input-sm" className="input-sm"
/> />
<Input <Input
form={form} form={form}
label="Bild-URL" label="Bild-URL"
@@ -146,6 +148,16 @@ export const ChangelogForm = ({ changelog }: { changelog?: Changelog }) => {
</span> </span>
</label> </label>
)} )}
<label className="label mx-6 mt-6 w-full cursor-pointer">
<input
type="checkbox"
className={cn("toggle")}
{...form.register("showOnChangelogPage", {})}
/>
<span className={cn("label-text w-full text-left")}>
Auf der Changelog-Seite anzeigen
</span>
</label>
<div className="card-body"> <div className="card-body">
<div className="flex w-full gap-4"> <div className="flex w-full gap-4">
<Button <Button

View File

@@ -1,23 +1,23 @@
"use client"; "use client";
import { DatabaseBackupIcon } from "lucide-react"; import { Check, Cross, DatabaseBackupIcon } 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 { Keyword, Prisma } from "@repo/db"; import { Changelog, Keyword, Prisma } from "@repo/db";
export default () => { export default () => {
return ( return (
<> <>
<PaginatedTable <PaginatedTable
stickyHeaders stickyHeaders
initialOrderBy={[{ id: "title", desc: true }]} initialOrderBy={[{ id: "createdAt", desc: true }]}
prismaModel="changelog" prismaModel="changelog"
showSearch showSearch
getFilter={(search) => getFilter={(search) =>
({ ({
OR: [ OR: [
{ title: { contains: search, mode: "insensitive" } }, { title: { contains: search, mode: "insensitive" } },
{ description: { contains: search, mode: "insensitive" } }, { text: { contains: search, mode: "insensitive" } },
], ],
}) as Prisma.ChangelogWhereInput }) as Prisma.ChangelogWhereInput
} }
@@ -27,6 +27,16 @@ export default () => {
header: "Title", header: "Title",
accessorKey: "title", accessorKey: "title",
}, },
{
header: "Auf Changelog Seite anzeigen",
accessorKey: "showOnChangelogPage",
cell: ({ row }) => (row.original.showOnChangelogPage ? <Check /> : <Cross />),
},
{
header: "Erstellt am",
accessorKey: "createdAt",
cell: ({ row }) => new Date(row.original.createdAt).toLocaleDateString(),
},
{ {
header: "Aktionen", header: "Aktionen",
cell: ({ row }) => ( cell: ({ row }) => (
@@ -37,7 +47,7 @@ export default () => {
</div> </div>
), ),
}, },
] as ColumnDef<Keyword>[] ] as ColumnDef<Changelog>[]
} }
leftOfSearch={ leftOfSearch={
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">

View File

@@ -6,6 +6,7 @@ import {
ConnectedAircraft, ConnectedAircraft,
ConnectedDispatcher, ConnectedDispatcher,
DiscordAccount, DiscordAccount,
FormerDiscordAccount,
Penalty, Penalty,
PERMISSION, PERMISSION,
Prisma, Prisma,
@@ -60,6 +61,7 @@ import { penaltyColumns } from "(app)/admin/penalty/columns";
import { addPenalty, editPenaltys } from "(app)/admin/penalty/actions"; import { addPenalty, editPenaltys } from "(app)/admin/penalty/actions";
import { reportColumns } from "(app)/admin/report/columns"; import { reportColumns } from "(app)/admin/report/columns";
import { sendMailByTemplate } from "../../../../../../helper/mail"; import { sendMailByTemplate } from "../../../../../../helper/mail";
import Image from "next/image";
interface ProfileFormProps { interface ProfileFormProps {
user: User; user: User;
@@ -566,6 +568,7 @@ interface AdminFormProps {
minutes: number; minutes: number;
lastLogin?: Date; lastLogin?: Date;
}; };
formerDiscordAccounts: (FormerDiscordAccount & { DiscordAccount: DiscordAccount | null })[];
reports: { reports: {
total: number; total: number;
open: number; open: number;
@@ -585,6 +588,7 @@ export const AdminForm = ({
pilotTime, pilotTime,
reports, reports,
discordAccount, discordAccount,
formerDiscordAccounts,
openBans, openBans,
openTimebans, openTimebans,
}: AdminFormProps) => { }: AdminFormProps) => {
@@ -755,6 +759,64 @@ export const AdminForm = ({
</p> </p>
</div> </div>
)} )}
<h2 className="card-title mt-2">
<DiscordLogoIcon className="h-5 w-5" /> Frühere Discord Accounts
</h2>
<div className="overflow-x-auto">
<table className="table-sm table">
<thead>
<tr>
<th>Avatar</th>
<th>Benutzername</th>
<th>Discord ID</th>
<th>getrennt am</th>
</tr>
</thead>
<tbody>
{discordAccount && (
<tr>
<td>
<Image
src={`https://cdn.discordapp.com/avatars/${discordAccount.discordId}/${discordAccount.avatar}.png`}
alt="Discord Avatar"
width={40}
height={40}
className="h-10 w-10 rounded-full"
/>
</td>
<td>{discordAccount.username}</td>
<td>{discordAccount.discordId}</td>
<td>N/A (Aktuell verbunden)</td>
</tr>
)}
{formerDiscordAccounts.map((account) => (
<tr key={account.discordId}>
<td>
{account.DiscordAccount && (
<Image
src={`https://cdn.discordapp.com/avatars/${account.DiscordAccount.discordId}/${account.DiscordAccount.avatar}.png`}
alt="Discord Avatar"
width={40}
height={40}
className="h-10 w-10 rounded-full"
/>
)}
</td>
<td>{account.DiscordAccount?.username || "Unbekannt"}</td>
<td>{account.DiscordAccount?.discordId || "Unbekannt"}</td>
<td>{new Date(account.removedAt).toLocaleDateString()}</td>
</tr>
))}
{!discordAccount && formerDiscordAccounts.length === 0 && (
<tr>
<td colSpan={3} className="text-center text-gray-400">
Keine Discord Accounts verknüpft
</td>
</tr>
)}
</tbody>
</table>
</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

View File

@@ -12,16 +12,37 @@ import { getUserPenaltys } from "@repo/shared-components";
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;
const user = await prisma.user.findUnique({ let user = await prisma.user.findUnique({
where: { where: {
id: id, id: id,
}, },
include: { include: {
discordAccounts: true, DiscordAccount: true,
CanonicalUser: true, CanonicalUser: true,
Duplicates: true, Duplicates: true,
}, },
}); });
if (!user) {
user = await prisma.user.findFirst({
where: {
publicId: id,
},
include: {
DiscordAccount: true,
CanonicalUser: true,
Duplicates: true,
},
});
}
const formerDiscordAccounts = await prisma.formerDiscordAccount.findMany({
where: {
userId: user?.id,
},
include: {
DiscordAccount: true,
},
});
if (!user) return <Error statusCode={404} title="User not found" />; if (!user) return <Error statusCode={404} title="User not found" />;
const dispoSessions = await prisma.connectedDispatcher.findMany({ const dispoSessions = await prisma.connectedDispatcher.findMany({
@@ -121,11 +142,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-3"> <div className="card bg-base-200 col-span-6 mb-4 shadow-xl xl:col-span-3">
<AdminForm <AdminForm
formerDiscordAccounts={formerDiscordAccounts}
user={user} user={user}
dispoTime={dispoTime} dispoTime={dispoTime}
pilotTime={pilotTime} pilotTime={pilotTime}
reports={reports} reports={reports}
discordAccount={user.discordAccounts[0]} discordAccount={user.DiscordAccount ?? undefined}
openBans={openBans} openBans={openBans}
openTimebans={openTimeban} openTimebans={openTimeban}
/> />

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 { DiscordAccount, Prisma, User } from "@repo/db"; import { DiscordAccount, Penalty, Prisma, User } from "@repo/db";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
const AdminUserPage = () => { const AdminUserPage = () => {
@@ -21,16 +21,15 @@ const AdminUserPage = () => {
{ firstname: { contains: searchTerm, mode: "insensitive" } }, { firstname: { contains: searchTerm, mode: "insensitive" } },
{ lastname: { contains: searchTerm, mode: "insensitive" } }, { lastname: { contains: searchTerm, mode: "insensitive" } },
{ email: { contains: searchTerm, mode: "insensitive" } }, { email: { contains: searchTerm, mode: "insensitive" } },
{ { publicId: { contains: searchTerm, mode: "insensitive" } },
discordAccounts: { { DiscordAccount: { username: { contains: searchTerm, mode: "insensitive" } } },
some: { username: { contains: searchTerm, mode: "insensitive" } },
},
},
], ],
} as Prisma.UserWhereInput; } as Prisma.UserWhereInput;
}} }}
include={{ include={{
discordAccounts: true, DiscordAccount: true,
ReceivedReports: true,
Penaltys: true,
}} }}
initialOrderBy={[ initialOrderBy={[
{ {
@@ -55,6 +54,15 @@ const AdminUserPage = () => {
{ {
header: "Berechtigungen", header: "Berechtigungen",
cell(props) { cell(props) {
const activePenaltys = props.row.original.Penaltys.filter(
(penalty) =>
!penalty.suspended &&
(penalty.type === "BAN" ||
(penalty.type === "TIME_BAN" && penalty!.until! > new Date())),
);
if (activePenaltys.length > 0) {
return <span className="font-bold text-red-600">AKTIVE STRAFE</span>;
}
if (props.row.original.permissions.length === 0) { if (props.row.original.permissions.length === 0) {
return <span className="text-gray-700">Keine</span>; return <span className="text-gray-700">Keine</span>;
} else if (props.row.original.permissions.includes("ADMIN_USER_ADVANCED")) { } else if (props.row.original.permissions.includes("ADMIN_USER_ADVANCED")) {
@@ -69,14 +77,26 @@ const AdminUserPage = () => {
); );
}, },
}, },
{
header: "Strafen / Reports",
cell(props) {
const penaltyCount = props.row.original.Penaltys.length;
const reportCount = props.row.original.ReceivedReports.length;
return (
<span className="w-full text-center">
{penaltyCount} / {reportCount}
</span>
);
},
},
{ {
header: "Discord", header: "Discord",
cell(props) { cell(props) {
const discord = props.row.original.discordAccounts; const discord = props.row.original.DiscordAccount;
if (discord.length === 0) { if (!discord) {
return <span className="text-gray-700">Nicht verbunden</span>; return <span className="text-gray-700">Nicht verbunden</span>;
} }
return <span>{discord.map((d) => d.username).join(", ")}</span>; return <span>{discord.username}</span>;
}, },
}, },
...(session?.user.permissions.includes("ADMIN_USER_ADVANCED") ...(session?.user.permissions.includes("ADMIN_USER_ADVANCED")
@@ -97,7 +117,13 @@ const AdminUserPage = () => {
</div> </div>
), ),
}, },
] as ColumnDef<User & { discordAccounts: DiscordAccount[] }>[] ] as ColumnDef<
User & {
DiscordAccount: DiscordAccount;
ReceivedReports: Report[];
Penaltys: Penalty[];
}
>[]
} // 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

@@ -0,0 +1,68 @@
"use client";
import MDEditor from "@uiw/react-md-editor";
import Image from "next/image";
export type TimelineEntry = {
id: number;
title: string;
text: string;
previewImage?: string | null;
createdAt: string;
};
const formatReleaseDate = (value: string) =>
new Intl.DateTimeFormat("en-GB", {
day: "2-digit",
month: "short",
year: "numeric",
}).format(new Date(value));
export const ChangelogTimeline = ({ entries }: { entries: TimelineEntry[] }) => {
if (!entries.length)
return <p className="text-base-content/70">Es sind noch keine Changelog-Einträge vorhanden.</p>;
return (
<div className="relative mt-6 pl-6">
<div className="bg-base-300 absolute bottom-0 left-2 top-0 w-px" aria-hidden />
<div className="space-y-8">
{entries.map((entry, idx) => (
<article key={entry.id ?? `${entry.title}-${idx}`} className="relative pl-4">
<div className="bg-primary ring-base-100 absolute -left-[9px] top-3 h-4 w-4 rounded-full ring-4" />
<div className="bg-base-200/80 rounded-xl p-5 shadow">
<div className="flex flex-col gap-1 text-left md:flex-row md:justify-between">
<div>
<h3 className="text-lg font-semibold leading-tight">{entry.title}</h3>
<p className="text-base-content/60 text-sm">
Release Date: {formatReleaseDate(entry.createdAt)}
</p>
</div>
{entry.previewImage && (
<div className="absolute right-5 top-5 md:pl-4">
<Image
src={entry.previewImage}
width={300}
height={300}
alt={`${entry.title} preview`}
className="mt-3 max-w-[300px] rounded-lg object-cover md:mt-0"
/>
</div>
)}
</div>
<div className="text-base-content/80 text-left" data-color-mode="dark">
<MDEditor.Markdown
source={entry.text}
style={{
backgroundColor: "transparent",
fontSize: "0.95rem",
}}
/>
</div>
</div>
</article>
))}
</div>
</div>
);
};

View File

@@ -0,0 +1,26 @@
import { prisma } from "@repo/db";
import { ChangelogTimeline } from "./_components/Timeline";
import { ActivityLogIcon } from "@radix-ui/react-icons";
export default async function Page() {
const changelog = await prisma.changelog.findMany({
where: { showOnChangelogPage: true },
orderBy: { createdAt: "desc" },
});
const entries = changelog.map((entry) => ({
...entry,
createdAt: entry.createdAt.toISOString(),
}));
return (
<>
<div className="w-full px-4">
<p className="flex items-center gap-2 text-left text-2xl font-semibold">
<ActivityLogIcon className="h-5 w-5" /> Changelog
</p>
</div>
<ChangelogTimeline entries={entries} />
</>
);
}

View File

@@ -31,7 +31,7 @@ export const ProfileForm = ({
}: { }: {
user: User; user: User;
penaltys: Penalty[]; penaltys: Penalty[];
discordAccount?: DiscordAccount; discordAccount: DiscordAccount | null;
}): React.JSX.Element => { }): React.JSX.Element => {
const canEdit = penaltys.length === 0 && !user.isBanned; const canEdit = penaltys.length === 0 && !user.isBanned;
@@ -215,9 +215,11 @@ export const ProfileForm = ({
export const SocialForm = ({ export const SocialForm = ({
discordAccount, discordAccount,
user, user,
penaltys,
}: { }: {
discordAccount?: DiscordAccount; discordAccount: DiscordAccount | null;
user: User; user: User;
penaltys: Penalty[];
}): React.JSX.Element | null => { }): React.JSX.Element | null => {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [vatsimLoading, setVatsimLoading] = useState(false); const [vatsimLoading, setVatsimLoading] = useState(false);
@@ -235,6 +237,7 @@ export const SocialForm = ({
}, },
resolver: zodResolver(schema), resolver: zodResolver(schema),
}); });
const canUnlinkDiscord = !user.isBanned && penaltys.length === 0;
if (!user) return null; if (!user) return null;
return ( return (
@@ -262,7 +265,7 @@ export const SocialForm = ({
</h2> </h2>
<div> <div>
<div> <div>
{discordAccount ? ( {discordAccount && canUnlinkDiscord ? (
<Button <Button
className="btn-success btn-block btn-outline hover:btn-error group transition-all duration-0" className="btn-success btn-block btn-outline hover:btn-error group transition-all duration-0"
isLoading={isLoading} isLoading={isLoading}
@@ -329,14 +332,13 @@ export const SocialForm = ({
export const DeleteForm = ({ export const DeleteForm = ({
user, user,
penaltys, penaltys,
reports,
}: { }: {
user: User; user: User;
penaltys: Penalty[]; penaltys: Penalty[];
reports: Report[]; reports: Report[];
}) => { }) => {
const router = useRouter(); const router = useRouter();
const userCanDelete = penaltys.length === 0 && !user.isBanned && reports.length === 0; const userCanDelete = penaltys.length === 0 && !user.isBanned;
return ( return (
<div className="card-body"> <div className="card-body">
<h2 className="card-title mb-5"> <h2 className="card-title mb-5">
@@ -344,11 +346,11 @@ export const DeleteForm = ({
</h2> </h2>
{!userCanDelete && ( {!userCanDelete && (
<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 nicht löschen!</h2>
<p className="text-sm text-gray-400"> <p className="text-sm text-gray-400">
Scheinbar hast du Strafen oder Reports in deinem Profil hinterlegt. Um unsere Community Da du Strafen hast oder hattest, kannst du deinen Account nicht löschen. Um unsere
zu schützen kannst du deinen Account nicht löschen. Bitte erstelle ein Support-Ticket, Community zu schützen kannst du deinen Account nicht löschen. Bitte erstelle ein
wenn du Fragen dazu hast. Support-Ticket, wenn du Fragen dazu hast.
</p> </p>
</div> </div>
)} )}

View File

@@ -4,9 +4,19 @@ import { getServerSession } from "../../api/auth/[...nextauth]/auth";
import bcrypt from "bcryptjs"; import bcrypt from "bcryptjs";
export const unlinkDiscord = async (userId: string) => { export const unlinkDiscord = async (userId: string) => {
await prisma.discordAccount.deleteMany({ const discordAccount = await prisma.discordAccount.update({
where: { where: {
userId: userId,
},
data: {
userId: null,
},
});
await prisma.formerDiscordAccount.create({
data: {
userId, userId,
discordId: discordAccount.discordId,
}, },
}); });
}; };

View File

@@ -3,6 +3,7 @@ import { getServerSession } from "../../api/auth/[...nextauth]/auth";
import { ProfileForm, SocialForm, PasswordForm, DeleteForm } from "./_components/forms"; import { ProfileForm, SocialForm, PasswordForm, DeleteForm } from "./_components/forms";
import { GearIcon } from "@radix-ui/react-icons"; import { GearIcon } from "@radix-ui/react-icons";
import { Error } from "_components/Error"; import { Error } from "_components/Error";
import { getUserPenaltys } from "@repo/shared-components";
export default async function Page() { export default async function Page() {
const session = await getServerSession(); const session = await getServerSession();
@@ -13,15 +14,17 @@ export default async function Page() {
id: session.user.id, id: session.user.id,
}, },
include: { include: {
discordAccounts: true, DiscordAccount: true,
Penaltys: true, Penaltys: true,
}, },
}); });
const userPenaltys = await prisma.penalty.findMany({ const userPenaltys = await prisma.penalty.findMany({
where: { where: {
userId: session.user.id, userId: session.user.id,
suspended: false,
}, },
}); });
const activePenaltys = await getUserPenaltys(session.user.id);
const userReports = await prisma.report.findMany({ const userReports = await prisma.report.findMany({
where: { where: {
@@ -30,7 +33,7 @@ export default async function Page() {
}); });
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?.DiscordAccount;
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">
@@ -39,16 +42,24 @@ export default async function Page() {
</p> </p>
</div> </div>
<div className="card bg-base-200 col-span-6 mb-4 shadow-xl 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}
discordAccount={discordAccount}
penaltys={[...activePenaltys.openBans, ...activePenaltys.openTimeban]}
/>
</div> </div>
<div className="card bg-base-200 col-span-6 mb-4 shadow-xl 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
user={user}
discordAccount={discordAccount}
penaltys={[...activePenaltys.openBans, ...activePenaltys.openTimeban]}
/>
</div> </div>
<div className="card bg-base-200 col-span-6 mb-4 shadow-xl 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 col-span-6 mb-4 shadow-xl 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} reports={userReports} /> <DeleteForm user={user} reports={userReports} penaltys={userPenaltys} />
</div> </div>
</div> </div>
); );

View File

@@ -8,6 +8,7 @@ import { deleteBookingAPI, getBookingsAPI } from "(app)/_querys/bookings";
import { Button } from "@repo/shared-components"; import { Button } from "@repo/shared-components";
import { formatTimeRange } from "../../helper/timerange"; import { formatTimeRange } from "../../helper/timerange";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import Link from "next/link";
interface BookingTimelineModalProps { interface BookingTimelineModalProps {
isOpen: boolean; isOpen: boolean;
@@ -298,7 +299,17 @@ export const BookingTimelineModal = ({
? "LST" ? "LST"
: booking.Station.bosCallsignShort || booking.Station.bosCallsign} : booking.Station.bosCallsignShort || booking.Station.bosCallsign}
</span> </span>
{currentUser?.permissions.includes("ADMIN_USER") ? (
<Link
href={`/admin/user/${booking.User.publicId}`}
className="link link-hover text-xs opacity-70"
>
{booking.User.fullName}
</Link>
) : (
<span className="text-sm font-medium">{booking.User.fullName}</span> <span className="text-sm font-medium">{booking.User.fullName}</span>
)}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="text-right"> <div className="text-right">

View File

@@ -6,13 +6,15 @@ import {
RocketIcon, RocketIcon,
ReaderIcon, ReaderIcon,
DownloadIcon, DownloadIcon,
UpdateIcon,
ActivityLogIcon,
} from "@radix-ui/react-icons"; } from "@radix-ui/react-icons";
import Link from "next/link"; import Link from "next/link";
import { WarningAlert } from "./ui/PageAlert"; import { WarningAlert } from "./ui/PageAlert";
import { getServerSession } from "api/auth/[...nextauth]/auth"; import { getServerSession } from "api/auth/[...nextauth]/auth";
import { Error } from "./Error"; import { Error } from "./Error";
import Image from "next/image"; import Image from "next/image";
import { Plane, Radar, Workflow } from "lucide-react"; import { Loader, Plane, Radar, Workflow } from "lucide-react";
import { BookingButton } from "./BookingButton"; import { BookingButton } from "./BookingButton";
export const VerticalNav = async () => { export const VerticalNav = async () => {
@@ -22,7 +24,8 @@ export const VerticalNav = async () => {
return p.startsWith("ADMIN"); return p.startsWith("ADMIN");
}); });
return ( return (
<ul className="menu bg-base-300 w-64 flex-nowrap rounded-lg p-3 font-semibold shadow-md"> <ul className="menu bg-base-300 flex w-64 flex-nowrap justify-between rounded-lg p-3 font-semibold shadow-md">
<div className="border-none">
<li> <li>
<Link href="/"> <Link href="/">
<HomeIcon /> Dashboard <HomeIcon /> Dashboard
@@ -109,6 +112,13 @@ export const VerticalNav = async () => {
</details> </details>
</li> </li>
)} )}
</div>
<li>
<Link href="/changelog">
<ActivityLogIcon />
Changelog
</Link>
</li>
</ul> </ul>
); );
}; };

View File

@@ -49,13 +49,13 @@ export default function SortableTable<TData>({
return ( return (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="table table-zebra w-full"> <table className="table-zebra table w-full">
<thead> <thead>
{table.getHeaderGroups().map((headerGroup) => ( {table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}> <tr key={headerGroup.id}>
{headerGroup.headers.map((header) => ( {headerGroup.headers.map((header) => (
<th key={header.id} onClick={header.column.getToggleSortingHandler()}> <th key={header.id} onClick={header.column.getToggleSortingHandler()}>
<div className="flex items-center gap-1 cursor-pointer"> <div className="flex cursor-pointer items-center gap-1">
{flexRender(header.column.columnDef.header, header.getContext())} {flexRender(header.column.columnDef.header, header.getContext())}
{header.column.getIsSorted() === "asc" && <ChevronUp size={16} />} {header.column.getIsSorted() === "asc" && <ChevronUp size={16} />}
{header.column.getIsSorted() === "desc" && <ChevronDown size={16} />} {header.column.getIsSorted() === "desc" && <ChevronDown size={16} />}
@@ -75,7 +75,7 @@ export default function SortableTable<TData>({
))} ))}
{table.getRowModel().rows.length === 0 && ( {table.getRowModel().rows.length === 0 && (
<tr> <tr>
<td colSpan={columns.length} className="text-center font-bold text-sm text-gray-500"> <td colSpan={columns.length} className="text-center text-sm font-bold text-gray-500">
Keine Daten gefunden Keine Daten gefunden
</td> </td>
</tr> </tr>
@@ -104,6 +104,8 @@ export const RowsPerPage = ({
<option value={50}>50</option> <option value={50}>50</option>
<option value={100}>100</option> <option value={100}>100</option>
<option value={300}>300</option> <option value={300}>300</option>
<option value={1000}>1000</option>
<option value={5000}>5000</option>
</select> </select>
); );
}; };

View File

@@ -3,6 +3,8 @@ import { NextRequest, NextResponse } from "next/server";
import { DiscordAccount, prisma } from "@repo/db"; import { DiscordAccount, prisma } from "@repo/db";
import { getServerSession } from "../auth/[...nextauth]/auth"; import { getServerSession } from "../auth/[...nextauth]/auth";
import { setStandardName } from "../../../helper/discord"; import { setStandardName } from "../../../helper/discord";
import { getUserPenaltys } from "@repo/shared-components";
import { markDuplicate } from "(app)/admin/user/action";
export const GET = async (req: NextRequest) => { export const GET = async (req: NextRequest) => {
const session = await getServerSession(); const session = await getServerSession();
@@ -77,6 +79,29 @@ export const GET = async (req: NextRequest) => {
userId: user.id, userId: user.id,
}); });
} }
const formerDiscordAccount = await prisma.formerDiscordAccount.findMany({
where: {
discordId: discordUser.id,
userId: {
not: session.user.id,
},
User: {
canonicalUserId: null,
},
},
include: { User: true },
});
// Account is suspicious to multiaccounting
if (formerDiscordAccount.length > 0) {
formerDiscordAccount.forEach(async (account) => {
await markDuplicate({
duplicateUserId: session.user.id,
canonicalPublicId: account.User!.publicId,
reason: "Multiaccounting Verdacht, gleicher Discord Account wie ein anderer Nutzer.",
});
});
}
return NextResponse.redirect(`${process.env.NEXT_PUBLIC_HUB_URL}/settings`); return NextResponse.redirect(`${process.env.NEXT_PUBLIC_HUB_URL}/settings`);
} catch (error) { } catch (error) {

View File

@@ -1,6 +1,10 @@
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
/* const removeImports = require("next-remove-imports")(); */ /* const removeImports = require("next-remove-imports")(); */
/* const nextConfig = removeImports({}); */ /* const nextConfig = removeImports({}); */
const nextConfig = {}; const nextConfig = {
images: {
domains: ["cdn.discordapp.com", "nextcloud.virtualairrescue.com"],
},
};
export default nextConfig; export default nextConfig;

View File

@@ -8,10 +8,10 @@
"schema": "./prisma/schema/" "schema": "./prisma/schema/"
}, },
"scripts": { "scripts": {
"generate": "npx prisma generate && npx prisma generate zod", "generate": "npx prisma@6.12.0 generate && npx prisma@6.12.0 generate zod",
"migrate": "npx prisma migrate dev", "migrate": "npx prisma@6.12.0 migrate dev",
"deploy": "npx prisma migrate deploy", "deploy": "npx prisma@6.12.0 migrate deploy",
"dev": "npx prisma studio --browser none" "dev": "npx prisma@6.12.0 studio --browser none"
}, },
"exports": { "exports": {
".": "./index.ts", ".": "./index.ts",

View File

@@ -4,4 +4,5 @@ model Changelog {
previewImage String? previewImage String?
text String text String
createdAt DateTime @default(now()) createdAt DateTime @default(now())
showOnChangelogPage Boolean @default(true)
} }

View File

@@ -0,0 +1,33 @@
/*
Warnings:
- You are about to drop the column `user_id` on the `discord_accounts` table. All the data in the column will be lost.
*/
-- DropForeignKey
ALTER TABLE "discord_accounts" DROP CONSTRAINT "discord_accounts_user_id_fkey";
-- AlterTable
ALTER TABLE "discord_accounts" RENAME COLUMN "user_id" TO "userId";
-- CreateTable
CREATE TABLE "former_discord_accounts" (
"id" SERIAL NOT NULL,
"discord_id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"removed_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "former_discord_accounts_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "former_discord_accounts_discord_id_key" ON "former_discord_accounts"("discord_id");
-- AddForeignKey
ALTER TABLE "former_discord_accounts" ADD CONSTRAINT "former_discord_accounts_discord_id_fkey" FOREIGN KEY ("discord_id") REFERENCES "discord_accounts"("discord_id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "former_discord_accounts" ADD CONSTRAINT "former_discord_accounts_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "discord_accounts" ADD CONSTRAINT "discord_accounts_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1,35 @@
/*
Warnings:
- You are about to drop the `former_discord_accounts` table. If the table is not empty, all the data it contains will be lost.
*/
-- DropForeignKey
ALTER TABLE "former_discord_accounts" DROP CONSTRAINT "former_discord_accounts_discord_id_fkey";
-- DropForeignKey
ALTER TABLE "former_discord_accounts" DROP CONSTRAINT "former_discord_accounts_user_id_fkey";
-- AlterTable
ALTER TABLE "discord_accounts" ALTER COLUMN "userId" DROP NOT NULL;
-- DropTable
DROP TABLE "former_discord_accounts";
-- CreateTable
CREATE TABLE "FormerDiscordAccount" (
"discord_id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"removed_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "FormerDiscordAccount_pkey" PRIMARY KEY ("discord_id","user_id")
);
-- CreateIndex
CREATE UNIQUE INDEX "FormerDiscordAccount_discord_id_key" ON "FormerDiscordAccount"("discord_id");
-- AddForeignKey
ALTER TABLE "FormerDiscordAccount" ADD CONSTRAINT "FormerDiscordAccount_discord_id_fkey" FOREIGN KEY ("discord_id") REFERENCES "discord_accounts"("discord_id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FormerDiscordAccount" ADD CONSTRAINT "FormerDiscordAccount_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,8 @@
/*
Warnings:
- A unique constraint covering the columns `[userId]` on the table `discord_accounts` will be added. If there are existing duplicate values, this will fail.
*/
-- CreateIndex
CREATE UNIQUE INDEX "discord_accounts_userId_key" ON "discord_accounts"("userId");

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Changelog" ADD COLUMN "showOnChangelogPage" BOOLEAN NOT NULL DEFAULT true;

View File

@@ -68,7 +68,6 @@ model User {
duplicateReason String? @map(name: "duplicate_reason") duplicateReason String? @map(name: "duplicate_reason")
// relations: // relations:
oauthTokens OAuthToken[] oauthTokens OAuthToken[]
discordAccounts DiscordAccount[]
participants Participant[] participants Participant[]
EventAppointmentUser EventAppointment[] @relation("EventAppointmentUser") EventAppointmentUser EventAppointment[] @relation("EventAppointmentUser")
EventAppointment EventAppointment[] EventAppointment EventAppointment[]
@@ -86,13 +85,26 @@ model User {
CreatedPenalties Penalty[] @relation("CreatedPenalties") CreatedPenalties Penalty[] @relation("CreatedPenalties")
Bookings Booking[] Bookings Booking[]
DiscordAccount DiscordAccount?
FormerDiscordAccounts FormerDiscordAccount[]
@@map(name: "users") @@map(name: "users")
} }
model FormerDiscordAccount {
discordId String @unique @map(name: "discord_id")
userId String @map(name: "user_id")
removedAt DateTime @default(now()) @map(name: "removed_at")
DiscordAccount DiscordAccount? @relation(fields: [discordId], references: [discordId], onDelete: SetNull)
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@id([discordId, userId])
}
model DiscordAccount { model DiscordAccount {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
discordId String @unique @map(name: "discord_id") discordId String @unique @map(name: "discord_id")
userId String @map(name: "user_id")
email String @map(name: "email") email String @map(name: "email")
username String @map(name: "username") username String @map(name: "username")
avatar String? @map(name: "avatar") avatar String? @map(name: "avatar")
@@ -104,8 +116,10 @@ model DiscordAccount {
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")
// relations: // Related User
user User @relation(fields: [userId], references: [id], onDelete: Cascade) // Beziehung zu User userId String? @unique
User User? @relation(fields: [userId], references: [id], onDelete: SetNull)
formerDiscordAccount FormerDiscordAccount?
@@map(name: "discord_accounts") @@map(name: "discord_accounts")
} }