Rebase for Dockerfile in hub #150

Merged
PxlLoewe merged 13 commits from release into staging 2026-01-15 23:03:33 +00:00
26 changed files with 613 additions and 145 deletions
Showing only changes of commit b1e508ef36 - Show all commits

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 cron from "node-cron";
import { changeMemberRoles } from "routes/member";
const removeMission = async (id: number, reason: string) => {
const log: MissionLog = {
@@ -34,7 +35,6 @@ const removeMission = async (id: number, reason: string) => {
console.log(`Mission ${updatedMission.id} closed due to inactivity.`);
};
const removeClosedMissions = async () => {
const oldMissions = await prisma.mission.findMany({
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 () => {
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) => {
if (!p.Event.discordRoleId) return;
if (eventCompleted(p.Event, p)) {
@@ -48,8 +67,12 @@ router.post("/set-standard-name", async (req, res) => {
const isPilot = user.permissions.includes("PILOT");
const isDispatcher = user.permissions.includes("DISPO");
await changeMemberRoles(memberId, [DISCORD_ROLES.PILOT], isPilot ? "add" : "remove");
await changeMemberRoles(memberId, [DISCORD_ROLES.DISPATCHER], isDispatcher ? "add" : "remove");
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.DISPATCHER], isDispatcher ? "add" : "remove");
}
});
export default router;

View File

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

View File

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

View File

@@ -23,6 +23,7 @@ export const ChangelogForm = ({ changelog }: { changelog?: Changelog }) => {
title: changelog?.title || "",
text: changelog?.text || "",
previewImage: changelog?.previewImage || "", // Changed to accept a URL as a string
showOnChangelogPage: changelog?.showOnChangelogPage || true,
},
});
const [skipUserUpdate, setSkipUserUpdate] = useState(false);
@@ -84,6 +85,7 @@ export const ChangelogForm = ({ changelog }: { changelog?: Changelog }) => {
placeholder="Titel (vX.X.X)"
className="input-sm"
/>
<Input
form={form}
label="Bild-URL"
@@ -146,6 +148,16 @@ export const ChangelogForm = ({ changelog }: { changelog?: Changelog }) => {
</span>
</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="flex w-full gap-4">
<Button

View File

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

View File

@@ -6,6 +6,7 @@ import {
ConnectedAircraft,
ConnectedDispatcher,
DiscordAccount,
FormerDiscordAccount,
Penalty,
PERMISSION,
Prisma,
@@ -60,6 +61,7 @@ import { penaltyColumns } from "(app)/admin/penalty/columns";
import { addPenalty, editPenaltys } from "(app)/admin/penalty/actions";
import { reportColumns } from "(app)/admin/report/columns";
import { sendMailByTemplate } from "../../../../../../helper/mail";
import Image from "next/image";
interface ProfileFormProps {
user: User;
@@ -566,6 +568,7 @@ interface AdminFormProps {
minutes: number;
lastLogin?: Date;
};
formerDiscordAccounts: (FormerDiscordAccount & { DiscordAccount: DiscordAccount | null })[];
reports: {
total: number;
open: number;
@@ -585,6 +588,7 @@ export const AdminForm = ({
pilotTime,
reports,
discordAccount,
formerDiscordAccounts,
openBans,
openTimebans,
}: AdminFormProps) => {
@@ -755,6 +759,64 @@ export const AdminForm = ({
</p>
</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">
<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 }> }) {
const { id } = await params;
const user = await prisma.user.findUnique({
let user = await prisma.user.findUnique({
where: {
id: id,
},
include: {
discordAccounts: true,
DiscordAccount: true,
CanonicalUser: 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" />;
const dispoSessions = await prisma.connectedDispatcher.findMany({
@@ -121,11 +142,12 @@ export default async function Page({ params }: { params: Promise<{ id: string }>
</div>
<div className="card bg-base-200 col-span-6 mb-4 shadow-xl xl:col-span-3">
<AdminForm
formerDiscordAccounts={formerDiscordAccounts}
user={user}
dispoTime={dispoTime}
pilotTime={pilotTime}
reports={reports}
discordAccount={user.discordAccounts[0]}
discordAccount={user.DiscordAccount ?? undefined}
openBans={openBans}
openTimebans={openTimeban}
/>

View File

@@ -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 { DiscordAccount, Prisma, User } from "@repo/db";
import { DiscordAccount, Penalty, Prisma, User } from "@repo/db";
import { useSession } from "next-auth/react";
const AdminUserPage = () => {
@@ -21,16 +21,15 @@ const AdminUserPage = () => {
{ firstname: { contains: searchTerm, mode: "insensitive" } },
{ lastname: { contains: searchTerm, mode: "insensitive" } },
{ email: { contains: searchTerm, mode: "insensitive" } },
{
discordAccounts: {
some: { username: { contains: searchTerm, mode: "insensitive" } },
},
},
{ publicId: { contains: searchTerm, mode: "insensitive" } },
{ DiscordAccount: { username: { contains: searchTerm, mode: "insensitive" } } },
],
} as Prisma.UserWhereInput;
}}
include={{
discordAccounts: true,
DiscordAccount: true,
ReceivedReports: true,
Penaltys: true,
}}
initialOrderBy={[
{
@@ -55,6 +54,15 @@ const AdminUserPage = () => {
{
header: "Berechtigungen",
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) {
return <span className="text-gray-700">Keine</span>;
} 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",
cell(props) {
const discord = props.row.original.discordAccounts;
if (discord.length === 0) {
const discord = props.row.original.DiscordAccount;
if (!discord) {
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")
@@ -97,7 +117,13 @@ const AdminUserPage = () => {
</div>
),
},
] as ColumnDef<User & { discordAccounts: DiscordAccount[] }>[]
] as ColumnDef<
User & {
DiscordAccount: DiscordAccount;
ReceivedReports: Report[];
Penaltys: Penalty[];
}
>[]
} // Define the columns for the user table
leftOfSearch={
<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;
penaltys: Penalty[];
discordAccount?: DiscordAccount;
discordAccount: DiscordAccount | null;
}): React.JSX.Element => {
const canEdit = penaltys.length === 0 && !user.isBanned;
@@ -215,9 +215,11 @@ export const ProfileForm = ({
export const SocialForm = ({
discordAccount,
user,
penaltys,
}: {
discordAccount?: DiscordAccount;
discordAccount: DiscordAccount | null;
user: User;
penaltys: Penalty[];
}): React.JSX.Element | null => {
const [isLoading, setIsLoading] = useState(false);
const [vatsimLoading, setVatsimLoading] = useState(false);
@@ -235,6 +237,7 @@ export const SocialForm = ({
},
resolver: zodResolver(schema),
});
const canUnlinkDiscord = !user.isBanned && penaltys.length === 0;
if (!user) return null;
return (
@@ -262,7 +265,7 @@ export const SocialForm = ({
</h2>
<div>
<div>
{discordAccount ? (
{discordAccount && canUnlinkDiscord ? (
<Button
className="btn-success btn-block btn-outline hover:btn-error group transition-all duration-0"
isLoading={isLoading}
@@ -329,14 +332,13 @@ export const SocialForm = ({
export const DeleteForm = ({
user,
penaltys,
reports,
}: {
user: User;
penaltys: Penalty[];
reports: Report[];
}) => {
const router = useRouter();
const userCanDelete = penaltys.length === 0 && !user.isBanned && reports.length === 0;
const userCanDelete = penaltys.length === 0 && !user.isBanned;
return (
<div className="card-body">
<h2 className="card-title mb-5">
@@ -344,11 +346,11 @@ export const DeleteForm = ({
</h2>
{!userCanDelete && (
<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">
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.
Da du Strafen hast oder hattest, kannst du deinen Account nicht löschen. 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>
)}

View File

@@ -4,9 +4,19 @@ import { getServerSession } from "../../api/auth/[...nextauth]/auth";
import bcrypt from "bcryptjs";
export const unlinkDiscord = async (userId: string) => {
await prisma.discordAccount.deleteMany({
const discordAccount = await prisma.discordAccount.update({
where: {
userId: userId,
},
data: {
userId: null,
},
});
await prisma.formerDiscordAccount.create({
data: {
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 { GearIcon } from "@radix-ui/react-icons";
import { Error } from "_components/Error";
import { getUserPenaltys } from "@repo/shared-components";
export default async function Page() {
const session = await getServerSession();
@@ -13,15 +14,17 @@ export default async function Page() {
id: session.user.id,
},
include: {
discordAccounts: true,
DiscordAccount: true,
Penaltys: true,
},
});
const userPenaltys = await prisma.penalty.findMany({
where: {
userId: session.user.id,
suspended: false,
},
});
const activePenaltys = await getUserPenaltys(session.user.id);
const userReports = await prisma.report.findMany({
where: {
@@ -30,7 +33,7 @@ export default async function Page() {
});
if (!user) return <Error statusCode={401} title="Dein Account wurde nicht gefunden" />;
const discordAccount = user?.discordAccounts[0];
const discordAccount = user?.DiscordAccount;
return (
<div className="grid grid-cols-6 gap-4">
<div className="col-span-full">
@@ -39,16 +42,24 @@ export default async function Page() {
</p>
</div>
<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 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 className="card bg-base-200 col-span-6 mb-4 shadow-xl xl:col-span-3">
<PasswordForm />
</div>
<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>
);

View File

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

View File

@@ -6,13 +6,15 @@ import {
RocketIcon,
ReaderIcon,
DownloadIcon,
UpdateIcon,
ActivityLogIcon,
} from "@radix-ui/react-icons";
import Link from "next/link";
import { WarningAlert } from "./ui/PageAlert";
import { getServerSession } from "api/auth/[...nextauth]/auth";
import { Error } from "./Error";
import Image from "next/image";
import { Plane, Radar, Workflow } from "lucide-react";
import { Loader, Plane, Radar, Workflow } from "lucide-react";
import { BookingButton } from "./BookingButton";
export const VerticalNav = async () => {
@@ -22,93 +24,101 @@ export const VerticalNav = async () => {
return p.startsWith("ADMIN");
});
return (
<ul className="menu bg-base-300 w-64 flex-nowrap rounded-lg p-3 font-semibold shadow-md">
<li>
<Link href="/">
<HomeIcon /> Dashboard
</Link>
</li>
<li>
<Link href="/events">
<RocketIcon />
Events & Kurse
</Link>
</li>
<li>
<Link href="/logbook">
<ReaderIcon />
Einsatzhistorie
</Link>
</li>
<li>
<Link href="/settings">
<GearIcon />
Einstellungen
</Link>
</li>
<li>
<Link href="/resources">
<DownloadIcon />
Downloads / Links
</Link>
</li>
{viewAdminMenu && (
<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>
<details open>
<summary>
<LockClosedIcon />
Admin
</summary>
<ul>
{session.user.permissions.includes("ADMIN_USER") && (
<li>
<Link href="/admin/user">Benutzer</Link>
</li>
)}
{session.user.permissions.includes("ADMIN_STATION") && (
<li>
<Link href="/admin/station">Stationen</Link>
</li>
)}
{session.user.permissions.includes("ADMIN_KEYWORD") && (
<li>
<Link href="/admin/keyword">Stichworte</Link>
</li>
)}
{session.user.permissions.includes("ADMIN_HELIPORT") && (
<li>
<Link href="/admin/heliport">Heliports</Link>
</li>
)}
{session.user.permissions.includes("ADMIN_EVENT") && (
<li>
<Link href="/admin/event">Events</Link>
</li>
)}
{session.user.permissions.includes("ADMIN_MESSAGE") && (
<li>
<Link href="/admin/config">Config</Link>
</li>
)}
{session.user.permissions.includes("ADMIN_USER") && (
<li>
<Link href="/admin/report">Reports</Link>
</li>
)}
{session.user.permissions.includes("ADMIN_USER") && (
<li>
<Link href="/admin/penalty">Audit-Log</Link>
</li>
)}
{session.user.permissions.includes("ADMIN_CHANGELOG") && (
<li>
<Link href="/admin/changelog">Changelog</Link>
</li>
)}
</ul>
</details>
<Link href="/">
<HomeIcon /> Dashboard
</Link>
</li>
)}
<li>
<Link href="/events">
<RocketIcon />
Events & Kurse
</Link>
</li>
<li>
<Link href="/logbook">
<ReaderIcon />
Einsatzhistorie
</Link>
</li>
<li>
<Link href="/settings">
<GearIcon />
Einstellungen
</Link>
</li>
<li>
<Link href="/resources">
<DownloadIcon />
Downloads / Links
</Link>
</li>
{viewAdminMenu && (
<li>
<details open>
<summary>
<LockClosedIcon />
Admin
</summary>
<ul>
{session.user.permissions.includes("ADMIN_USER") && (
<li>
<Link href="/admin/user">Benutzer</Link>
</li>
)}
{session.user.permissions.includes("ADMIN_STATION") && (
<li>
<Link href="/admin/station">Stationen</Link>
</li>
)}
{session.user.permissions.includes("ADMIN_KEYWORD") && (
<li>
<Link href="/admin/keyword">Stichworte</Link>
</li>
)}
{session.user.permissions.includes("ADMIN_HELIPORT") && (
<li>
<Link href="/admin/heliport">Heliports</Link>
</li>
)}
{session.user.permissions.includes("ADMIN_EVENT") && (
<li>
<Link href="/admin/event">Events</Link>
</li>
)}
{session.user.permissions.includes("ADMIN_MESSAGE") && (
<li>
<Link href="/admin/config">Config</Link>
</li>
)}
{session.user.permissions.includes("ADMIN_USER") && (
<li>
<Link href="/admin/report">Reports</Link>
</li>
)}
{session.user.permissions.includes("ADMIN_USER") && (
<li>
<Link href="/admin/penalty">Audit-Log</Link>
</li>
)}
{session.user.permissions.includes("ADMIN_CHANGELOG") && (
<li>
<Link href="/admin/changelog">Changelog</Link>
</li>
)}
</ul>
</details>
</li>
)}
</div>
<li>
<Link href="/changelog">
<ActivityLogIcon />
Changelog
</Link>
</li>
</ul>
);
};

View File

@@ -49,13 +49,13 @@ export default function SortableTable<TData>({
return (
<div className="overflow-x-auto">
<table className="table table-zebra w-full">
<table className="table-zebra table w-full">
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<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())}
{header.column.getIsSorted() === "asc" && <ChevronUp size={16} />}
{header.column.getIsSorted() === "desc" && <ChevronDown size={16} />}
@@ -75,7 +75,7 @@ export default function SortableTable<TData>({
))}
{table.getRowModel().rows.length === 0 && (
<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
</td>
</tr>
@@ -104,6 +104,8 @@ export const RowsPerPage = ({
<option value={50}>50</option>
<option value={100}>100</option>
<option value={300}>300</option>
<option value={1000}>1000</option>
<option value={5000}>5000</option>
</select>
);
};

View File

@@ -3,6 +3,8 @@ import { NextRequest, NextResponse } from "next/server";
import { DiscordAccount, prisma } from "@repo/db";
import { getServerSession } from "../auth/[...nextauth]/auth";
import { setStandardName } from "../../../helper/discord";
import { getUserPenaltys } from "@repo/shared-components";
import { markDuplicate } from "(app)/admin/user/action";
export const GET = async (req: NextRequest) => {
const session = await getServerSession();
@@ -77,6 +79,29 @@ export const GET = async (req: NextRequest) => {
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`);
} catch (error) {

View File

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

View File

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

View File

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