diff --git a/prisma/migrations/20250524155917_punishments/migration.sql b/prisma/migrations/20250524155917_punishments/migration.sql
new file mode 100644
index 0000000..6efd7e0
--- /dev/null
+++ b/prisma/migrations/20250524155917_punishments/migration.sql
@@ -0,0 +1,36 @@
+-- CreateEnum
+CREATE TYPE "PunishmentType" AS ENUM ('WARNING', 'TEMP_EXILE', 'PERM_EXILE');
+
+-- AlterTable
+ALTER TABLE "miis" ADD COLUMN "punishmentId" INTEGER;
+
+-- CreateTable
+CREATE TABLE "mii_punishments" (
+ "punishmentId" INTEGER NOT NULL,
+ "miiId" INTEGER NOT NULL,
+ "reason" TEXT NOT NULL,
+
+ CONSTRAINT "mii_punishments_pkey" PRIMARY KEY ("punishmentId","miiId")
+);
+
+-- CreateTable
+CREATE TABLE "punishments" (
+ "id" SERIAL NOT NULL,
+ "userId" INTEGER NOT NULL,
+ "type" "PunishmentType" NOT NULL,
+ "notes" TEXT NOT NULL,
+ "reasons" TEXT[],
+ "expiresAt" TIMESTAMP(3),
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "punishments_pkey" PRIMARY KEY ("id")
+);
+
+-- AddForeignKey
+ALTER TABLE "mii_punishments" ADD CONSTRAINT "mii_punishments_punishmentId_fkey" FOREIGN KEY ("punishmentId") REFERENCES "punishments"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "mii_punishments" ADD CONSTRAINT "mii_punishments_miiId_fkey" FOREIGN KEY ("miiId") REFERENCES "miis"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "punishments" ADD CONSTRAINT "punishments_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index fe62043..9eaf964 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -21,12 +21,14 @@ model User {
usernameUpdatedAt DateTime?
imageUpdatedAt DateTime?
- accounts Account[]
- sessions Session[]
- miis Mii[]
- likes Like[]
- reportsAuthored Report[] @relation("ReportAuthor")
- reports Report[] @relation("ReportTargetCreator")
+ accounts Account[]
+ sessions Session[]
+ miis Mii[]
+ likes Like[]
+
+ reportsAuthored Report[] @relation("ReportAuthor")
+ reports Report[] @relation("ReportTargetCreator")
+ punishments Punishment[]
@@map("users")
}
@@ -84,6 +86,9 @@ model Mii {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
likedBy Like[]
+ punishmentId Int?
+ punishments MiiPunishment[]
+
@@map("miis")
}
@@ -118,6 +123,35 @@ model Report {
@@map("reports")
}
+model MiiPunishment {
+ punishmentId Int
+ miiId Int
+ reason String
+
+ punishment Punishment @relation(fields: [punishmentId], references: [id], onDelete: Cascade)
+ mii Mii @relation(fields: [miiId], references: [id], onDelete: Cascade)
+
+ @@id([punishmentId, miiId])
+ @@map("mii_punishments")
+}
+
+model Punishment {
+ id Int @id @default(autoincrement())
+ userId Int
+ type PunishmentType
+
+ notes String
+ reasons String[]
+ violatingMiis MiiPunishment[]
+
+ expiresAt DateTime?
+ createdAt DateTime @default(now())
+
+ user User @relation(fields: [userId], references: [id])
+
+ @@map("punishments")
+}
+
enum MiiGender {
MALE
FEMALE
@@ -140,3 +174,9 @@ enum ReportStatus {
RESOLVED
DISMISSED
}
+
+enum PunishmentType {
+ WARNING
+ TEMP_EXILE
+ PERM_EXILE
+}
diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx
index ceb9a48..5b0a5f1 100644
--- a/src/app/admin/page.tsx
+++ b/src/app/admin/page.tsx
@@ -5,6 +5,7 @@ import { auth } from "@/lib/auth";
import BannerForm from "@/components/admin/banner-form";
import ControlCenter from "@/components/admin/control-center";
+import UserManagement from "@/components/admin/user-management";
import Reports from "@/components/admin/reports";
export const metadata: Metadata = {
@@ -46,6 +47,15 @@ export default async function AdminPage() {
+ {/* Separator */}
+
+
+ User Management
+
+
+
+
+
{/* Separator */}
diff --git a/src/app/api/admin/lookup/route.ts b/src/app/api/admin/lookup/route.ts
new file mode 100644
index 0000000..3020f3c
--- /dev/null
+++ b/src/app/api/admin/lookup/route.ts
@@ -0,0 +1,53 @@
+import { NextRequest, NextResponse } from "next/server";
+
+import { auth } from "@/lib/auth";
+import { prisma } from "@/lib/prisma";
+import { idSchema } from "@/lib/schemas";
+
+export async function GET(request: NextRequest) {
+ const session = await auth();
+ if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+
+ if (Number(session.user.id) !== Number(process.env.NEXT_PUBLIC_ADMIN_USER_ID)) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
+
+ const searchParams = request.nextUrl.searchParams;
+ const parsed = idSchema.safeParse(searchParams.get("id"));
+
+ if (!parsed.success) return NextResponse.json({ error: parsed.error.errors[0].message }, { status: 400 });
+ const userId = parsed.data;
+
+ const user = await prisma.user.findUnique({
+ where: {
+ id: userId,
+ },
+ include: {
+ punishments: {
+ select: {
+ id: true,
+ type: true,
+ notes: true,
+ reasons: true,
+ violatingMiis: {
+ select: {
+ miiId: true,
+ reason: true,
+ },
+ },
+ expiresAt: true,
+ createdAt: true,
+ },
+ },
+ },
+ });
+
+ if (!user) return NextResponse.json({ error: "No user found" }, { status: 404 });
+
+ return NextResponse.json({
+ success: true,
+ name: user.name,
+ username: user.username,
+ image: user.image,
+ createdAt: user.createdAt,
+ punishments: user.punishments,
+ });
+}
diff --git a/src/app/api/admin/punish/route.ts b/src/app/api/admin/punish/route.ts
new file mode 100644
index 0000000..80000d2
--- /dev/null
+++ b/src/app/api/admin/punish/route.ts
@@ -0,0 +1,69 @@
+import { NextRequest, NextResponse } from "next/server";
+
+import { z } from "zod";
+import dayjs from "dayjs";
+
+import { auth } from "@/lib/auth";
+import { prisma } from "@/lib/prisma";
+import { idSchema } from "@/lib/schemas";
+import { PunishmentType } from "@prisma/client";
+
+const punishSchema = z.object({
+ type: z.enum([PunishmentType.WARNING, PunishmentType.TEMP_EXILE, PunishmentType.PERM_EXILE]),
+ duration: z
+ .number({ message: "Duration (days) must be a number" })
+ .int({ message: "Duration (days) must be an integer" })
+ .positive({ message: "Duration (days) must be valid" }),
+ notes: z.string(),
+ reasons: z.array(z.string()).optional(),
+ miiReasons: z
+ .array(
+ z.object({
+ id: z
+ .number({ message: "Mii ID must be a number" })
+ .int({ message: "Mii ID must be an integer" })
+ .positive({ message: "Mii ID must be valid" }),
+ reason: z.string(),
+ })
+ )
+ .optional(),
+});
+
+export async function POST(request: NextRequest) {
+ const session = await auth();
+ if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+
+ if (Number(session.user.id) !== Number(process.env.NEXT_PUBLIC_ADMIN_USER_ID)) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
+
+ const searchParams = request.nextUrl.searchParams;
+ const parsedUserId = idSchema.safeParse(searchParams.get("id"));
+
+ if (!parsedUserId.success) return NextResponse.json({ error: parsedUserId.error.errors[0].message }, { status: 400 });
+ const userId = parsedUserId.data;
+
+ const body = await request.json();
+ const parsed = punishSchema.safeParse(body);
+
+ if (!parsed.success) return NextResponse.json({ error: parsed.error.errors[0].message }, { status: 400 });
+ const { type, duration, notes, reasons, miiReasons } = parsed.data;
+
+ const expiresAt = type === "TEMP_EXILE" ? dayjs().add(duration, "days").toDate() : null;
+
+ await prisma.punishment.create({
+ data: {
+ userId,
+ type: type as PunishmentType,
+ expiresAt,
+ notes,
+ reasons: reasons?.length !== 0 ? reasons : [],
+ violatingMiis: {
+ create: miiReasons?.map((mii) => ({
+ miiId: mii.id,
+ reason: mii.reason,
+ })),
+ },
+ },
+ });
+
+ return NextResponse.json({ success: true });
+}
diff --git a/src/components/admin/reports.tsx b/src/components/admin/reports.tsx
index d42bcdc..3f56c84 100644
--- a/src/components/admin/reports.tsx
+++ b/src/components/admin/reports.tsx
@@ -26,7 +26,7 @@ export default async function Reports() {
{reports.map((report) => (
-
+
();
+
+ const [type, setType] = useState("WARNING");
+ const [duration, setDuration] = useState(1);
+ const [notes, setNotes] = useState("");
+ const [reasons, setReasons] = useState("");
+
+ const [miiList, setMiiList] = useState([]);
+ const [newMii, setNewMii] = useState({
+ id: 0,
+ reason: "",
+ });
+
+ const [error, setError] = useState(undefined);
+
+ const addMiiToList = () => {
+ if (newMii.id && newMii.reason) {
+ setMiiList([...miiList, { ...newMii, id: Number(newMii.id) }]);
+ setNewMii({ id: 0, reason: "" });
+ }
+ };
+
+ const removeMiiFromList = (index: number) => {
+ setMiiList(miiList.filter((_, i) => i !== index));
+ };
+
+ const handleLookup = async () => {
+ const response = await fetch(`/api/admin/lookup?id=${userId}`);
+ const data = await response.json();
+ setUser(data);
+ };
+
+ const handleSubmit = async () => {
+ // todo: delete punishments
+ const response = await fetch(`/api/admin/punish?id=${userId}`, {
+ method: "POST",
+ body: JSON.stringify({
+ type,
+ duration,
+ notes,
+ reasons: reasons.split(","),
+ miiReasons: miiList,
+ }),
+ });
+
+ if (!response.ok) {
+ const { error } = await response.json();
+ setError(error);
+ }
+
+ // Set all inputs to empty/default
+ setType("WARNING");
+ setDuration(1);
+ setNotes("");
+ setReasons("");
+ setMiiList([]);
+ setNewMii({ id: 0, reason: "" });
+ setError("");
+
+ await handleLookup();
+ };
+
+ return (
+
+
+ setUserId(Number(e.target.value))}
+ className="pill input w-full max-w-lg"
+ />
+
+
+
+ {user && (
+
+
+
+
+
+
{user.name}
+
@{user.username}
+
+ Created:{" "}
+ {new Date(user.createdAt).toLocaleString("en-GB", {
+ day: "2-digit",
+ month: "long",
+ year: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ second: "2-digit",
+ timeZone: "UTC",
+ })}{" "}
+ UTC
+
+
+
+
+
+
+
+ {user.punishments.length === 0 ? (
+
No punishments found.
+ ) : (
+ <>
+ {user.punishments.map((punishment) => (
+
+
+
+ {punishment.type}
+
+
+ {new Date(punishment.createdAt).toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "numeric" })}
+
+
+
+ Notes: {punishment.notes}
+
+
+ Expires:{" "}
+ {punishment.expiresAt
+ ? new Date(punishment.expiresAt).toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "numeric" })
+ : "Never"}
+
+
+ Reasons:
+
+
+ {punishment.reasons.map((reason, index) => (
+ - {reason}
+ ))}
+
+
+ Mii Reasons:
+
+
+ {punishment.violatingMiis.map((mii) => (
+ -
+ {mii.miiId}: {mii.reason}
+
+ ))}
+
+
+ ))}
+ >
+ )}
+
+
+
+
+ {/* Punishment type */}
+
Punishment Type
+
+
+ {/* Punishment duration */}
+ {type === "TEMP_EXILE" && (
+ <>
+
Duration
+
+ >
+ )}
+
+ {/* Punishment notes */}
+
Notes
+
+
+ )}
+
+ );
+}