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 } }) { {`Profile

- {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} +

+
+ Contact administration for further assistance. +
+
+
+ ); +} + +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 ( + + + + + + + + Create New Role + +
+
+
+ + setName(e.target.value)} + placeholder="Enter role name" + /> +
+

Permissions

+
+ {permissionEntries.map((p) => ( +
+
+ + togglePerm(p.value) + } + /> + + {p.name} + +
+
+ {hasPermLocal(p.value) + ? "Enabled" + : "Disabled"} + {!canAssignPermission(p.value) && + " (You can't assign this permission)"} +
+
+ ))} +
+
+
+

Appearance

+
+ + setColor(e.target.value)} + /> +
+
+
+
+ + +
+
+
+
+ ); +} 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 ( + + + + + + + Delete Role + +

+ Are you sure you want to delete the role "{role.name}"? This + action cannot be undone. +

+
+ + +
+
+
+ ); +} 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 ( + + + + + + + Ban {name}. + + Ban this user (not permanent action). + + +
+
+