Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 41 additions & 1 deletion apps/web/src/actions/admin/user-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { adminAction } from "@/lib/safe-action";
import { returnValidationErrors } from "next-safe-action";
import { z } from "zod";
import { perms } from "config";
import { userCommonData } from "db/schema";
import { userCommonData, bannedUsers } from "db/schema";
import { db } from "db";
import { eq } from "db/drizzle";
import { revalidatePath } from "next/cache";
Expand Down Expand Up @@ -60,3 +60,43 @@ export const setUserApproval = adminAction
return { success: true };
},
);

export const banUser = adminAction
.schema(
z.object({
userIDToUpdate: z.string(),
reason: z.string(),
}),
)
.action(
async ({
parsedInput: { userIDToUpdate, reason },
ctx: { user, userId },
}) => {
//TODO: Validate Permission

await db.insert(bannedUsers).values({
userID: userIDToUpdate,
reason: reason,
bannedByID: user.clerkID,
});
revalidatePath(`/admin/users/${userIDToUpdate}`);
return { success: true };
},
);

export const removeUserBan = adminAction
.schema(
z.object({
userIDToUpdate: z.string(),
}),
)
.action(async ({ parsedInput: { userIDToUpdate }, ctx: { user } }) => {
//TODO: Validate Permission

await db
.delete(bannedUsers)
.where(eq(bannedUsers.userID, userIDToUpdate));
revalidatePath(`/admin/users/${userIDToUpdate}`);
return { success: true };
});
30 changes: 30 additions & 0 deletions apps/web/src/app/admin/users/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ import { isUserAdmin } from "@/lib/utils/server/admin";
import ApproveUserButton from "@/components/admin/users/ApproveUserButton";
import c from "config";
import { getHacker, getUser } from "db/functions";
import BanUserDialog from "@/components/admin/users/BanUserDialog";
import { db, eq } from "db";
import { bannedUsers } from "db/schema";
import RemoveUserBanDialog from "@/components/admin/users/RemoveUserBanDialog";

export default async function Page({ params }: { params: { slug: string } }) {
const { userId } = await auth();
Expand All @@ -30,8 +34,20 @@ export default async function Page({ params }: { params: { slug: string } }) {
return <p className="text-center font-bold">User Not Found</p>;
}

const banInstance = await db.query.bannedUsers.findFirst({
where: eq(bannedUsers.userID, user.clerkID),
});

return (
<main className="mx-auto max-w-5xl pt-44">
{!!banInstance && (
<div className="absolute left-0 top-28 w-screen bg-destructive p-2 text-center">
<strong>
This user has been suspended, reason for suspenssion:{" "}
</strong>
{banInstance.reason}
</div>
)}
<div className="mb-5 grid w-full grid-cols-3">
<div className="flex items-center">
<div>
Expand All @@ -57,6 +73,20 @@ export default async function Page({ params }: { params: { slug: string } }) {
currPermision={user.role}
userID={user.clerkID}
/>

{!!banInstance ? (
<RemoveUserBanDialog
name={`${user.firstName} ${user.lastName}`}
reason={banInstance.reason!}
userID={user.clerkID}
/>
) : (
<BanUserDialog
name={`${user.firstName} ${user.lastName}`}
userID={user.clerkID}
/>
)}

{(c.featureFlags.core.requireUsersApproval as boolean) && (
<ApproveUserButton
userIDToUpdate={user.clerkID}
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@
--background: 240 10% 3.9%;
--foreground: 0 0% 98%;

--nav: 0, 0, 0;
--nav: 240 10% 3.9%;

--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%;
Expand Down
42 changes: 42 additions & 0 deletions apps/web/src/app/suspended/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { auth } from "@clerk/nextjs/server";
import { db, eq } from "db";
import { getUser } from "db/functions";
import { bannedUsers } from "db/schema";

export default async function Page() {
const { userId } = await auth();
if (!userId) return null;
const user = await getUser(userId);
if (!user) return null;

const banInstance = await db.query.bannedUsers.findFirst({
where: eq(bannedUsers.userID, userId),
});
if (!banInstance) return null;

return (
<div className="flex h-screen w-screen justify-center pt-16">
<div className="h-fit max-w-md border-2 border-accent p-6">
<h1 className="text-center text-2xl font-bold text-destructive">
Account Suspended
</h1>
<h1 className="pt-2 text-center text-lg font-medium">
Dear {user.firstName} {user.lastName},
</h1>
<h3 className="w-full text-center opacity-90">
{" "}
Your account was suspended
</h3>
<p className="w-full py-3 opacity-85">
<strong>Reason: </strong>
{banInstance.reason}
</p>
<footer className="border-t-2 border-accent pt-1 opacity-75">
Contact administration for further assistance.
</footer>
</div>
</div>
);
}

export const runtime = "edge";
79 changes: 79 additions & 0 deletions apps/web/src/components/admin/users/BanUserDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
"use client";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/shadcn/ui/dialog";
import { Button } from "@/components/shadcn/ui/button";
import { toast } from "sonner";
import { useAction } from "next-safe-action/hooks";
import { banUser } from "@/actions/admin/user-actions";
import { useState } from "react";
import { Textarea } from "@/components/shadcn/ui/textarea";

interface BanUserDialogProps {
userID: string;
name: string;
}

export default function BanUserDialog({ userID, name }: BanUserDialogProps) {
const [reason, setReason] = useState("");
const [open, setOpen] = useState(false);

const { execute } = useAction(banUser, {
async onSuccess() {
toast.dismiss();
toast.success("Successfully Banned!");
},
async onError(e) {
toast.dismiss();
toast.error("An error occurred while banning this user.");
console.error(e);
},
});

return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant={"destructive"}>Ban</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Ban {name}.</DialogTitle>
<DialogDescription>
Ban this user (not permament action).
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="flex">
<Textarea
placeholder="Reason"
rows={3}
onChange={(event) => setReason(event.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button
onClick={() => {
toast.loading("Banning User...", { duration: 0 });
execute({
userIDToUpdate: userID,
reason: reason,
});
setOpen(false);
}}
type="submit"
variant={"destructive"}
>
<span className="text-nowrap">Ban</span>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
67 changes: 67 additions & 0 deletions apps/web/src/components/admin/users/RemoveUserBanDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
"use client";
import {
Dialog,
DialogContent,
DialogTrigger,
} from "@/components/shadcn/ui/dialog";
import { Button } from "@/components/shadcn/ui/button";
import { toast } from "sonner";
import { useAction } from "next-safe-action/hooks";
import { removeUserBan } from "@/actions/admin/user-actions";
import { useState } from "react";

interface BanUserDialogProps {
userID: string;
name: string;
reason: string;
}

export default function RemoveUserBanDialog({
userID,
reason,
name,
}: BanUserDialogProps) {
const [open, setOpen] = useState(false);

const { execute } = useAction(removeUserBan, {
async onSuccess() {
toast.dismiss();
toast.success("Suspension successfuly removed!");
},
async onError(e) {
toast.dismiss();
toast.error("An error occurred while removing suspenssion.");
console.error(e);
},
});

return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant={"destructive"}>Unban</Button>
</DialogTrigger>
<DialogContent>
<div className="font-bold">
Are you sure you want to unban {name}?
</div>
<div className="pt-2">
<strong className="font-medium">Reason for ban:</strong>{" "}
{reason}
</div>
<Button
onClick={() => {
toast.loading("Unbanning User...", { duration: 0 });
execute({
userIDToUpdate: userID,
});
setOpen(false);
}}
type="submit"
variant={"destructive"}
>
<span className="text-nowrap">Unban</span>
</Button>
</DialogContent>
</Dialog>
);
}
12 changes: 11 additions & 1 deletion apps/web/src/middleware.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server";
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { publicRoutes } from "config";
import { bannedUsers } from "db/schema";
import { db } from "db";
import { eq } from "db/drizzle";

const isPublicRoute = createRouteMatcher(publicRoutes);

Expand All @@ -19,6 +21,14 @@ export default clerkMiddleware(async (auth, req) => {

if (!isPublicRoute(req)) {
await auth.protect();

const isBanned = !!(await db.query.bannedUsers.findFirst({
where: eq(bannedUsers.userID, (await auth()).userId!),
}));

if (isBanned) {
return NextResponse.rewrite(new URL("/suspended", req.url));
}
}

return NextResponse.next();
Expand Down
21 changes: 21 additions & 0 deletions packages/db/drizzle/0001_smooth_squadron_supreme.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
-- Drop old stuff (safe if they don't exist)
DROP TABLE IF EXISTS `invites`;
DROP TABLE IF EXISTS `teams`;

-- Remove the old column from user_hacker_data (SQLite ≥ 3.35 supports DROP COLUMN)
ALTER TABLE `user_hacker_data` DROP COLUMN `team_id`;

-- Create the final banned_users table in its desired shape
CREATE TABLE `banned_users` (
`id` INTEGER PRIMARY KEY NOT NULL,
`user_id` TEXT(255) NOT NULL,
`reason` TEXT,
`created_at` INTEGER DEFAULT (current_timestamp) NOT NULL,
`banned_by_id` TEXT(255) NOT NULL,
FOREIGN KEY (`user_id`)
REFERENCES `user_common_data`(`clerk_id`)
ON UPDATE NO ACTION ON DELETE CASCADE,
FOREIGN KEY (`banned_by_id`)
REFERENCES `user_common_data`(`clerk_id`)
ON UPDATE NO ACTION ON DELETE CASCADE
);
7 changes: 7 additions & 0 deletions packages/db/drizzle/meta/_journal.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@
"when": 1740069435681,
"tag": "0000_chilly_lady_mastermind",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1759264799447,
"tag": "0001_smooth_squadron_supreme",
"breakpoints": true
}
]
}
Loading