diff --git a/.gitignore b/.gitignore
index adc80bcb..333247bf 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,7 +2,7 @@
# dependencies
node_modules
-.pnp
+.pnp*
.pnp.js
# testing
diff --git a/apps/web/package.json b/apps/web/package.json
index e087cd7c..00f634f2 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -94,7 +94,6 @@
"tailwind-merge": "^2.4.0",
"tailwindcss": "3.4.6",
"tailwindcss-animate": "^1.0.7",
- "title-case": "^4.3.1",
"typescript": "5.5.3",
"use-debounce": "^10.0.1",
"usehooks-ts": "^3.1.0",
diff --git a/apps/web/src/actions/admin/event-actions.ts b/apps/web/src/actions/admin/event-actions.ts
index 24ee4b71..722c8dfb 100644
--- a/apps/web/src/actions/admin/event-actions.ts
+++ b/apps/web/src/actions/admin/event-actions.ts
@@ -1,21 +1,25 @@
"use server";
+import { PermissionType } from "@/lib/constants/permission";
import { adminAction } from "@/lib/safe-action";
-import { newEventFormSchema as editEventFormSchema } from "@/validators/event";
-import { editEvent as modifyEvent } from "db/functions";
+import { userHasPermission } from "@/lib/utils/server/admin";
+import { newEventFormSchema, editEventFormSchema } from "@/validators/event";
+import { createNewEvent, editEvent as modifyEvent } from "db/functions";
import { deleteEvent as removeEvent } from "db/functions";
import { revalidatePath } from "next/cache";
import { z } from "zod";
export const editEvent = adminAction
.schema(editEventFormSchema)
- .action(async ({ parsedInput }) => {
- const { id, ...options } = parsedInput;
-
+ .action(async ({ parsedInput: { id, ...options }, ctx: { user } }) => {
if (id === undefined) {
throw new Error("The event's ID is not defined");
}
+ if (!userHasPermission(user, PermissionType.EDIT_EVENTS)) {
+ throw new Error("You do not have permission to edit events.");
+ }
+
try {
await modifyEvent(id, options);
revalidatePath("/admin/events");
@@ -29,9 +33,28 @@ export const editEvent = adminAction
}
});
+export const createEvent = adminAction
+ .schema(newEventFormSchema)
+ .action(async ({ parsedInput, ctx: { user } }) => {
+ if (!userHasPermission(user, PermissionType.CREATE_EVENTS)) {
+ throw new Error("You do not have permission to create events.");
+ }
+
+ const res = await createNewEvent(parsedInput);
+ return {
+ success: true,
+ message: "Event created successfully.",
+ redirect: `/schedule/${res[0].eventID}`,
+ };
+ });
+
export const deleteEventAction = adminAction
.schema(z.object({ eventID: z.number().positive().int() }))
- .action(async ({ parsedInput }) => {
+ .action(async ({ parsedInput, ctx: { user } }) => {
+ if (!userHasPermission(user, PermissionType.DELETE_EVENTS)) {
+ throw new Error("You do not have permission to delete events.");
+ }
+
await removeEvent(parsedInput.eventID);
revalidatePath("/admin/events");
return { success: true };
diff --git a/apps/web/src/actions/admin/modify-nav-item.ts b/apps/web/src/actions/admin/modify-nav-item.ts
index 11c575b7..35407525 100644
--- a/apps/web/src/actions/admin/modify-nav-item.ts
+++ b/apps/web/src/actions/admin/modify-nav-item.ts
@@ -6,6 +6,8 @@ import { redisSAdd, redisHSet, removeNavItem } from "@/lib/utils/server/redis";
import { revalidatePath } from "next/cache";
import { Redis } from "@upstash/redis";
+import { userHasPermission } from "@/lib/utils/server/admin";
+import { PermissionType } from "@/lib/constants/permission";
const redis = Redis.fromEnv();
@@ -25,6 +27,12 @@ const navAdminPage = "/admin/toggles/landing";
export const setItem = adminAction
.schema(metadataSchema)
.action(async ({ parsedInput: { name, url }, ctx: { user, userId } }) => {
+ if (!userHasPermission(user, PermissionType.MANAGE_NAVLINKS)) {
+ throw new Error(
+ "You do not have permission to manage navigation links.",
+ );
+ }
+
await redisSAdd("config:navitemslist", encodeURIComponent(name));
await redisHSet(`config:navitems:${encodeURIComponent(name)}`, {
url,
@@ -37,29 +45,45 @@ export const setItem = adminAction
export const editItem = adminAction
.schema(editMetadataSchema)
- .action(async ({ parsedInput: { name, url, existingName } }) => {
- const pipe = redis.pipeline();
+ .action(
+ async ({ parsedInput: { name, url, existingName }, ctx: { user } }) => {
+ if (!userHasPermission(user, PermissionType.MANAGE_NAVLINKS)) {
+ throw new Error(
+ "You do not have permission to manage navigation links.",
+ );
+ }
- if (existingName != name) {
- pipe.srem("config:navitemslist", encodeURIComponent(existingName));
- }
+ const pipe = redis.pipeline();
- pipe.sadd("config:navitemslist", encodeURIComponent(name));
- pipe.hset(`config:navitems:${encodeURIComponent(name)}`, {
- url,
- name,
- enabled: true,
- });
+ if (existingName != name) {
+ pipe.srem(
+ "config:navitemslist",
+ encodeURIComponent(existingName),
+ );
+ }
- await pipe.exec();
+ pipe.sadd("config:navitemslist", encodeURIComponent(name));
+ pipe.hset(`config:navitems:${encodeURIComponent(name)}`, {
+ url,
+ name,
+ enabled: true,
+ });
- revalidatePath(navAdminPage);
- return { success: true };
- });
+ await pipe.exec();
+
+ revalidatePath(navAdminPage);
+ return { success: true };
+ },
+ );
export const removeItem = adminAction
.schema(z.string())
.action(async ({ parsedInput: name, ctx: { user, userId } }) => {
+ if (!userHasPermission(user, PermissionType.MANAGE_NAVLINKS)) {
+ throw new Error(
+ "You do not have permission to manage navigation links.",
+ );
+ }
await removeNavItem(name);
// await new Promise((resolve) => setTimeout(resolve, 1500));
revalidatePath(navAdminPage);
@@ -73,6 +97,11 @@ export const toggleItem = adminAction
parsedInput: { name, statusToSet },
ctx: { user, userId },
}) => {
+ if (!userHasPermission(user, PermissionType.MANAGE_NAVLINKS)) {
+ throw new Error(
+ "You do not have permission to manage navigation links.",
+ );
+ }
await redisHSet(`config:navitems:${encodeURIComponent(name)}`, {
enabled: statusToSet,
});
diff --git a/apps/web/src/actions/admin/registration-actions.ts b/apps/web/src/actions/admin/registration-actions.ts
index 9ad5c027..e62d70d4 100644
--- a/apps/web/src/actions/admin/registration-actions.ts
+++ b/apps/web/src/actions/admin/registration-actions.ts
@@ -4,6 +4,8 @@ import { z } from "zod";
import { adminAction } from "@/lib/safe-action";
import { redisSet } from "@/lib/utils/server/redis";
import { revalidatePath } from "next/cache";
+import { userHasPermission } from "@/lib/utils/server/admin";
+import { PermissionType } from "@/lib/constants/permission";
const defaultRegistrationToggleSchema = z.object({
enabled: z.boolean(),
@@ -16,6 +18,12 @@ const defaultRSVPLimitSchema = z.object({
export const toggleRegistrationEnabled = adminAction
.schema(defaultRegistrationToggleSchema)
.action(async ({ parsedInput: { enabled }, ctx: { user, userId } }) => {
+ if (!userHasPermission(user, PermissionType.MANAGE_REGISTRATION)) {
+ throw new Error(
+ "You do not have permission to manage registration settings.",
+ );
+ }
+
await redisSet("config:registration:registrationEnabled", enabled);
revalidatePath("/admin/toggles/registration");
return { success: true, statusSet: enabled };
@@ -24,6 +32,12 @@ export const toggleRegistrationEnabled = adminAction
export const toggleRegistrationMessageEnabled = adminAction
.schema(defaultRegistrationToggleSchema)
.action(async ({ parsedInput: { enabled }, ctx: { user, userId } }) => {
+ if (!userHasPermission(user, PermissionType.MANAGE_REGISTRATION)) {
+ throw new Error(
+ "You do not have permission to manage registration settings.",
+ );
+ }
+
await redisSet(
"config:registration:registrationMessageEnabled",
enabled,
@@ -35,6 +49,12 @@ export const toggleRegistrationMessageEnabled = adminAction
export const toggleRSVPs = adminAction
.schema(defaultRegistrationToggleSchema)
.action(async ({ parsedInput: { enabled }, ctx: { user, userId } }) => {
+ if (!userHasPermission(user, PermissionType.MANAGE_REGISTRATION)) {
+ throw new Error(
+ "You do not have permission to manage registration settings.",
+ );
+ }
+
await redisSet("config:registration:allowRSVPs", enabled);
revalidatePath("/admin/toggles/registration");
return { success: true, statusSet: enabled };
@@ -43,6 +63,12 @@ export const toggleRSVPs = adminAction
export const setRSVPLimit = adminAction
.schema(defaultRSVPLimitSchema)
.action(async ({ parsedInput: { rsvpLimit }, ctx: { user, userId } }) => {
+ if (!userHasPermission(user, PermissionType.MANAGE_REGISTRATION)) {
+ throw new Error(
+ "You do not have permission to manage registration settings.",
+ );
+ }
+
await redisSet("config:registration:maxRSVPs", rsvpLimit);
revalidatePath("/admin/toggles/registration");
return { success: true, statusSet: rsvpLimit };
diff --git a/apps/web/src/actions/admin/role-actions.ts b/apps/web/src/actions/admin/role-actions.ts
new file mode 100644
index 00000000..c26da902
--- /dev/null
+++ b/apps/web/src/actions/admin/role-actions.ts
@@ -0,0 +1,139 @@
+"use server";
+
+import { z } from "zod";
+import { adminAction } from "@/lib/safe-action";
+import { db } from "db";
+import { roles, userCommonData } from "db/schema";
+import { eq } from "db/drizzle";
+import { revalidatePath } from "next/cache";
+import { PermissionType } from "@/lib/constants/permission";
+import {
+ userHasPermission,
+ compareUserPosition,
+} from "@/lib/utils/server/admin";
+
+const createRoleSchema = z.object({
+ name: z.string().min(1).max(50),
+ position: z.number().int().nonnegative(),
+ permissions: z.number().nonnegative(),
+ color: z.string().optional(),
+});
+
+const editRoleSchema = z.object({
+ roleId: z.number().int().positive(),
+ name: z.string().min(1).max(50).optional(),
+ position: z.number().int().nonnegative().optional(),
+ permissions: z.number().optional(),
+ color: z.string().optional(),
+});
+
+const deleteRoleSchema = z.object({
+ roleId: z.number().int().positive(),
+});
+
+export const createRole = adminAction
+ .schema(createRoleSchema)
+ .action(
+ async ({
+ parsedInput: { name, position, permissions, color },
+ ctx: { user },
+ }) => {
+ if (!userHasPermission(user, PermissionType.CREATE_ROLES)) {
+ if (!compareUserPosition(user, position)) {
+ /* This prevents creation of roles higher-or-equal to the current user's position */
+
+ throw new Error(
+ "You do not have permission to create a role at this position.",
+ );
+ }
+ }
+
+ const existing = await db.query.roles.findFirst({
+ where: eq(roles.name, name),
+ });
+ if (existing)
+ throw new Error("Role with that name already exists.");
+
+ await db
+ .insert(roles)
+ .values({ name, position, permissions, color });
+ revalidatePath("/admin/roles");
+ return { success: true };
+ },
+ );
+
+export const editRole = adminAction
+ .schema(editRoleSchema)
+ .action(
+ async ({
+ parsedInput: { roleId, name, position, permissions, color },
+ ctx: { user },
+ }) => {
+ const role = await db.query.roles.findFirst({
+ where: eq(roles.id, roleId),
+ });
+ console.log(user);
+ if (!role) throw new Error("Role not found");
+
+ if (
+ !userHasPermission(user, PermissionType.EDIT_ROLES) ||
+ !compareUserPosition(user, role.position)
+ ) {
+ throw new Error(
+ "You do not have permission to edit this role.",
+ );
+ }
+ if (
+ position !== undefined &&
+ !compareUserPosition(user, position)
+ ) {
+ throw new Error(
+ "You do not have permission to move a role to that position.",
+ );
+ }
+
+ await db
+ .update(roles)
+ .set({
+ name: name ?? role.name,
+ position: position ?? role.position,
+ permissions: permissions ?? role.permissions,
+ color: color ?? role.color,
+ })
+ .where(eq(roles.id, roleId));
+
+ revalidatePath("/admin/roles");
+ return { success: true };
+ },
+ );
+
+export const deleteRole = adminAction
+ .schema(deleteRoleSchema)
+ .action(async ({ parsedInput: { roleId }, ctx: { user } }) => {
+ const role = await db.query.roles.findFirst({
+ where: eq(roles.id, roleId),
+ });
+ if (!role) throw new Error("Role not found");
+
+ const userCount = await db.query.userCommonData.findMany({
+ where: eq(userCommonData.role_id, roleId),
+ });
+
+ if (userCount.length > 0) {
+ throw new Error("Cannot delete a role that is assigned to users.");
+ }
+
+ if (!userHasPermission(user, PermissionType.DELETE_ROLES)) {
+ if (!compareUserPosition(user, role.position)) {
+ /* This prevents deletion of roles higher-or-equal to the current user's position */
+
+ throw new Error(
+ "You do not have permission to delete this role.",
+ );
+ }
+ }
+
+ await db.delete(roles).where(eq(roles.id, roleId));
+ revalidatePath("/admin/roles");
+ return { success: true };
+ });
diff --git a/apps/web/src/actions/admin/scanner-admin-actions.ts b/apps/web/src/actions/admin/scanner-admin-actions.ts
index 3447db0d..2267e479 100644
--- a/apps/web/src/actions/admin/scanner-admin-actions.ts
+++ b/apps/web/src/actions/admin/scanner-admin-actions.ts
@@ -5,6 +5,8 @@ import { z } from "zod";
import { db, sql } from "db";
import { scans, userCommonData } from "db/schema";
import { eq, and } from "db/drizzle";
+import { userHasPermission } from "@/lib/utils/server/admin";
+import { PermissionType } from "@/lib/constants/permission";
export const createScan = volunteerAction
.schema(
@@ -56,6 +58,10 @@ export const getScan = volunteerAction
parsedInput: { eventID, userID },
ctx: { user, userId: adminUserID },
}) => {
+ if (!userHasPermission(user, PermissionType.CHECK_IN)) {
+ throw new Error("You do not have permission to view scans.");
+ }
+
const scan = await db.query.scans.findFirst({
where: and(
eq(scans.eventID, eventID),
@@ -79,7 +85,11 @@ const checkInUserSchema = z.object({
export const checkInUserToHackathon = volunteerAction
.schema(checkInUserSchema)
- .action(async ({ parsedInput: { userID } }) => {
+ .action(async ({ parsedInput: { userID }, ctx: { user } }) => {
+ if (!userHasPermission(user, PermissionType.CHECK_IN)) {
+ throw new Error("You do not have permission to check in users.");
+ }
+
// Set checkinTimestamp
await db
.update(userCommonData)
diff --git a/apps/web/src/actions/admin/user-actions.ts b/apps/web/src/actions/admin/user-actions.ts
index 919a9320..a1f48cdb 100644
--- a/apps/web/src/actions/admin/user-actions.ts
+++ b/apps/web/src/actions/admin/user-actions.ts
@@ -3,37 +3,55 @@
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, roles } from "db/schema";
import { db } from "db";
import { eq } from "db/drizzle";
import { revalidatePath } from "next/cache";
+import {
+ compareUserPosition,
+ userHasPermission,
+} from "@/lib/utils/server/admin";
+import { PermissionType } from "@/lib/constants/permission";
+import { getUser } from "db/functions";
export const updateRole = adminAction
.schema(
z.object({
userIDToUpdate: z.string(),
- roleToSet: z.enum(perms),
+ roleIdToSet: z.number().positive().int(),
}),
)
.action(
async ({
- parsedInput: { userIDToUpdate, roleToSet },
+ parsedInput: { userIDToUpdate, roleIdToSet },
ctx: { user, userId },
}) => {
- if (
- user.role !== "super_admin" &&
- (roleToSet === "super_admin" ||
- roleToSet === "admin" ||
- roleToSet === "volunteer")
- ) {
- returnValidationErrors(z.null(), {
- _errors: ["You are not allowed to do this!"],
- });
+ const userToUpdate = await getUser(userIDToUpdate);
+ const roleToSet = await db.query.roles.findFirst({
+ where: eq(roles.id, roleIdToSet),
+ });
+
+ if (!roleToSet) {
+ throw new Error("Role does not exist");
+ }
+
+ if (!userToUpdate) {
+ throw new Error("User to update not found.");
+ }
+
+ if (!userHasPermission(user, PermissionType.CHANGE_USER_ROLES)) {
+ if (
+ !compareUserPosition(user, userToUpdate.role.position) ||
+ !compareUserPosition(user, roleToSet.position)
+ ) {
+ throw new Error(
+ "You do not have permission to set this role.",
+ );
+ }
}
await db
.update(userCommonData)
- .set({ role: roleToSet })
+ .set({ role_id: roleIdToSet })
.where(eq(userCommonData.clerkID, userIDToUpdate));
revalidatePath(`/admin/users/${userIDToUpdate}`);
return { success: true };
@@ -60,3 +78,57 @@ 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 },
+ }) => {
+ const userToBan = await getUser(userIDToUpdate);
+
+ if (
+ !userHasPermission(user, PermissionType.BAN_USERS) ||
+ !compareUserPosition(user, userToBan!.role.position)
+ ) {
+ throw new Error("You do not have permission to ban users.");
+ }
+
+ 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 } }) => {
+ const userToBan = await getUser(userIDToUpdate);
+
+ if (
+ !userHasPermission(user, PermissionType.BAN_USERS) ||
+ !compareUserPosition(user, userToBan!.role.position)
+ ) {
+ throw new Error("You do not have permission to ban users.");
+ }
+
+ await db
+ .delete(bannedUsers)
+ .where(eq(bannedUsers.userID, userIDToUpdate));
+ revalidatePath(`/admin/users/${userIDToUpdate}`);
+ return { success: true };
+ });
diff --git a/apps/web/src/actions/registration.ts b/apps/web/src/actions/registration.ts
index d3928fc0..7e27f0f0 100644
--- a/apps/web/src/actions/registration.ts
+++ b/apps/web/src/actions/registration.ts
@@ -7,7 +7,7 @@ import { returnValidationErrors } from "next-safe-action";
import { hackerRegistrationFormValidator } from "@/validators/shared/registration";
import { userCommonData, userHackerData } from "db/schema";
import { currentUser } from "@clerk/nextjs/server";
-import c from "config";
+import c, { defaultRoleId } from "config";
import { DatabaseError } from "db/types";
import {
UNIQUE_KEY_CONSTRAINT_VIOLATION_CODE,
@@ -60,6 +60,7 @@ export const registerHacker = authenticatedAction
skills: userData.skills.map((v) => v.text.toLowerCase()),
isFullyRegistered: true,
dietRestrictions: userData.dietRestrictions,
+ role_id: defaultRoleId,
});
await tx.insert(userHackerData).values({
diff --git a/apps/web/src/app/admin/check-in/page.tsx b/apps/web/src/app/admin/check-in/page.tsx
index af117b82..2d35a8ed 100644
--- a/apps/web/src/app/admin/check-in/page.tsx
+++ b/apps/web/src/app/admin/check-in/page.tsx
@@ -1,11 +1,20 @@
import CheckinScanner from "@/components/admin/scanner/CheckinScanner";
import { getUser } from "db/functions";
+import { userHasPermission } from "@/lib/utils/server/admin";
+import { PermissionType } from "@/lib/constants/permission";
+import { notFound } from "next/navigation";
+import { getCurrentUser } from "@/lib/utils/server/user";
export default async function Page({
searchParams,
}: {
searchParams: { [key: string]: string | undefined };
}) {
+ const user = await getCurrentUser();
+ if (!userHasPermission(user, PermissionType.CHECK_IN)) {
+ return notFound();
+ }
+
if (!searchParams.user)
return (
@@ -19,7 +28,6 @@ export default async function Page({
);
const scanUser = await getUser(searchParams.user);
- console.log(scanUser);
if (!scanUser) {
return (
diff --git a/apps/web/src/app/admin/events/edit/[slug]/page.tsx b/apps/web/src/app/admin/events/edit/[slug]/page.tsx
index f49cbaaf..aa2c4346 100644
--- a/apps/web/src/app/admin/events/edit/[slug]/page.tsx
+++ b/apps/web/src/app/admin/events/edit/[slug]/page.tsx
@@ -1,12 +1,20 @@
import { getEventById } from "db/functions";
import { notFound } from "next/navigation";
import EditEventForm from "@/components/events/admin/EditEventForm";
+import { userHasPermission } from "@/lib/utils/server/admin";
+import { PermissionType } from "@/lib/constants/permission";
+import { getCurrentUser } from "@/lib/utils/server/user";
export default async function EditEventPage({
params,
}: {
params: { slug: string };
}) {
+ const user = await getCurrentUser();
+ if (!userHasPermission(user, PermissionType.EDIT_EVENTS)) {
+ return notFound();
+ }
+
const eventId = parseInt(params.slug);
if (!eventId) {
diff --git a/apps/web/src/app/admin/events/new/page.tsx b/apps/web/src/app/admin/events/new/page.tsx
index 0050480d..95ec6faa 100644
--- a/apps/web/src/app/admin/events/new/page.tsx
+++ b/apps/web/src/app/admin/events/new/page.tsx
@@ -1,6 +1,14 @@
import NewEventForm from "@/components/events/admin/NewEventForm";
+import { PermissionType } from "@/lib/constants/permission";
+import { userHasPermission } from "@/lib/utils/server/admin";
+import { getCurrentUser } from "@/lib/utils/server/user";
+import { notFound } from "next/navigation";
-export default function Page() {
+export default async function Page() {
+ const user = await getCurrentUser();
+ if (!userHasPermission(user, PermissionType.CREATE_EVENTS)) {
+ return notFound();
+ }
const defaultDate = new Date();
return (
diff --git a/apps/web/src/app/admin/events/page.tsx b/apps/web/src/app/admin/events/page.tsx
index d43e3b4d..1120e5cc 100644
--- a/apps/web/src/app/admin/events/page.tsx
+++ b/apps/web/src/app/admin/events/page.tsx
@@ -5,29 +5,25 @@ import { columns } from "@/components/events/shared/EventColumns";
import { Button } from "@/components/shadcn/ui/button";
import { PlusCircle } from "lucide-react";
import Link from "next/link";
-import { getAllEvents, getUser } from "db/functions";
-import { auth } from "@clerk/nextjs/server";
+import { getAllEvents } from "db/functions";
import FullScreenMessage from "@/components/shared/FullScreenMessage";
-import { isUserAdmin } from "@/lib/utils/server/admin";
+import { userHasPermission } from "@/lib/utils/server/admin";
+import { PermissionType } from "@/lib/constants/permission";
+import { notFound } from "next/navigation";
+import { getCurrentUser } from "@/lib/utils/server/user";
export default async function Page() {
- const { userId, redirectToSignIn } = await auth();
- if (!userId) {
- return redirectToSignIn();
- }
+ const user = await getCurrentUser();
- const userData = await getUser(userId);
- if (!userData) {
- return (
-
- );
+ if (!userHasPermission(user, PermissionType.VIEW_EVENTS)) {
+ return notFound();
}
const events = await getAllEvents();
- const isUserAuthorized = isUserAdmin(userData);
+ const isUserAuthorized = userHasPermission(
+ user,
+ PermissionType.CREATE_EVENTS,
+ );
return (
diff --git a/apps/web/src/app/admin/layout.tsx b/apps/web/src/app/admin/layout.tsx
index 47cd9a1f..a9f02eda 100644
--- a/apps/web/src/app/admin/layout.tsx
+++ b/apps/web/src/app/admin/layout.tsx
@@ -1,35 +1,24 @@
import c from "config";
import Image from "next/image";
-import { auth } from "@clerk/nextjs/server";
import Link from "next/link";
import { Button } from "@/components/shadcn/ui/button";
import DashNavItem from "@/components/dash/shared/DashNavItem";
import FullScreenMessage from "@/components/shared/FullScreenMessage";
import ProfileButton from "@/components/shared/ProfileButton";
-import { Suspense } from "react";
+import React, { Suspense } from "react";
import ClientToast from "@/components/shared/ClientToast";
-import { redirect } from "next/navigation";
-import { getUser } from "db/functions";
+import { isUserAdmin, userHasPermission } from "../../lib/utils/server/admin";
+import { PermissionType } from "@/lib/constants/permission";
+import { getCurrentUser } from "@/lib/utils/server/user";
interface AdminLayoutProps {
children: React.ReactNode;
}
export default async function AdminLayout({ children }: AdminLayoutProps) {
- const { userId } = await auth();
+ const user = await getCurrentUser();
- if (!userId) {
- return redirect("/sign-in");
- }
-
- const user = await getUser(userId);
-
- if (
- !user ||
- (user.role !== "admin" &&
- user.role !== "super_admin" &&
- user.role !== "volunteer")
- ) {
+ if (!isUserAdmin(user)) {
console.log("Denying admin access to user", user);
return (
-
+
@@ -85,12 +74,31 @@ export default async function AdminLayout({ children }: AdminLayoutProps) {
- {Object.entries(c.dashPaths.admin).map(([name, path]) =>
- ["Users", "Toggles"].includes(name) &&
- user.role === "volunteer" ? null : (
-
- ),
- )}
+ {Object.entries(c.dashPaths.admin).map(([name, path]) => {
+ // Gate specific admin nav items by permission
+ if (
+ name === "Users" &&
+ !userHasPermission(user, PermissionType.VIEW_USERS)
+ )
+ return null;
+ if (
+ name === "Events" &&
+ !userHasPermission(user, PermissionType.VIEW_EVENTS)
+ )
+ return null;
+ if (
+ name === "Roles" &&
+ !userHasPermission(user, PermissionType.VIEW_ROLES)
+ )
+ return null;
+ if (
+ name === "Toggles" &&
+ !userHasPermission(user, PermissionType.MANAGE_NAVLINKS)
+ )
+ return null;
+ // Keep other configured admin paths visible by default
+ return ;
+ })}
Loading...}>{children}
>
diff --git a/apps/web/src/app/admin/page.tsx b/apps/web/src/app/admin/page.tsx
index 39fb7b45..46952290 100644
--- a/apps/web/src/app/admin/page.tsx
+++ b/apps/web/src/app/admin/page.tsx
@@ -8,27 +8,16 @@ import {
} from "@/components/shadcn/ui/card";
import { Users, UserCheck, User2, TimerReset, MailCheck } from "lucide-react";
import type { User } from "db/types";
-import { auth } from "@clerk/nextjs/server";
import { notFound } from "next/navigation";
-import { getAllUsers, getUser } from "db/functions";
+import { getAllUsers } from "db/functions";
import Link from "next/link";
import { getRequestContext } from "@cloudflare/next-on-pages";
import { formatInTimeZone } from "date-fns-tz";
import { getClientTimeZone } from "@/lib/utils/client/shared";
+import { getCurrentUser } from "@/lib/utils/server/user";
export default async function Page() {
- const { userId } = await auth();
- if (!userId) return notFound();
-
- const adminUser = await getUser(userId);
- if (
- !adminUser ||
- (adminUser.role !== "admin" &&
- adminUser.role !== "super_admin" &&
- adminUser.role !== "volunteer")
- ) {
- return notFound();
- }
+ const adminUser = await getCurrentUser();
const allUsers = (await getAllUsers()) ?? [];
diff --git a/apps/web/src/app/admin/roles/page.tsx b/apps/web/src/app/admin/roles/page.tsx
new file mode 100644
index 00000000..f5bcc550
--- /dev/null
+++ b/apps/web/src/app/admin/roles/page.tsx
@@ -0,0 +1,29 @@
+import { db } from "db";
+import RolesManager from "@/components/admin/roles/RolesManager";
+import { notFound } from "next/navigation";
+import { userHasPermission } from "@/lib/utils/server/admin";
+import { PermissionType } from "@/lib/constants/permission";
+import { getCurrentUser } from "@/lib/utils/server/user";
+import { Suspense } from "react";
+
+export default async function Page() {
+ // server-side fetch roles and current user
+ const resRoles = await db.query.roles.findMany();
+ const user = await getCurrentUser();
+
+ if (!userHasPermission(user, PermissionType.VIEW_ROLES)) return notFound();
+
+ // pass serializable data to client
+ return (
+
+
Roles
+ Loading roles...
+ }
+ >
+
+
+
+ );
+}
diff --git a/apps/web/src/app/admin/scanner/[id]/page.tsx b/apps/web/src/app/admin/scanner/[id]/page.tsx
index 473be983..10378306 100644
--- a/apps/web/src/app/admin/scanner/[id]/page.tsx
+++ b/apps/web/src/app/admin/scanner/[id]/page.tsx
@@ -3,7 +3,11 @@ import FullScreenMessage from "@/components/shared/FullScreenMessage";
import { db } from "db";
import { eq, and } from "db/drizzle";
import { getHacker } from "db/functions";
-import { events, userCommonData, scans } from "db/schema";
+import { events, scans } from "db/schema";
+import { userHasPermission } from "@/lib/utils/server/admin";
+import { PermissionType } from "@/lib/constants/permission";
+import { notFound } from "next/dist/client/components/navigation";
+import { getCurrentUser } from "@/lib/utils/server/user";
export default async function Page({
params,
@@ -14,6 +18,12 @@ export default async function Page({
}) {
// TODO: maybe move event existant check into a layout so it holds state?
+ const user = await getCurrentUser();
+
+ if (!userHasPermission(user, PermissionType.CREATE_SCANS)) {
+ return notFound();
+ }
+
if (!params || !params.id || isNaN(parseInt(params.id))) {
return (
-
+
+
+
{children}
diff --git a/apps/web/src/app/admin/toggles/registration/page.tsx b/apps/web/src/app/admin/toggles/registration/page.tsx
index 626b97d7..0da223e0 100644
--- a/apps/web/src/app/admin/toggles/registration/page.tsx
+++ b/apps/web/src/app/admin/toggles/registration/page.tsx
@@ -1,7 +1,11 @@
import { RegistrationToggles } from "@/components/admin/toggles/RegistrationSettings";
+import { PermissionType } from "@/lib/constants/permission";
+import { userHasPermission } from "@/lib/utils/server/admin";
import { redisMGet } from "@/lib/utils/server/redis";
import { parseRedisBoolean, parseRedisNumber } from "@/lib/utils/server/redis";
+import { getCurrentUser } from "@/lib/utils/server/user";
import c from "config";
+import { notFound } from "next/dist/client/components/navigation";
export default async function Page() {
const [defaultRegistrationEnabled, defaultRSVPsEnabled, defaultRSVPLimit]: (
@@ -13,6 +17,11 @@ export default async function Page() {
"config:registration:maxRSVPs",
);
+ const user = await getCurrentUser();
+
+ if (!userHasPermission(user, PermissionType.MANAGE_REGISTRATION))
+ return notFound();
+
return (
diff --git a/apps/web/src/app/admin/users/[slug]/page.tsx b/apps/web/src/app/admin/users/[slug]/page.tsx
index f4333182..de613c26 100644
--- a/apps/web/src/app/admin/users/[slug]/page.tsx
+++ b/apps/web/src/app/admin/users/[slug]/page.tsx
@@ -10,35 +10,50 @@ import {
ProfileInfo,
} from "@/components/admin/users/ServerSections";
import {
- DropdownMenu,
- DropdownMenuTrigger,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuSeparator
+ DropdownMenu,
+ DropdownMenuTrigger,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
} from "@/components/shadcn/ui/dropdown-menu";
import { auth } from "@clerk/nextjs/server";
import { notFound } from "next/navigation";
-import { isUserAdmin } from "@/lib/utils/server/admin";
+import { userHasPermission } 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";
+import { PermissionType } from "@/lib/constants/permission";
+import Restricted from "@/components/Restricted";
+import { getCurrentUser } from "@/lib/utils/server/user";
export default async function Page({ params }: { params: { slug: string } }) {
- const { userId } = await auth();
+ const admin = await getCurrentUser();
+ if (!userHasPermission(admin, PermissionType.VIEW_USERS)) return notFound();
- if (!userId) return notFound();
+ const subject = await getHacker(params.slug);
- const admin = await getUser(userId);
- if (!admin || !isUserAdmin(admin)) return notFound();
-
- const user = await getHacker(params.slug);
-
- if (!user) {
+ if (!subject) {
return
User Not Found
;
}
+ const banInstance = await db.query.bannedUsers.findFirst({
+ where: eq(bannedUsers.userID, subject.clerkID),
+ });
+
return (
+ {!!banInstance && (
+
+
+ This user has been suspended, reason for suspension:{" "}
+
+ {banInstance.reason}
+
+ )}
@@ -49,65 +64,100 @@ export default async function Page({ params }: { params: { slug: string } }) {
{/*
{users.length} Total Users
*/}
-
-
+
+
-
+
-
- {(c.featureFlags.core.requireUsersApproval as boolean) && (
-
- )}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
-
+
+
+
+ {!!banInstance ? (
+
+ ) : (
+
+ )}
+
{(c.featureFlags.core.requireUsersApproval as boolean) && (
)}
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {(c.featureFlags.core
+ .requireUsersApproval as boolean) && (
+
+ )}
+
+
@@ -116,27 +166,27 @@ export default async function Page({ params }: { params: { slug: string } }) {
- {user.firstName} {user.lastName}
+ {subject.firstName} {subject.lastName}
- @{user.hackerTag}
+ @{subject.hackerTag}
{/*
{team.bio}
*/}
Joined{" "}
- {user.signupTime
+ {subject.signupTime
.toDateString()
.split(" ")
.slice(1)
.join(" ")}
- {user.isRSVPed && (
+ {subject.isRSVPed && (
RSVP
@@ -145,9 +195,9 @@ export default async function Page({ params }: { params: { slug: string } }) {
diff --git a/apps/web/src/app/admin/users/page.tsx b/apps/web/src/app/admin/users/page.tsx
index 6e762102..fedcb66d 100644
--- a/apps/web/src/app/admin/users/page.tsx
+++ b/apps/web/src/app/admin/users/page.tsx
@@ -4,20 +4,15 @@ import { columns } from "@/components/admin/users/UserColumns";
import { Button } from "@/components/shadcn/ui/button";
import { FolderInput } from "lucide-react";
import { getAllUsers } from "db/functions";
-import { userCommonData } from "db/schema";
-import { getUser } from "db/functions";
-import { auth } from "@clerk/nextjs/server";
import { notFound } from "next/navigation";
-import { isUserAdmin } from "@/lib/utils/server/admin";
+import { userHasPermission } from "@/lib/utils/server/admin";
+import { PermissionType } from "@/lib/constants/permission";
+import { getCurrentUser } from "@/lib/utils/server/user";
// This begs a question where we might want to have an option later on to sort by the role as we might want different things
export default async function Page() {
- const { userId } = await auth();
-
- if (!userId) return notFound();
-
- const admin = await getUser(userId);
- if (!admin || !isUserAdmin(admin)) return notFound();
+ const user = await getCurrentUser();
+ if (!userHasPermission(user, PermissionType.VIEW_USERS)) return notFound();
const userData = await getAllUsers();
diff --git a/apps/web/src/app/api/admin/events/create/route.ts b/apps/web/src/app/api/admin/events/create/route.ts
deleted file mode 100644
index 6606ef35..00000000
--- a/apps/web/src/app/api/admin/events/create/route.ts
+++ /dev/null
@@ -1,56 +0,0 @@
-import { auth } from "@clerk/nextjs/server";
-import { db } from "db";
-import { events } from "db/schema";
-import { newEventFormSchema } from "@/validators/event";
-import { BasicRedirValidator } from "@/validators/shared/basicRedir";
-import { NextResponse } from "next/server";
-import { z } from "zod";
-import superjson from "superjson";
-import c from "config";
-import { getUser } from "db/functions";
-
-// Make this a server action
-export async function POST(req: Request) {
- const { userId } = await auth();
-
- if (!userId) return new Response("Unauthorized", { status: 401 });
-
- const reqUserRecord = await getUser(userId);
- if (
- !reqUserRecord ||
- (reqUserRecord.role !== "super_admin" && reqUserRecord.role !== "admin")
- ) {
- return new Response("Unauthorized", { status: 401 });
- }
-
- const body = superjson.parse(await req.text());
- const parsedBody = newEventFormSchema.safeParse(body);
-
- if (!parsedBody.success) {
- return new Response("Malformed request body.", { status: 400 });
- }
-
- const res = await db
- .insert(events)
- .values({
- title: parsedBody.data.title,
- description: parsedBody.data.description,
- startTime: parsedBody.data.startTime,
- endTime: parsedBody.data.endTime,
- location: parsedBody.data.location,
- type: parsedBody.data.type,
- host:
- parsedBody.data.host && parsedBody.data.host.length > 0
- ? parsedBody.data.host
- : `${c.hackathonName}`,
- })
- .returning();
-
- return NextResponse.json
>({
- success: true,
- message: "Event created successfully.",
- redirect: `/schedule/${res[0].id}`,
- });
-}
-
-export const runtime = "edge";
diff --git a/apps/web/src/app/api/admin/export/route.ts b/apps/web/src/app/api/admin/export/route.ts
index d44f4508..ffeebcf0 100644
--- a/apps/web/src/app/api/admin/export/route.ts
+++ b/apps/web/src/app/api/admin/export/route.ts
@@ -1,3 +1,5 @@
+import { PermissionType } from "@/lib/constants/permission";
+import { isUserAdmin, userHasPermission } from "@/lib/utils/server/admin";
import { auth } from "@clerk/nextjs/server";
import { getAllHackers, getUser } from "db/functions";
@@ -36,9 +38,13 @@ export async function GET() {
if (!userId) return new Response("Unauthorized", { status: 401 });
const reqUserRecord = await getUser(userId);
+ if (!reqUserRecord) {
+ return new Response("Unauthorized", { status: 401 });
+ }
+
if (
- !reqUserRecord ||
- (reqUserRecord.role !== "super_admin" && reqUserRecord.role !== "admin")
+ !isUserAdmin(reqUserRecord) ||
+ !userHasPermission(reqUserRecord, PermissionType.VIEW_USERS)
) {
return new Response("Unauthorized", { status: 401 });
}
diff --git a/apps/web/src/app/dash/layout.tsx b/apps/web/src/app/dash/layout.tsx
index 0c118199..5e85035c 100644
--- a/apps/web/src/app/dash/layout.tsx
+++ b/apps/web/src/app/dash/layout.tsx
@@ -28,8 +28,7 @@ export default async function DashLayout({ children }: DashLayoutProps) {
if (
(c.featureFlags.core.requireUsersApproval as boolean) === true &&
- user.isApproved === false &&
- user.role === "hacker"
+ user.isApproved === false
) {
return redirect("/i/approval");
}
diff --git a/apps/web/src/app/dash/page.tsx b/apps/web/src/app/dash/page.tsx
index 1438131e..0706c249 100644
--- a/apps/web/src/app/dash/page.tsx
+++ b/apps/web/src/app/dash/page.tsx
@@ -11,15 +11,13 @@ import {
QuickQR,
} from "@/components/dash/overview/ServerBubbles";
import { getUser } from "db/functions";
+import { getCurrentUser } from "@/lib/utils/server/user";
export default async function Page() {
- const { userId } = await auth();
- if (!userId) return null;
- const user = await getUser(userId);
- if (!user) return null;
+ const user = await getCurrentUser();
const qrPayload = createQRpayload({
- userID: userId,
+ userID: user.clerkID,
createdAt: new Date(),
});
diff --git a/apps/web/src/app/dash/pass/page.tsx b/apps/web/src/app/dash/pass/page.tsx
index 3746e840..baf48a46 100644
--- a/apps/web/src/app/dash/pass/page.tsx
+++ b/apps/web/src/app/dash/pass/page.tsx
@@ -101,7 +101,7 @@ function EventPass({ qrPayload, user, clerk, guild }: EventPassProps) {
c.startDate,
"h:mma, MMM d, yyyy",
)}`}
-
+
{c.prettyLocation}
diff --git a/apps/web/src/app/discord-verify/page.tsx b/apps/web/src/app/discord-verify/page.tsx
index ef55b7e7..10c00a00 100644
--- a/apps/web/src/app/discord-verify/page.tsx
+++ b/apps/web/src/app/discord-verify/page.tsx
@@ -44,8 +44,7 @@ export default async function Page({
if (
(c.featureFlags.core.requireUsersApproval as boolean) === true &&
- user.isApproved === false &&
- user.role === "hacker"
+ user.isApproved === false
) {
return redirect("/i/approval");
}
diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css
index a35dbcef..8494cab5 100644
--- a/apps/web/src/app/globals.css
+++ b/apps/web/src/app/globals.css
@@ -139,7 +139,6 @@
}
@keyframes pulseDot {
-
0%,
100% {
transform: scale(1);
@@ -148,4 +147,4 @@
50% {
transform: scale(1.1);
}
-}
\ No newline at end of file
+}
diff --git a/apps/web/src/app/rsvp/page.tsx b/apps/web/src/app/rsvp/page.tsx
index 162b77ec..1c531a35 100644
--- a/apps/web/src/app/rsvp/page.tsx
+++ b/apps/web/src/app/rsvp/page.tsx
@@ -37,8 +37,7 @@ export default async function RsvpPage({
if (
(c.featureFlags.core.requireUsersApproval as boolean) === true &&
- user.isApproved === false &&
- user.role === "hacker"
+ user.isApproved === false
) {
return redirect("/i/approval");
}
diff --git a/apps/web/src/app/suspended/page.tsx b/apps/web/src/app/suspended/page.tsx
new file mode 100644
index 00000000..ee9e6110
--- /dev/null
+++ b/apps/web/src/app/suspended/page.tsx
@@ -0,0 +1,40 @@
+import { getCurrentUser } from "@/lib/utils/server/user";
+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 user = await getCurrentUser();
+
+ const banInstance = await db.query.bannedUsers.findFirst({
+ where: eq(bannedUsers.userID, user.clerkID),
+ });
+ if (!banInstance) return null;
+
+ return (
+
+
+
+ Account Suspended
+
+
+ Dear {user.firstName} {user.lastName},
+
+
+ {" "}
+ Your account was suspended
+
+
+ Reason:
+ {banInstance.reason}
+
+
+
+
+ );
+}
+
+export const runtime = "edge";
diff --git a/apps/web/src/components/Restricted.tsx b/apps/web/src/components/Restricted.tsx
new file mode 100644
index 00000000..52b46e56
--- /dev/null
+++ b/apps/web/src/components/Restricted.tsx
@@ -0,0 +1,48 @@
+import {
+ compareUserPosition,
+ userHasPermission,
+} from "@/lib/utils/server/admin";
+import { PermissionType } from "@/lib/constants/permission";
+import { UserWithRole } from "db/types";
+import { ReactNode } from "react";
+
+function Restricted({
+ user,
+ permissions,
+ children,
+ position = "higher",
+ targetRolePosition = undefined,
+}: {
+ user: UserWithRole;
+ permissions: PermissionType | [PermissionType];
+ children: ReactNode;
+ position?: "higher" | "lower" | "equal";
+ targetRolePosition?: number;
+}) {
+ if (!userHasPermission(user, permissions)) {
+ return <>>;
+ }
+ if (targetRolePosition !== undefined) {
+ if (
+ position === "higher" &&
+ !compareUserPosition(user, targetRolePosition)
+ ) {
+ return <>>;
+ }
+ if (
+ position === "lower" &&
+ compareUserPosition(user, targetRolePosition) !== -1
+ ) {
+ return <>>;
+ }
+ if (
+ position === "equal" &&
+ compareUserPosition(user, targetRolePosition) !== 0
+ ) {
+ return <>>;
+ }
+ }
+ return <>{children}>;
+}
+
+export default Restricted;
diff --git a/apps/web/src/components/admin/roles/CreateRoleDialog.tsx b/apps/web/src/components/admin/roles/CreateRoleDialog.tsx
new file mode 100644
index 00000000..2a7cc8ea
--- /dev/null
+++ b/apps/web/src/components/admin/roles/CreateRoleDialog.tsx
@@ -0,0 +1,177 @@
+"use client";
+
+import { useState } from "react";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/shadcn/ui/dialog";
+import { Button } from "@/components/shadcn/ui/button";
+import { Input } from "@/components/shadcn/ui/input";
+import { Label } from "@/components/shadcn/ui/label";
+import { PermissionType } from "@/lib/constants/permission";
+import { PermissionMask } from "@/lib/utils/shared/permission";
+import { useAction } from "next-safe-action/hooks";
+import { createRole } from "@/actions/admin/role-actions";
+import { toast } from "sonner";
+import Restricted from "@/components/Restricted";
+import { UserWithRole } from "db/types";
+import { userHasPermission } from "@/lib/utils/server/admin";
+
+export default function CreateRoleDialog({
+ currentUser,
+ nextPosition,
+}: {
+ currentUser: UserWithRole;
+ nextPosition: number;
+}) {
+ const [open, setOpen] = useState(false);
+ const [name, setName] = useState("");
+ const [permissionsMask, setPermissionsMask] = useState(0);
+ const [color, setColor] = useState("#000000");
+ const { execute: doCreate } = useAction(createRole, {
+ onError: ({ error }) => {
+ toast.error(
+ "Failed to create role: " +
+ (error.serverError || "Unknown error"),
+ );
+ },
+ onSuccess: () => {
+ toast.success("Role created");
+ setOpen(false);
+ setName("");
+ setPermissionsMask(0);
+ setColor("#000000");
+ },
+ });
+
+ const permissionEntries = Object.keys(PermissionType)
+ .filter((k) => isNaN(Number(k)))
+ .map((k) => ({
+ name: k.replaceAll("_", " "),
+ value: (PermissionType as any)[k] as number,
+ }));
+
+ const hasPermLocal = (perm: PermissionType) => {
+ const mask = new PermissionMask(permissionsMask);
+ return mask.has(perm);
+ };
+
+ // Check if user can assign this permission (they must have it themselves)
+ const canAssignPermission = (perm: PermissionType) => {
+ return userHasPermission(currentUser, perm);
+ };
+
+ const togglePerm = (perm: PermissionType) => {
+ if (!canAssignPermission(perm)) return;
+ setPermissionsMask(new PermissionMask(permissionsMask).toggle(perm));
+ };
+
+ const handleCreate = () => {
+ if (!name.trim()) {
+ toast.error("Role name is required");
+ return;
+ }
+ doCreate({
+ name: name.trim(),
+ position: nextPosition,
+ permissions: permissionsMask,
+ color: color || undefined,
+ });
+ };
+ return (
+
+
+
+ );
+}
diff --git a/apps/web/src/components/admin/roles/DeleteRoleDialog.tsx b/apps/web/src/components/admin/roles/DeleteRoleDialog.tsx
new file mode 100644
index 00000000..0c5a1631
--- /dev/null
+++ b/apps/web/src/components/admin/roles/DeleteRoleDialog.tsx
@@ -0,0 +1,72 @@
+"use client";
+
+import { useState } from "react";
+import { Button } from "@/components/shadcn/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/shadcn/ui/dialog";
+import { useAction } from "next-safe-action/hooks";
+import { deleteRole } from "@/actions/admin/role-actions";
+import { toast } from "sonner";
+import { UserWithRole } from "db/types";
+import { InferSelectModel } from "drizzle-orm";
+import { roles } from "db/schema";
+
+type Role = InferSelectModel
;
+
+export default function DeleteRoleDialog({
+ role,
+ class_name,
+}: {
+ role: Role;
+ currentUser: UserWithRole;
+ class_name?: string;
+}) {
+ const [open, setOpen] = useState(false);
+ const { execute: doDelete } = useAction(deleteRole, {
+ onError: ({ error }) => {
+ toast.error(
+ "Failed to delete role: " +
+ (error.serverError || "Unknown error"),
+ );
+ },
+ onSuccess: () => {
+ toast.success("Role deleted successfully");
+ setOpen(false);
+ },
+ });
+
+ return (
+
+ );
+}
diff --git a/apps/web/src/components/admin/roles/RoleCard.tsx b/apps/web/src/components/admin/roles/RoleCard.tsx
new file mode 100644
index 00000000..802e3d5d
--- /dev/null
+++ b/apps/web/src/components/admin/roles/RoleCard.tsx
@@ -0,0 +1,210 @@
+"use client";
+
+import { useState, useEffect } from "react";
+import { Button } from "@/components/shadcn/ui/button";
+import {
+ AccordionItem,
+ AccordionTrigger,
+ AccordionContent,
+} from "@/components/shadcn/ui/accordion";
+import Restricted from "@/components/Restricted";
+import { PermissionType } from "@/lib/constants/permission";
+import { PermissionMask } from "@/lib/utils/shared/permission";
+import { ChevronUp, ChevronDown } from "lucide-react";
+import RoleBadge from "@/components/dash/shared/RoleBadge";
+import { UserWithRole } from "db/types";
+import { InferSelectModel } from "drizzle-orm";
+import { roles } from "db/schema";
+import {
+ compareUserPosition,
+ userHasPermission,
+} from "@/lib/utils/server/admin";
+import DeleteRoleDialog from "@/components/admin/roles/DeleteRoleDialog";
+
+type Role = InferSelectModel;
+
+export default function RoleCard({
+ role,
+ currentUser,
+ onRoleChange,
+ onMove,
+ index,
+ total,
+}: {
+ role: Role;
+ currentUser: UserWithRole;
+ onRoleChange: (r: Role) => void;
+ onMove: (roleId: number, dir: "up" | "down") => void;
+ index: number;
+ total: number;
+}) {
+ const [permissionsMask, setPermissionsMask] = useState(
+ role.permissions ?? 0,
+ );
+ const [color, setColor] = useState(role.color ?? "");
+
+ useEffect(() => {
+ setPermissionsMask(role.permissions ?? 0);
+ setColor(role.color ?? "");
+ }, [role]);
+
+ const hasPermLocal = (perm: PermissionType) => {
+ const mask = new PermissionMask(permissionsMask);
+ return mask.has(perm);
+ };
+
+ // Whether current user may toggle this permission on target role (they must have EDIT_ROLES and higher position)
+ const canEditRole = (() => {
+ try {
+ return (
+ userHasPermission(currentUser, PermissionType.EDIT_ROLES) &&
+ compareUserPosition(currentUser, role.position)
+ );
+ } catch (e) {
+ return false;
+ }
+ })();
+
+ // user cannot toggle individual permission bits they don't personally have
+ function canTogglePermission(perm: PermissionType) {
+ return userHasPermission(currentUser, perm) && canEditRole;
+ }
+
+ function togglePerm(perm: PermissionType) {
+ if (!canTogglePermission(perm)) return;
+ const next = new PermissionMask(role.permissions).toggle(perm);
+ setPermissionsMask(next);
+ onRoleChange({ ...role, permissions: next, color });
+ }
+
+ function onColorChange(v: string) {
+ setColor(v);
+ onRoleChange({ ...role, permissions: permissionsMask, color: v });
+ }
+
+ // Build a list of PermissionType entries
+ const permissionEntries = Object.keys(PermissionType)
+ .filter((k) => isNaN(Number(k)))
+ .map((k) => ({
+ name: k.replaceAll("_", " "),
+ value: (PermissionType as any)[k] as number,
+ }));
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Permissions
+
+ {permissionEntries.map((p) => (
+
+
+ togglePerm(p.value)}
+ />
+ {p.name}
+
+
+ {hasPermLocal(p.value)
+ ? "Enabled"
+ : "Disabled"}
+
+
+ ))}
+
+
+
+
Appearance
+
+
+
+ {canEditRole ? (
+
+ onColorChange(e.target.value)
+ }
+ />
+ ) : (
+
+ {role.color || "—"}
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/admin/roles/RolesManager.tsx b/apps/web/src/components/admin/roles/RolesManager.tsx
new file mode 100644
index 00000000..bc258b10
--- /dev/null
+++ b/apps/web/src/components/admin/roles/RolesManager.tsx
@@ -0,0 +1,150 @@
+"use client";
+
+import { useState, useEffect } from "react";
+import RoleCard from "@/components/admin/roles/RoleCard";
+import { Accordion } from "@/components/shadcn/ui/accordion";
+import { Button } from "@/components/shadcn/ui/button";
+import { useAction } from "next-safe-action/hooks";
+import { editRole } from "@/actions/admin/role-actions";
+import { toast } from "sonner";
+import CreateRoleDialog from "@/components/admin/roles/CreateRoleDialog";
+import Restricted from "@/components/Restricted";
+import { PermissionType } from "@/lib/constants/permission";
+
+export default function RolesManager({
+ roles: initialRoles,
+ currentUser,
+}: {
+ roles: any[];
+ currentUser: any;
+}) {
+ const [roles, setRoles] = useState(() =>
+ [...initialRoles].sort((a, b) => a.position - b.position),
+ );
+ const [dirty, setDirty] = useState(false);
+ const { execute: doEdit } = useAction(editRole, {
+ onError: ({ error }) => {
+ toast.error(
+ "Failed to save role: " +
+ (error.serverError || "Unknown error"),
+ );
+ },
+ onSuccess: () => {
+ // Individual success not needed since we save all at once
+ },
+ });
+
+ useEffect(() => {
+ setRoles([...initialRoles].sort((a, b) => a.position - b.position));
+ }, [initialRoles]);
+
+ const nextPosition =
+ roles.length > 0 ? Math.max(...roles.map((r) => r.position)) + 1 : 1;
+
+ function onRoleChange(updated: any) {
+ setRoles((prev) => {
+ const next = prev.map((r) =>
+ r.id === updated.id ? { ...r, ...updated } : r,
+ );
+ setDirty(true);
+ return next;
+ });
+ }
+
+ function onMove(roleId: number, dir: "up" | "down") {
+ setRoles((prev) => {
+ const idx = prev.findIndex((r) => r.id === roleId);
+ if (idx === -1) return prev;
+ const next = [...prev];
+ const swapIdx = dir === "up" ? idx - 1 : idx + 1;
+ if (swapIdx < 0 || swapIdx >= next.length) return prev;
+ // swap positions
+ const a = next[idx];
+ const b = next[swapIdx];
+ const aPos = a.position;
+ next[idx] = { ...a, position: b.position };
+ next[swapIdx] = { ...b, position: aPos };
+ setDirty(true);
+ // resort after swap
+ return next.sort((a, b) => a.position - b.position);
+ });
+ }
+
+ const total = roles.length;
+
+ async function saveAll() {
+ // Compute which roles actually changed compared to the original `initialRoles`
+ const changedRoles = roles.filter((r) => {
+ const orig = initialRoles.find((o: any) => o.id === r.id);
+ if (!orig) return true;
+ const origPerm = orig.permissions ?? 0;
+ const rPerm = r.permissions ?? 0;
+ const origColor = orig.color ?? null;
+ const rColor = r.color ?? null;
+ return (
+ orig.name !== r.name ||
+ orig.position !== r.position ||
+ origPerm !== rPerm ||
+ origColor !== rColor
+ );
+ });
+
+ if (changedRoles.length === 0) {
+ toast.success("No changes to save");
+ setDirty(false);
+ return;
+ }
+
+ const promises = changedRoles.map((r) =>
+ doEdit({
+ roleId: r.id,
+ name: r.name,
+ position: r.position,
+ permissions: r.permissions,
+ color: r.color ?? null,
+ }),
+ );
+
+ try {
+ await Promise.all(promises);
+ toast.success("All changes saved successfully");
+ setDirty(false);
+ } catch (e: any) {
+ // Errors are handled by onError callback
+ console.error(e);
+ }
+ }
+
+ return (
+
+
+
+
+
+
+
+ {roles.map((r, i) => (
+
+ ))}
+
+
+
+ {dirty ? : null}
+
+
+ );
+}
diff --git a/apps/web/src/components/admin/scanner/PassScanner.tsx b/apps/web/src/components/admin/scanner/PassScanner.tsx
index f7c1d936..e6429b55 100644
--- a/apps/web/src/components/admin/scanner/PassScanner.tsx
+++ b/apps/web/src/components/admin/scanner/PassScanner.tsx
@@ -63,7 +63,7 @@ export default function PassScanner({
: "Not Checked In";
const guild =
Object.keys(c.groups)[scanUser?.hackerData.group || 0] ?? "None";
- const role = scanUser?.role ? scanUser?.role : "Not Found";
+ const role = scanUser?.role?.name ? scanUser?.role?.name : "Not Found";
function handleScanCreate() {
const params = new URLSearchParams(searchParams.toString());
diff --git a/apps/web/src/components/admin/users/BanUserDialog.tsx b/apps/web/src/components/admin/users/BanUserDialog.tsx
new file mode 100644
index 00000000..1e0204dd
--- /dev/null
+++ b/apps/web/src/components/admin/users/BanUserDialog.tsx
@@ -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 (
+
+ );
+}
diff --git a/apps/web/src/components/admin/users/RemoveUserBanDialog.tsx b/apps/web/src/components/admin/users/RemoveUserBanDialog.tsx
new file mode 100644
index 00000000..a592e88d
--- /dev/null
+++ b/apps/web/src/components/admin/users/RemoveUserBanDialog.tsx
@@ -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 successfully removed!");
+ },
+ async onError(e) {
+ toast.dismiss();
+ toast.error("An error occurred while removing suspension.");
+ console.error(e);
+ },
+ });
+
+ return (
+
+ );
+}
diff --git a/apps/web/src/components/admin/users/ServerSections.tsx b/apps/web/src/components/admin/users/ServerSections.tsx
index 21b7c6cb..951654ee 100644
--- a/apps/web/src/components/admin/users/ServerSections.tsx
+++ b/apps/web/src/components/admin/users/ServerSections.tsx
@@ -1,9 +1,9 @@
import UserInfoSection from "@/components/admin/users/UserInfoSection";
import type { Hacker } from "db/types";
-import { titleCase } from "title-case";
import { Button } from "@/components/shadcn/ui/button";
import Link from "next/link";
import { clerkClient } from "@clerk/nextjs/server";
+import { titleCase } from "@/lib/utils/shared/string";
export function PersonalInfo({ user }: { user: Hacker }) {
return (
diff --git a/apps/web/src/components/admin/users/UpdateRoleDialog.tsx b/apps/web/src/components/admin/users/UpdateRoleDialog.tsx
index edf59a25..cea26185 100644
--- a/apps/web/src/components/admin/users/UpdateRoleDialog.tsx
+++ b/apps/web/src/components/admin/users/UpdateRoleDialog.tsx
@@ -16,30 +16,34 @@ import {
SelectValue,
} from "@/components/shadcn/ui/select";
import { Button } from "@/components/shadcn/ui/button";
-import { perms } from "config";
import { toast } from "sonner";
import { useAction } from "next-safe-action/hooks";
import { updateRole } from "@/actions/admin/user-actions";
import { useState } from "react";
-import { titleCase } from "title-case";
import { Badge } from "@/components/shadcn/ui/badge";
+import { db } from "db";
+import { titleCase } from "@/lib/utils/shared/string";
interface UpdateRoleDialogProps {
userID: string;
name: string;
- currPermision: (typeof perms)[number];
- canMakeAdmins: boolean;
+ currentRoleId: number;
}
-export default function UpdateRoleDialog({
+export default async function UpdateRoleDialog({
userID,
- currPermision,
- canMakeAdmins,
+ currentRoleId,
name,
}: UpdateRoleDialogProps) {
- const [roleToSet, setRoleToSet] = useState(currPermision);
+ const [roleToSet, setRoleToSet] = useState(currentRoleId);
const [open, setOpen] = useState(false);
+ const roles = await db.query.roles.findMany();
+
+ const currentRoleName = titleCase(
+ roles.find((r) => r.id === currentRoleId)?.name.replace("_", " ") || "",
+ );
+
const { execute } = useAction(updateRole, {
async onSuccess() {
toast.dismiss();
@@ -66,41 +70,15 @@ export default function UpdateRoleDialog({
- {roleToSet !== currPermision ? (
+ {roleToSet !== currentRoleId ? (
-
- {titleCase(currPermision.replace("_", " "))}
-
+ {currentRoleName}
→
-
- {titleCase(roleToSet.replace("_", " "))}
-
+ {currentRoleName}
) : null}