Hackathon Not Found
; diff --git a/apps/blade/src/app/admin/page.tsx b/apps/blade/src/app/admin/page.tsx index 43856eaef..ec1014c00 100644 --- a/apps/blade/src/app/admin/page.tsx +++ b/apps/blade/src/app/admin/page.tsx @@ -1,10 +1,30 @@ import type { Metadata } from "next"; import Link from "next/link"; import { redirect } from "next/navigation"; +import { + AlertCircle, + CalendarDays, + ChartPie, + FormInput, + Hotel, + Mail, + Settings, + ShieldCheck, + Swords, + TicketCheck, + User, + Users, +} from "lucide-react"; import { auth } from "@forge/auth"; import { Button } from "@forge/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "@forge/ui/card"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@forge/ui/card"; import { SIGN_IN_PATH } from "~/consts"; import { api, HydrateClient } from "~/trpc/server"; @@ -20,111 +40,196 @@ export default async function Admin() { redirect(SIGN_IN_PATH); } - const hasCheckIn = await api.auth.hasCheckIn(); - const hasFullAdmin = await api.auth.hasFullAdmin(); - const isOfficer = await api.auth.getOfficerStatus(); + const perms = await api.roles.getPermissions(); - if (!hasCheckIn && !hasFullAdmin) { + // Check if user has any admin permissions + const hasAnyAdminAccess = Object.entries(perms).some(([key, value]) => { + if (key === "IS_OFFICER") return value; + return value && key !== "IS_JUDGE"; + }); + + if (!hasAnyAdminAccess) { redirect("/"); } const user = await api.member.getMember(); - if (!user) { - redirect("/"); - } + + // Define sections with their permission requirements + const sections = [ + { + title: "Club Management", + description: "Manage club members, events, and data", + icon: User, + color: "text-blue-500", + bgColor: "bg-blue-500/10", + items: [ + { + href: "/admin/club/members", + label: "Members", + icon: User, + show: perms.READ_MEMBERS || perms.EDIT_MEMBERS || perms.IS_OFFICER, + }, + { + href: "/admin/club/events", + label: "Events", + icon: CalendarDays, + show: + perms.READ_CLUB_EVENT || perms.EDIT_CLUB_EVENT || perms.IS_OFFICER, + }, + { + href: "/admin/club/check-in", + label: "Check-in", + icon: TicketCheck, + show: perms.CHECKIN_CLUB_EVENT || perms.IS_OFFICER, + }, + { + href: "/admin/club/data", + label: "Analytics", + icon: ChartPie, + show: perms.READ_CLUB_DATA || perms.IS_OFFICER, + }, + ], + }, + { + title: "Hackathon", + description: "Manage hackers, events, and assignments", + icon: Swords, + color: "text-purple-500", + bgColor: "bg-purple-500/10", + items: [ + { + href: "/admin/hackathon/hackers", + label: "Hackers", + icon: Swords, + show: perms.READ_HACKERS || perms.EDIT_HACKERS || perms.IS_OFFICER, + }, + { + href: "/admin/hackathon/events", + label: "Events", + icon: CalendarDays, + show: + perms.READ_HACK_EVENT || perms.EDIT_HACK_EVENT || perms.IS_OFFICER, + }, + { + href: "/admin/hackathon/check-in", + label: "Check-in", + icon: TicketCheck, + show: perms.CHECKIN_HACK_EVENT || perms.IS_OFFICER, + }, + { + href: "/admin/hackathon/data", + label: "Analytics", + icon: ChartPie, + show: perms.READ_HACK_DATA || perms.IS_OFFICER, + }, + { + href: "/admin/hackathon/roomAssignment", + label: "Room Assignment", + icon: Hotel, + show: perms.IS_OFFICER, + }, + { + href: "/admin/hackathon/judge-assignment", + label: "Judge Assignment", + icon: Users, + show: perms.IS_JUDGE || perms.IS_OFFICER, + }, + { + href: "/admin/hackathon/control-room", + label: "Control Room", + icon: AlertCircle, + show: perms.IS_OFFICER, + }, + ], + }, + { + title: "System", + description: "Forms, emails, and role management", + icon: Settings, + color: "text-green-500", + bgColor: "bg-green-500/10", + items: [ + { + href: "/admin/forms", + label: "Forms", + icon: FormInput, + show: perms.READ_FORMS || perms.EDIT_FORMS || perms.IS_OFFICER, + }, + { + href: "/admin/email", + label: "Email Portal", + icon: Mail, + show: perms.EMAIL_PORTAL || perms.IS_OFFICER, + }, + { + href: "/admin/roles/configure", + label: "Configure Roles", + icon: Settings, + show: perms.CONFIGURE_ROLES || perms.IS_OFFICER, + }, + { + href: "/admin/roles/manage", + label: "Assign Roles", + icon: ShieldCheck, + show: perms.ASSIGN_ROLES || perms.IS_OFFICER, + }, + ], + }, + ]; + + // Filter sections to only show those with at least one visible item + const visibleSections = sections + .map((section) => ({ + ...section, + items: section.items.filter((item) => item.show), + })) + .filter((section) => section.items.length > 0); return (
-
-
- Let's get cooking.
+
- Hello, {user.firstName} -
-
- Let's get cooking.
+
+ {/* Header */}
+
+
+ Welcome back, {user?.firstName ?? "Admin"}
-
-
- {(hasFullAdmin || hasCheckIn) && (
-
-
- Club
-
-
- {hasFullAdmin && (
- <>
-
-
-
-
-
-
-
-
-
- >
- )}
- {(hasFullAdmin || hasCheckIn) && (
-
-
-
- )}
-
-
- )}
- {(hasFullAdmin || hasCheckIn) && (
-
-
- Hackathon
-
-
- {hasFullAdmin && (
- <>
-
-
-
-
-
-
-
-
-
-
-
-
- >
- )}
- {(hasFullAdmin || hasCheckIn) && (
-
-
-
- )}
-
-
- )}
-
- {hasFullAdmin && (
-
-
- Email Dashboard
-
-
-
-
+
+ Here's what's happening with Knight Hacks today.
+
+
+
+ {/* Main Navigation Sections */}
+
+ {visibleSections.map((section) => (
+
+
+
+
+
+
+
+ {section.title}
+
+ {section.description}
+
+
+
+
+
+ {section.items.map((item) => (
+
+
-
-
- )}
-
- {isOfficer && (
-
-
-
-
-
+ ))}
- )}
+ ))}
diff --git a/apps/blade/src/app/admin/roles/configure/_components/roleedit.tsx b/apps/blade/src/app/admin/roles/configure/_components/roleedit.tsx
new file mode 100644
index 000000000..185d00d88
--- /dev/null
+++ b/apps/blade/src/app/admin/roles/configure/_components/roleedit.tsx
@@ -0,0 +1,327 @@
+"use client";
+
+import type { APIRole } from "discord-api-types/v10";
+import { useEffect, useState } from "react";
+import { Link, Loader2, Pencil, User, X } from "lucide-react";
+import { z, ZodBoolean } from "zod";
+
+import { PERMISSION_DATA, PERMISSIONS } from "@forge/consts/knight-hacks";
+import { Button } from "@forge/ui/button";
+import { Checkbox } from "@forge/ui/checkbox";
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ useForm,
+} from "@forge/ui/form";
+import { Input } from "@forge/ui/input";
+import { Label } from "@forge/ui/label";
+import { toast } from "@forge/ui/toast";
+
+import { getPermsAsList } from "~/lib/utils";
+import { api } from "~/trpc/react";
+
+export default function RoleEdit({
+ oldRole,
+}: {
+ oldRole?: {
+ id: string;
+ name: string;
+ permissions: string | null;
+ discordRoleId: string;
+ };
+}) {
+ const [name, setName] = useState(oldRole?.name || "");
+ const [roleID, setRoleID] = useState(oldRole?.discordRoleId || "");
+
+ const [role, setRole] = useState();
+ const [loadingRole, setLoadingRole] = useState(false);
+ const [isDupeID, setIsDupeID] = useState(false);
+ const [isDupeName, setIsDupeName] = useState(false);
+ const [isCreating, setIsCreating] = useState(false);
+ const [isUpdating, setIsUpdating] = useState(false);
+
+ const [permString, setPermString] = useState(
+ "0".repeat(Object.keys(PERMISSIONS).length),
+ );
+
+ const roleQ = api.roles.getDiscordRole.useQuery(
+ { roleId: roleID },
+ {
+ enabled: true,
+ retry: false,
+ },
+ );
+
+ const { data: roles } = api.roles.getAllLinks.useQuery();
+ const { data: roleCounts } = api.roles.getDiscordRoleCounts.useQuery();
+ const createLinkMutation = api.roles.createRoleLink.useMutation();
+ const updateLinkMutation = api.roles.updateRoleLink.useMutation();
+ // Create base form schema dynamically from consts
+ const roleObj: Record = {};
+ const defaults: Record = {};
+ Object.keys(PERMISSIONS).map((v, i) => {
+ roleObj[v] = z.boolean();
+ if (oldRole) {
+ defaults[v] = oldRole.permissions?.at(i) == "1";
+ } else {
+ defaults[v] = false;
+ }
+ });
+
+ const roleSchema = z.object(roleObj);
+
+ const form = useForm({
+ schema: roleSchema,
+ defaultValues: defaults,
+ });
+
+ useEffect(() => {
+ updateString(form.getValues());
+ }, []);
+
+ useEffect(() => {
+ if (roles)
+ setIsDupeID(
+ oldRole
+ ? false
+ : roles.find((v) => v.discordRoleId == roleID) != undefined,
+ );
+
+ async function doGetRole() {
+ setLoadingRole(true);
+ setRole((await roleQ.refetch()).data);
+ setLoadingRole(false);
+ }
+
+ void doGetRole();
+ }, [roleID]);
+
+ useEffect(() => {
+ if (roles)
+ setIsDupeName(
+ oldRole && oldRole.name == name
+ ? false
+ : roles.find((v) => v.name == name) != undefined,
+ );
+ }, [name]);
+
+ function updateString(values: z.infer) {
+ const perms = Object.entries(values);
+ console.log(perms);
+ let newString = "";
+ perms.forEach((v) => {
+ if (v[1]) newString += "1";
+ else newString += "0";
+ });
+
+ setPermString(newString);
+ }
+
+ function sendRole(str: string) {
+ try {
+ if (oldRole) {
+ setIsUpdating(true);
+ updateLinkMutation.mutate(
+ {
+ name: name,
+ id: oldRole.id,
+ roleId: roleID,
+ permissions: str,
+ },
+ {
+ onSettled: () => {
+ setIsUpdating(false);
+ location.reload();
+ },
+ onError: (opts) => {
+ toast.error(opts.message);
+ },
+ },
+ );
+ } else {
+ setIsCreating(true);
+ createLinkMutation.mutate(
+ {
+ name: name,
+ roleId: roleID,
+ permissions: str,
+ },
+ {
+ onSettled: () => {
+ setIsCreating(false);
+ location.reload();
+ },
+ onError: (opts) => {
+ toast.error(opts.message);
+ },
+ },
+ );
+ }
+ } catch (error) {
+ toast((error as Error).message);
+ }
+ }
+
+ return (
+
+ {`${oldRole ? "Edit" : "Create"} Role`}
+
+
+ setName(e.target.value)}
+ id="name"
+ placeholder="ex. Officer"
+ className={`col-span-2 ${isDupeName && "bg-red-900/25"}`}
+ />
+ {isDupeName && (
+
+
+
+ There is already a role with this name.
+
+
+ )}
+
+
+
+ setRoleID(e.target.value)}
+ id="roleId"
+ placeholder="ex. 1151884200069320805"
+ className="col-span-2 font-mono"
+ />
+
+ {loadingRole || !roles ? (
+
+
+ The following role will be linked:
+
+
+
+ ) : role ? (
+
+
+ {isDupeID ? (
+
+
+
+ This role is already linked.
+
+
+ ) : (
+ "The following role will be linked:"
+ )}
+
+
+
+
+
+ {role.name}
+
+
+
+
+
+ {roleCounts ? (
+ {roleCounts[role.id] ?? 0}
+ ) : (
+
+ )}
+
+
+
+
+ ) : (
+
+
+
+ Could not find a Discord role with this ID.
+
+
+ )}
+
+ Permissions
+
+
+
+
+ {`${getPermsAsList(permString).length} permission(s) applied`}
+
+
+
+ );
+}
diff --git a/apps/blade/src/app/admin/roles/configure/_components/roletable.tsx b/apps/blade/src/app/admin/roles/configure/_components/roletable.tsx
new file mode 100644
index 000000000..dc7ab3ecd
--- /dev/null
+++ b/apps/blade/src/app/admin/roles/configure/_components/roletable.tsx
@@ -0,0 +1,198 @@
+/* eslint-disable @typescript-eslint/no-unnecessary-condition */
+"use client";
+
+import type { APIRole } from "discord-api-types/v10";
+import { useEffect, useState } from "react";
+import {
+ Check,
+ ChevronDown,
+ Copy,
+ Edit,
+ Loader2,
+ Trash,
+ User,
+ X,
+} from "lucide-react";
+
+import { Button } from "@forge/ui/button";
+import { Dialog, DialogContent, DialogTrigger } from "@forge/ui/dialog";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuTrigger,
+} from "@forge/ui/dropdown-menu";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@forge/ui/table";
+import { toast } from "@forge/ui/toast";
+
+import { getPermsAsList } from "~/lib/utils";
+import { api } from "~/trpc/react";
+import RoleEdit from "./roleedit";
+
+export default function RoleTable() {
+ const { data: roles } = api.roles.getAllLinks.useQuery();
+ const discordRolesQ = api.roles.getDiscordRoles.useQuery(
+ { roles: roles ?? [] },
+ { enabled: false, retry: false },
+ );
+ const { data: roleCounts } = api.roles.getDiscordRoleCounts.useQuery();
+ const deleteLinkMutation = api.roles.deleteRoleLink.useMutation();
+
+ const [discordRoles, setDiscordRoles] = useState<
+ (APIRole | null)[] | undefined
+ >();
+ const [copyConfirm, setCopyConfirm] = useState(-1);
+
+ useEffect(() => {
+ async function fetchDiscordRoles() {
+ setDiscordRoles((await discordRolesQ.refetch()).data);
+ }
+
+ if (roles) void fetchDiscordRoles();
+ }, [roles]);
+
+ function deleteRole(id: string) {
+ try {
+ deleteLinkMutation.mutate({ id: id });
+ location.reload();
+ } catch (error) {
+ toast((error as Error).message);
+ }
+ }
+
+ return !roles ? (
+
+ ) : roles.length == 0 ? (
+
+ There are currently no roles linked.
+
+ ) : (
+
+
+
+ Role Name
+ Discord Role
+ Permissions
+ Members
+ Edit
+
+
+
+ {roles.map((v, i) => {
+ const role = discordRoles?.at(i);
+ return (
+
+
+ {v.name}
+
+
+ {discordRolesQ.status == "pending" ? (
+
+ ) : role ? (
+
+
+
+
+ {role.name}
+
+
+ ) : (
+
+
+ Not Found
+
+ )}
+
+
+
+
+
+ {getPermsAsList(v.permissions).length}
+
+
+
+
+
+ This role has the following permissions:
+
+
+ {getPermsAsList(v.permissions).map((p) => {
+ return (
+ -
+ {p}
+
+ );
+ })}
+
+
+
+
+
+ {roleCounts ? (
+
+
+ {roleCounts[v.discordRoleId] ?? 0}
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+
+ );
+ })}
+
+
+ );
+}
diff --git a/apps/blade/src/app/admin/roles/configure/page.tsx b/apps/blade/src/app/admin/roles/configure/page.tsx
new file mode 100644
index 000000000..1e39181a9
--- /dev/null
+++ b/apps/blade/src/app/admin/roles/configure/page.tsx
@@ -0,0 +1,48 @@
+import { redirect } from "next/navigation";
+import { ShieldPlus } from "lucide-react";
+
+import { auth } from "@forge/auth";
+import { Button } from "@forge/ui/button";
+import { Dialog, DialogContent, DialogTrigger } from "@forge/ui/dialog";
+
+import { SIGN_IN_PATH } from "~/consts";
+import { api } from "~/trpc/server";
+import RoleEdit from "./_components/roleedit";
+import RoleTable from "./_components/roletable";
+
+export default async function Roles() {
+ const session = await auth();
+ if (!session) {
+ redirect(SIGN_IN_PATH);
+ }
+
+ const hasAccess = await api.roles.hasPermission({
+ or: ["CONFIGURE_ROLES"],
+ });
+
+ if (!hasAccess) {
+ redirect("/");
+ }
+
+ return (
+
+
+
+ Role Configuration
+
+
+
+
+
+ );
+}
diff --git a/apps/blade/src/app/admin/roles/manage/page.tsx b/apps/blade/src/app/admin/roles/manage/page.tsx
new file mode 100644
index 000000000..c26e075a6
--- /dev/null
+++ b/apps/blade/src/app/admin/roles/manage/page.tsx
@@ -0,0 +1,23 @@
+import { redirect } from "next/navigation";
+
+import { api } from "~/trpc/server";
+import RoleAssign from "./roleassign";
+
+export default async function ManageRoles() {
+ const hasAccess = await api.roles.hasPermission({
+ or: ["ASSIGN_ROLES"],
+ });
+ if (!hasAccess) {
+ redirect("/");
+ }
+ return (
+
+
+
+ Role Management
+
+
+
+
+ );
+}
diff --git a/apps/blade/src/app/admin/roles/manage/roleassign.tsx b/apps/blade/src/app/admin/roles/manage/roleassign.tsx
new file mode 100644
index 000000000..d2447bb51
--- /dev/null
+++ b/apps/blade/src/app/admin/roles/manage/roleassign.tsx
@@ -0,0 +1,346 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import {
+ Check,
+ ChevronDown,
+ Copy,
+ Filter,
+ Loader2,
+ Search,
+ ShieldOff,
+ ShieldPlus,
+ UserCheck,
+} from "lucide-react";
+
+import { ResetIcon } from "@forge/ui";
+import { Button } from "@forge/ui/button";
+import { Checkbox } from "@forge/ui/checkbox";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuTrigger,
+} from "@forge/ui/dropdown-menu";
+import { Input } from "@forge/ui/input";
+import { Label } from "@forge/ui/label";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@forge/ui/table";
+import { toast } from "@forge/ui/toast";
+
+import { api } from "~/trpc/react";
+
+export default function RoleAssign() {
+ const { data: users, status } = api.user.getUsers.useQuery();
+ const { data: roles } = api.roles.getAllLinks.useQuery();
+
+ const batchQ = api.roles.batchManagePermission.useMutation();
+
+ const utils = api.useUtils();
+
+ const mappedRoles: Record<
+ string,
+ { name: string; permissions: string; discordRoleId: string }
+ > = {};
+
+ roles?.forEach((v) => {
+ mappedRoles[v.id] = {
+ name: v.name,
+ discordRoleId: v.discordRoleId,
+ permissions: v.permissions,
+ };
+ });
+
+ const [copyConfirm, setCopyConfirm] = useState(-1);
+ const [searchTerm, setSearchTerm] = useState("");
+
+ // weird hack to force the DOM to update
+ const [upd, sUpd] = useState(false);
+
+ const [checkedUsers, setCheckedUsers] = useState>({}); // stores userIds
+ const [checkedRoles, _setCheckedRoles] = useState>(
+ {},
+ ); // stores roleIds
+ // all checked roles will be applied to all checked users
+
+ const [filterRoles, _setFilterRoles] = useState>({});
+
+ const [countedUsers, setCountedUsers] = useState(0);
+
+ useEffect(() => {
+ let sum = 0;
+ Object.entries(checkedUsers).forEach((v) => {
+ if (v[1]) sum++;
+ });
+ setCountedUsers(sum);
+ }, [checkedUsers, upd]);
+
+ const filteredUsers = (users ?? []).filter((user) =>
+ Object.values(filterRoles).includes(true) &&
+ !user.permissions.find((v) => filterRoles[v.roleId])
+ ? false
+ : Object.values(user).some((value) => {
+ if (value === null) return false;
+ return typeof value === "string" ||
+ typeof value === "number" ||
+ typeof value === "boolean"
+ ? value.toString().toLowerCase().includes(searchTerm.toLowerCase())
+ : false;
+ }),
+ );
+
+ const sendBatchRequest = (
+ users: typeof checkedUsers,
+ roles: typeof checkedRoles,
+ revoking: boolean,
+ ) => {
+ const finalUsers = Object.entries(users)
+ .map((v) => {
+ if (v[1]) return v[0];
+ })
+ .filter((v) => v != undefined);
+ const finalRoles = Object.entries(roles)
+ .map((v) => {
+ if (v[1]) return v[0];
+ })
+ .filter((v) => v != undefined);
+
+ void batchQ.mutate(
+ { roleIds: finalRoles, userIds: finalUsers, revoking },
+ {
+ onSuccess() {
+ void utils.roles.getAllLinks.invalidate();
+ location.reload();
+ },
+ onError(opts) {
+ toast.error(opts.message);
+ },
+ },
+ );
+ };
+
+ return (
+
+
+
+
+
+ setSearchTerm(e.target.value)}
+ className="pl-8"
+ />
+
+ setCheckedUsers({})}
+ className="my-auto flex h-full cursor-pointer flex-row gap-1 rounded-lg border px-2 py-1 hover:bg-muted"
+ >
+
+ {countedUsers}
+
+
+
+
+
+
+
+
+
+
+
+ Select roles to filter by:
+
+
+ {!roles ? (
+
+ ) : (
+ roles.map((v, i) => {
+ return (
+ -
+
{
+ filterRoles[v.id] = c == true;
+ sUpd(!upd);
+ }}
+ />
+
+
+ );
+ })
+ )}
+
+
+
+
+ {status == "pending" ? (
+
+ ) : !users ? (
+
+ Failed to get users.
+
+ ) : filteredUsers.length == 0 ? (
+
+ Could not find any users matching this search.
+
+ ) : (
+
+
+
+ User
+ Discord ID
+ Roles
+
+
+
+ {filteredUsers.map((v, i) => {
+ return (
+
+
+ {
+ checkedUsers[v.id] = c == true;
+ sUpd(!upd);
+ }}
+ />
+
+
+
+ {
+ void navigator.clipboard.writeText(v.discordUserId);
+ setCopyConfirm(i);
+ toast(`Copied "${v.discordUserId}" to clipboard!`);
+ }}
+ className={`text-muted-foreground ${copyConfirm == i && "border-muted-foreground bg-muted"} flex w-fit cursor-pointer flex-row gap-1 rounded-full border px-2 py-1 hover:border-white hover:bg-muted hover:text-white`}
+ >
+ {copyConfirm == i ? (
+
+ ) : (
+
+ )}
+ {`${v.discordUserId}`}
+
+
+
+ {v.permissions.length == 0 ? (
+ ""
+ ) : v.permissions.length == 1 ? (
+ (mappedRoles[v.permissions.at(0)?.roleId || ""]?.name ??
+ "?")
+ ) : (
+
+
+
+ {v.permissions.length}
+
+
+
+
+
+ This user has the following roles:
+
+
+ {v.permissions.map((p) => {
+ return (
+ -
+ {mappedRoles[p.roleId]?.name ?? ""}
+
+ );
+ })}
+
+
+
+ )}
+
+
+ );
+ })}
+
+
+ )}
+
+
+
+
+ Controls
+
+
+
+
+
+
+
+ {!roles ? (
+
+ ) : (
+ roles.map((v, i) => {
+ return (
+ -
+
{
+ checkedRoles[v.id] = c == true;
+ sUpd(!upd);
+ }}
+ />
+
+
+ );
+ })
+ )}
+
+
+
+
+ );
+}
diff --git a/apps/blade/src/app/dashboard/_components/hackathon-dashboard/point-leaderboard.tsx b/apps/blade/src/app/dashboard/_components/hackathon-dashboard/point-leaderboard.tsx
index 801dba979..ee6941757 100644
--- a/apps/blade/src/app/dashboard/_components/hackathon-dashboard/point-leaderboard.tsx
+++ b/apps/blade/src/app/dashboard/_components/hackathon-dashboard/point-leaderboard.tsx
@@ -46,7 +46,9 @@ export function PointLeaderboard({
const [activeInd, setInd] = useState(-1);
const [activeTop, setTop] = useState(overall);
- const { data: isAdmin } = api.auth.getAdminStatus.useQuery();
+ const { data: isAdmin } = api.roles.hasPermission.useQuery({
+ or: ["READ_CLUB_DATA"],
+ });
const targetDate = new Date("2025-10-25T23:00:00").getTime();
diff --git a/apps/blade/src/app/judge/results/page.tsx b/apps/blade/src/app/judge/results/page.tsx
index a53f9ea50..2ddce1dab 100644
--- a/apps/blade/src/app/judge/results/page.tsx
+++ b/apps/blade/src/app/judge/results/page.tsx
@@ -1,6 +1,7 @@
import type { Metadata } from "next";
+import { redirect } from "next/navigation";
-import { HydrateClient } from "~/trpc/server";
+import { api, HydrateClient } from "~/trpc/server";
import ResultsTable from "./_components/results-table";
export const metadata: Metadata = {
@@ -8,7 +9,13 @@ export const metadata: Metadata = {
description: "Display hackathon results",
};
-export default function ResultsDashboard() {
+export default async function ResultsDashboard() {
+ const hasAccess = await api.roles.hasPermission({
+ or: ["IS_JUDGE"],
+ });
+ if (!hasAccess) {
+ redirect("/");
+ }
return (
diff --git a/apps/blade/src/lib/utils.ts b/apps/blade/src/lib/utils.ts
index aa65ce7a3..d57da175b 100644
--- a/apps/blade/src/lib/utils.ts
+++ b/apps/blade/src/lib/utils.ts
@@ -3,6 +3,7 @@ import type { z } from "zod";
import type { EventTagsColor } from "@forge/consts/knight-hacks";
import type { HackerClass } from "@forge/db/schemas/knight-hacks";
+import { PERMISSION_DATA, PERMISSIONS } from "@forge/consts/knight-hacks";
export const formatDateTime = (date: Date) => {
// Create a new Date object 5 hours behind the original
@@ -115,3 +116,16 @@ export function extractProcedures(router: AnyTRPCRouter) {
return procedures;
}
+
+export function getPermsAsList(perms: string) {
+ const list = [];
+ const permKeys = Object.keys(PERMISSIONS);
+ for (let i = 0; i < perms.length; i++) {
+ const permKey = permKeys.at(i);
+ if (perms[i] == "1" && permKey) {
+ const permissionData = PERMISSION_DATA[permKey];
+ if (permissionData) list.push(permissionData.name);
+ }
+ }
+ return list;
+}
diff --git a/apps/tk/src/hooks/index.ts b/apps/tk/src/hooks/index.ts
index 7cc7db71f..ad0096b57 100644
--- a/apps/tk/src/hooks/index.ts
+++ b/apps/tk/src/hooks/index.ts
@@ -4,6 +4,7 @@ import { execute as animals } from "./animals";
import { execute as daily } from "./daily";
import { execute as emailQueue } from "./email-queue";
import { execute as reminder } from "./reminder";
+import { execute as roleSync } from "./role-sync";
// Export all commands
export const hooks = {
@@ -13,4 +14,5 @@ export const hooks = {
reminder,
emailQueue,
alumniSync,
+ roleSync,
};
diff --git a/apps/tk/src/hooks/role-sync.ts b/apps/tk/src/hooks/role-sync.ts
new file mode 100644
index 000000000..32a85b9b9
--- /dev/null
+++ b/apps/tk/src/hooks/role-sync.ts
@@ -0,0 +1,21 @@
+import cron from "node-cron";
+
+import { syncRoles } from "../services/role-sync";
+
+/**
+ * Cron job to sync Blade permissions with Discord roles
+ * Runs every morning at 8:00 AM
+ */
+export function execute() {
+ cron.schedule("0 8 * * *", () => {
+ void (async () => {
+ console.log("[CRON] Role sync job fired:", new Date().toISOString());
+ try {
+ await syncRoles();
+ console.log("[CRON] Role sync completed successfully");
+ } catch (err) {
+ console.error("[CRON] Role sync failed:", err);
+ }
+ })();
+ });
+}
diff --git a/apps/tk/src/services/role-sync.ts b/apps/tk/src/services/role-sync.ts
new file mode 100644
index 000000000..32ed4f461
--- /dev/null
+++ b/apps/tk/src/services/role-sync.ts
@@ -0,0 +1,110 @@
+import type { APIGuildMember } from "discord-api-types/v10";
+import { Routes } from "discord-api-types/v10";
+
+import { eq } from "@forge/db";
+import { db } from "@forge/db/client";
+import { Permissions, Roles, User } from "@forge/db/schemas/auth";
+
+import {
+ discord,
+ KNIGHTHACKS_GUILD_ID,
+} from "../../../../packages/api/src/utils";
+
+/**
+ * Syncs Blade permissions with Discord roles
+ *
+ * This cron job ensures consistency between Blade's permission system and Discord:
+ * 1. If a user has a permission in Blade but NOT the role on Discord → Remove from Blade
+ * 2. If a user has a role on Discord that's linked in Blade but NO permission entry → Add to Blade
+ */
+export async function syncRoles() {
+ try {
+ console.log("[Role Sync] Starting role synchronization...");
+
+ // Get all roles that are linked in Blade
+ const linkedRoles = await db.select().from(Roles);
+ console.log(`[Role Sync] Found ${linkedRoles.length} linked roles`);
+
+ // Get all users in Blade
+ const users = await db.select().from(User);
+ console.log(`[Role Sync] Checking ${users.length} users`);
+
+ let addedCount = 0;
+ let removedCount = 0;
+ let skippedCount = 0;
+
+ for (const user of users) {
+ try {
+ // Fetch the user's roles from Discord
+ const guildMember = (await discord.get(
+ Routes.guildMember(KNIGHTHACKS_GUILD_ID, user.discordUserId),
+ )) as APIGuildMember;
+
+ const discordRoleIds = guildMember.roles;
+
+ // Get user's current permissions in Blade
+ const userPermissions = await db
+ .select({
+ permissionId: Permissions.id,
+ roleId: Permissions.roleId,
+ discordRoleId: Roles.discordRoleId,
+ roleName: Roles.name,
+ })
+ .from(Permissions)
+ .innerJoin(Roles, eq(Permissions.roleId, Roles.id))
+ .where(eq(Permissions.userId, user.id));
+
+ // Check 1: Remove permissions from Blade if user doesn't have role on Discord
+ for (const perm of userPermissions) {
+ if (!discordRoleIds.includes(perm.discordRoleId)) {
+ console.log(
+ `[Role Sync] Removing role "${perm.roleName}" from user ${user.name} (${user.discordUserId}) - not on Discord`,
+ );
+ await db
+ .delete(Permissions)
+ .where(eq(Permissions.id, perm.permissionId));
+ removedCount++;
+ }
+ }
+
+ // Check 2: Add permissions to Blade if user has role on Discord but not in Blade
+ for (const role of linkedRoles) {
+ const hasRoleOnDiscord = discordRoleIds.includes(role.discordRoleId);
+ const hasPermissionInBlade = userPermissions.some(
+ (p) => p.roleId === role.id,
+ );
+
+ if (hasRoleOnDiscord && !hasPermissionInBlade) {
+ console.log(
+ `[Role Sync] Adding role "${role.name}" to user ${user.name} (${user.discordUserId}) - found on Discord`,
+ );
+ await db.insert(Permissions).values({
+ roleId: role.id,
+ userId: user.id,
+ });
+ addedCount++;
+ }
+ }
+ } catch (error) {
+ // User might not be in the guild anymore
+ if ((error as { status?: number } | undefined)?.status === 404) {
+ console.log(
+ `[Role Sync] User ${user.name} (${user.discordUserId}) not found in guild - skipping`,
+ );
+ skippedCount++;
+ } else {
+ console.error(
+ `[Role Sync] Error syncing user ${user.name} (${user.discordUserId}):`,
+ error,
+ );
+ }
+ }
+ }
+
+ console.log(
+ `[Role Sync] Sync completed. Added: ${addedCount}, Removed: ${removedCount}, Skipped: ${skippedCount}`,
+ );
+ } catch (err) {
+ console.error("[Role Sync] Unexpected error during role sync:", err);
+ }
+}
diff --git a/packages/api/src/root.ts b/packages/api/src/root.ts
index 64a95de3c..4b8e3dd0f 100644
--- a/packages/api/src/root.ts
+++ b/packages/api/src/root.ts
@@ -16,6 +16,7 @@ import { memberRouter } from "./routers/member";
import { passkitRouter } from "./routers/passkit";
import { qrRouter } from "./routers/qr";
import { resumeRouter } from "./routers/resume";
+import { rolesRouter } from "./routers/roles";
import { userRouter } from "./routers/user";
import { createTRPCRouter } from "./trpc";
@@ -39,6 +40,7 @@ export const appRouter = createTRPCRouter<{
csvImporter: typeof csvImporterRouter;
companies: typeof companiesRouter;
forms: typeof formsRouter;
+ roles: typeof rolesRouter;
}>({
auth: authRouter,
duesPayment: duesPaymentRouter,
@@ -59,6 +61,7 @@ export const appRouter = createTRPCRouter<{
csvImporter: csvImporterRouter,
companies: companiesRouter,
forms: formsRouter,
+ roles: rolesRouter,
});
// export type definition of API
diff --git a/packages/api/src/routers/auth.ts b/packages/api/src/routers/auth.ts
index f8c295783..9b0dc6e34 100644
--- a/packages/api/src/routers/auth.ts
+++ b/packages/api/src/routers/auth.ts
@@ -1,20 +1,9 @@
import type { TRPCRouterRecord } from "@trpc/server";
-import { z } from "zod";
-import type { PermissionIndex } from "@forge/consts/knight-hacks";
import { invalidateSessionToken } from "@forge/auth/server";
import { protectedProcedure, publicProcedure } from "../trpc";
-import {
- getUserPermissions,
- isDiscordAdmin,
- isDiscordMember,
- isJudgeAdmin,
- userHasCheckIn,
- userHasFullAdmin,
- userHasPermission,
- userIsOfficer,
-} from "../utils";
+import { isDiscordAdmin, isDiscordMember, isJudgeAdmin } from "../utils";
export const authRouter = {
getSession: publicProcedure.query(({ ctx }) => {
@@ -45,50 +34,11 @@ export const authRouter = {
}
return isDiscordMember(ctx.session.user);
}),
- getOfficerStatus: publicProcedure.query(({ ctx }): Promise => {
- if (!ctx.session) {
- return Promise.resolve(false);
- }
- return userIsOfficer(ctx.session.user);
- }),
- getUserPermissions: publicProcedure.query(({ ctx }): Promise => {
- if (!ctx.session) {
- return Promise.resolve("00");
- }
- return getUserPermissions(ctx.session.user);
- }),
-
- hasPermission: publicProcedure
- .input(z.object({ permission: z.number() }))
- .query(({ ctx, input }): Promise => {
- if (!ctx.session) {
- return Promise.resolve(false);
- }
- return userHasPermission(
- ctx.session.user,
- input.permission as PermissionIndex,
- );
- }),
-
- hasFullAdmin: publicProcedure.query(({ ctx }): Promise => {
- if (!ctx.session) {
- return Promise.resolve(false);
- }
- return userHasFullAdmin(ctx.session.user);
- }),
-
getJudgeStatus: publicProcedure.query(async () => {
const isJudge = await isJudgeAdmin();
return isJudge;
}),
- hasCheckIn: publicProcedure.query(({ ctx }): Promise => {
- if (!ctx.session) {
- return Promise.resolve(false);
- }
- return userHasCheckIn(ctx.session.user);
- }),
-
signOut: protectedProcedure.mutation(async (opts) => {
if (!opts.ctx.token) {
return { success: false };
diff --git a/packages/api/src/routers/csv-importer.ts b/packages/api/src/routers/csv-importer.ts
index 00c06be1d..fff5eea3e 100644
--- a/packages/api/src/routers/csv-importer.ts
+++ b/packages/api/src/routers/csv-importer.ts
@@ -6,7 +6,8 @@ import { eq, sql } from "@forge/db";
import { db } from "@forge/db/client";
import { Challenges, Submissions, Teams } from "@forge/db/schemas/knight-hacks";
-import { officerProcedure } from "../trpc";
+import { permProcedure } from "../trpc";
+import { controlPerms } from "../utils";
interface CsvImporterRecord {
"Opt-In Prize": string | null;
@@ -23,14 +24,16 @@ interface CsvImporterRecord {
}
export const csvImporterRouter = {
- import: officerProcedure
+ import: permProcedure
.input(
z.object({
hackathon_id: z.string(),
csvContent: z.string(),
}),
)
- .mutation(async ({ input }) => {
+ .mutation(async ({ ctx, input }) => {
+ controlPerms.or(["IS_OFFICER"], ctx);
+
try {
// Get raw records
const rawRecords = parse(input.csvContent, {
diff --git a/packages/api/src/routers/email-queue.ts b/packages/api/src/routers/email-queue.ts
index d53e77304..4b072afac 100644
--- a/packages/api/src/routers/email-queue.ts
+++ b/packages/api/src/routers/email-queue.ts
@@ -20,13 +20,15 @@ import {
updateEmailInputSchema,
} from "@forge/validators";
-import { publicProcedure } from "../trpc";
+import { permProcedure, publicProcedure } from "../trpc";
+import { controlPerms } from "../utils";
export const emailQueueRouter = {
// Queue a single email
- queueEmail: publicProcedure
+ queueEmail: permProcedure
.input(emailQueueInputSchema)
- .mutation(async ({ input }) => {
+ .mutation(async ({ input, ctx }) => {
+ controlPerms.or(["EMAIL_PORTAL"], ctx);
const result = await db
.insert(EmailQueue)
.values({
@@ -48,9 +50,10 @@ export const emailQueueRouter = {
}),
// Queue a batch of emails
- queueBatchEmail: publicProcedure
+ queueBatchEmail: permProcedure
.input(batchEmailInputSchema)
- .mutation(async ({ input }) => {
+ .mutation(async ({ input, ctx }) => {
+ controlPerms.or(["EMAIL_PORTAL"], ctx);
const batchId = crypto.randomUUID();
const emails = input.recipients.map((to, index) => ({
batch_id: batchId,
@@ -82,9 +85,10 @@ export const emailQueueRouter = {
}),
// Update a queued email
- updateQueuedEmail: publicProcedure
+ updateQueuedEmail: permProcedure
.input(updateEmailInputSchema)
- .mutation(async ({ input }) => {
+ .mutation(async ({ input, ctx }) => {
+ controlPerms.or(["EMAIL_PORTAL"], ctx);
const { id, ...updateData } = input;
// Check if email is editable
@@ -124,9 +128,10 @@ export const emailQueueRouter = {
}),
// Delete a queued email
- deleteQueuedEmail: publicProcedure
+ deleteQueuedEmail: permProcedure
.input(z.object({ id: z.string().uuid() }))
- .mutation(async ({ input }) => {
+ .mutation(async ({ input, ctx }) => {
+ controlPerms.or(["EMAIL_PORTAL"], ctx);
const email = await db
.select()
.from(EmailQueue)
@@ -153,58 +158,61 @@ export const emailQueueRouter = {
}),
// Get queue status
- getQueueStatus: publicProcedure.output(queueStatusSchema).query(async () => {
- // Get queue length
- const queueResult = await db
- .select({ count: sql`count(*)` })
- .from(EmailQueue)
- .where(sql`status IN ('pending', 'scheduled')`);
-
- const queueLength = Number(queueResult[0]?.count ?? 0);
-
- // Get daily count and limit
- const todayParts = new Date().toISOString().split("T");
- const today = todayParts[0];
- if (!today) {
- throw new Error("Failed to get today's date");
- }
- const dailyCountResult = await db
- .select()
- .from(EmailDailyCount)
- .where(eq(EmailDailyCount.date, today))
- .limit(1);
-
- const dailyCount = dailyCountResult[0]?.count ?? 0;
- const dailyLimit = dailyCountResult[0]?.limit ?? 100;
- const remainingCapacity = Math.max(0, dailyLimit - dailyCount);
-
- // Get next scheduled email time
- const nextEmailResult = await db
- .select({ scheduled_for: EmailQueue.scheduled_for })
- .from(EmailQueue)
- .where(sql`status = 'scheduled' AND scheduled_for IS NOT NULL`)
- .orderBy(asc(EmailQueue.scheduled_for))
- .limit(1);
-
- const nextSendTime = nextEmailResult[0]?.scheduled_for;
-
- // Get config
- const configResult = await db.select().from(EmailConfig).limit(1);
-
- const isEnabled = configResult[0]?.enabled ?? true;
-
- return {
- queueLength,
- dailyCount,
- dailyLimit,
- remainingCapacity,
- nextSendTime: nextSendTime?.toISOString(),
- isEnabled,
- };
- }),
+ getQueueStatus: permProcedure
+ .output(queueStatusSchema)
+ .query(async ({ ctx }) => {
+ controlPerms.or(["EMAIL_PORTAL"], ctx);
+ // Get queue length
+ const queueResult = await db
+ .select({ count: sql`count(*)` })
+ .from(EmailQueue)
+ .where(sql`status IN ('pending', 'scheduled')`);
+
+ const queueLength = Number(queueResult[0]?.count ?? 0);
+
+ // Get daily count and limit
+ const todayParts = new Date().toISOString().split("T");
+ const today = todayParts[0];
+ if (!today) {
+ throw new Error("Failed to get today's date");
+ }
+ const dailyCountResult = await db
+ .select()
+ .from(EmailDailyCount)
+ .where(eq(EmailDailyCount.date, today))
+ .limit(1);
+
+ const dailyCount = dailyCountResult[0]?.count ?? 0;
+ const dailyLimit = dailyCountResult[0]?.limit ?? 100;
+ const remainingCapacity = Math.max(0, dailyLimit - dailyCount);
+
+ // Get next scheduled email time
+ const nextEmailResult = await db
+ .select({ scheduled_for: EmailQueue.scheduled_for })
+ .from(EmailQueue)
+ .where(sql`status = 'scheduled' AND scheduled_for IS NOT NULL`)
+ .orderBy(asc(EmailQueue.scheduled_for))
+ .limit(1);
+
+ const nextSendTime = nextEmailResult[0]?.scheduled_for;
+
+ // Get config
+ const configResult = await db.select().from(EmailConfig).limit(1);
+
+ const isEnabled = configResult[0]?.enabled ?? true;
+
+ return {
+ queueLength,
+ dailyCount,
+ dailyLimit,
+ remainingCapacity,
+ nextSendTime: nextSendTime?.toISOString(),
+ isEnabled,
+ };
+ }),
// Get queued emails with pagination
- getQueuedEmails: publicProcedure
+ getQueuedEmails: permProcedure
.input(
z.object({
page: z.number().min(1).default(1),
@@ -216,7 +224,8 @@ export const emailQueueRouter = {
}),
)
.output(paginatedEmailsSchema)
- .query(async ({ input }) => {
+ .query(async ({ input, ctx }) => {
+ controlPerms.or(["EMAIL_PORTAL"], ctx);
const { page, pageSize, status, priority } = input;
const offset = (page - 1) * pageSize;
@@ -271,9 +280,10 @@ export const emailQueueRouter = {
}),
// Update email configuration
- updateEmailConfig: publicProcedure
+ updateEmailConfig: permProcedure
.input(emailConfigInputSchema)
- .mutation(async ({ input }) => {
+ .mutation(async ({ input, ctx }) => {
+ controlPerms.or(["EMAIL_PORTAL"], ctx);
const configResult = await db.select().from(EmailConfig).limit(1);
if (configResult.length === 0) {
@@ -305,7 +315,8 @@ export const emailQueueRouter = {
}),
// Get email configuration
- getEmailConfig: publicProcedure.query(async () => {
+ getEmailConfig: permProcedure.query(async ({ ctx }) => {
+ controlPerms.or(["EMAIL_PORTAL"], ctx);
try {
const configResult = await db.select().from(EmailConfig).limit(1);
@@ -347,7 +358,7 @@ export const emailQueueRouter = {
}),
// Schedule an email for future delivery
- scheduleEmail: publicProcedure
+ scheduleEmail: permProcedure
.input(
z.object({
to: z.string().email(),
@@ -361,7 +372,8 @@ export const emailQueueRouter = {
maxAttempts: z.number().min(1).max(10).default(3),
}),
)
- .mutation(async ({ input }) => {
+ .mutation(async ({ input, ctx }) => {
+ controlPerms.or(["EMAIL_PORTAL"], ctx);
const result = await db
.insert(EmailQueue)
.values({
@@ -392,7 +404,7 @@ export const emailQueueRouter = {
}),
// Queue emails with blacklist rules
- queueEmailWithBlacklist: publicProcedure
+ queueEmailWithBlacklist: permProcedure
.input(
z.object({
to: z.string().email(),
@@ -406,7 +418,8 @@ export const emailQueueRouter = {
maxAttempts: z.number().min(1).max(10).default(3),
}),
)
- .mutation(async ({ input }) => {
+ .mutation(async ({ input, ctx }) => {
+ controlPerms.or(["EMAIL_PORTAL"], ctx);
const result = await db
.insert(EmailQueue)
.values({
diff --git a/packages/api/src/routers/email.ts b/packages/api/src/routers/email.ts
index 53ffd2880..a9e1b8bf6 100644
--- a/packages/api/src/routers/email.ts
+++ b/packages/api/src/routers/email.ts
@@ -1,11 +1,11 @@
import type { TRPCRouterRecord } from "@trpc/server";
import { z } from "zod";
-import { publicProcedure } from "../trpc";
-import { sendEmail } from "../utils";
+import { permProcedure } from "../trpc";
+import { controlPerms, sendEmail } from "../utils";
export const emailRouter = {
- sendEmail: publicProcedure
+ sendEmail: permProcedure
.input(
z.object({
to: z.string().email(),
@@ -14,7 +14,8 @@ export const emailRouter = {
from: z.string().min(1),
}),
)
- .mutation(async ({ input }) => {
+ .mutation(async ({ input, ctx }) => {
+ controlPerms.or(["EMAIL_PORTAL"], ctx);
try {
const response = await sendEmail({
to: input.to,
diff --git a/packages/api/src/routers/event-feedback.ts b/packages/api/src/routers/event-feedback.ts
index 90fe58561..1d9f178f9 100644
--- a/packages/api/src/routers/event-feedback.ts
+++ b/packages/api/src/routers/event-feedback.ts
@@ -14,13 +14,14 @@ import {
Member,
} from "@forge/db/schemas/knight-hacks";
-import { protectedProcedure } from "../trpc";
-import { log } from "../utils";
+import { permProcedure } from "../trpc";
+import { controlPerms, log } from "../utils";
export const eventFeedbackRouter = {
- createEventFeedback: protectedProcedure
+ createEventFeedback: permProcedure
.input(InsertEventFeedbackSchema)
.mutation(async ({ input, ctx }) => {
+ controlPerms.or(["IS_JUDGE"], ctx);
const existingFeedback = await db.query.EventFeedback.findFirst({
where: (t, { eq }) =>
and(eq(t.memberId, input.memberId), eq(t.eventId, input.eventId)),
@@ -72,14 +73,15 @@ export const eventFeedbackRouter = {
});
}),
- hasGivenFeedback: protectedProcedure
+ hasGivenFeedback: permProcedure
.input(
z.object({
eventId: z.string(),
memberId: z.string(),
}),
)
- .query(async ({ input }) => {
+ .query(async ({ input, ctx }) => {
+ controlPerms.or(["IS_JUDGE"], ctx);
const givenFeedback = await db.query.EventFeedback.findFirst({
where: (t, { eq }) =>
and(eq(t.memberId, input.memberId), eq(t.eventId, input.eventId)),
@@ -88,7 +90,7 @@ export const eventFeedbackRouter = {
return !!givenFeedback;
}),
- logHackathonFeedback: protectedProcedure
+ logHackathonFeedback: permProcedure
.input(
z.object({
description: z.string(),
diff --git a/packages/api/src/routers/event.ts b/packages/api/src/routers/event.ts
index 33ee05f68..8f0f52050 100644
--- a/packages/api/src/routers/event.ts
+++ b/packages/api/src/routers/event.ts
@@ -28,8 +28,8 @@ import {
} from "@forge/db/schemas/knight-hacks";
import { env } from "../env";
-import { adminProcedure, publicProcedure } from "../trpc";
-import { calendar, discord, log } from "../utils";
+import { permProcedure, publicProcedure } from "../trpc";
+import { calendar, controlPerms, discord, log } from "../utils";
const GOOGLE_CALENDAR_ID =
env.NODE_ENV === "production"
@@ -55,21 +55,27 @@ export const eventRouter = {
.orderBy(desc(Event.start_datetime));
return events;
}),
- getAttendees: adminProcedure.input(z.string()).query(async ({ input }) => {
- const attendees = await db
- .select({
- ...getTableColumns(Member),
- })
- .from(Event)
- .innerJoin(EventAttendee, eq(Event.id, EventAttendee.eventId))
- .innerJoin(Member, eq(EventAttendee.memberId, Member.id))
- .where(eq(Event.id, input))
- .orderBy(Member.firstName);
- return attendees;
- }),
- getHackerAttendees: adminProcedure
+ getAttendees: permProcedure
.input(z.string())
- .query(async ({ input }) => {
+ .query(async ({ ctx, input }) => {
+ controlPerms.or(["READ_CLUB_EVENT"], ctx);
+
+ const attendees = await db
+ .select({
+ ...getTableColumns(Member),
+ })
+ .from(Event)
+ .innerJoin(EventAttendee, eq(Event.id, EventAttendee.eventId))
+ .innerJoin(Member, eq(EventAttendee.memberId, Member.id))
+ .where(eq(Event.id, input))
+ .orderBy(Member.firstName);
+ return attendees;
+ }),
+ getHackerAttendees: permProcedure
+ .input(z.string())
+ .query(async ({ ctx, input }) => {
+ controlPerms.or(["READ_HACK_EVENT"], ctx);
+
const attendees = await db
.select({
...getTableColumns(Hacker),
@@ -88,11 +94,13 @@ export const eventRouter = {
.orderBy(Hacker.firstName);
return attendees;
}),
- createEvent: adminProcedure
+ createEvent: permProcedure
.input(
InsertEventSchema.omit({ id: true, discordId: true, googleId: true }),
)
.mutation(async ({ input, ctx }) => {
+ controlPerms.or(["EDIT_CLUB_EVENT", "EDIT_HACK_EVENT"], ctx);
+
// Step 0: Convert provided start/end datetimes into Local Date objects
const startDatetime = new Date(input.start_datetime);
const endDatetime = new Date(input.end_datetime);
@@ -260,9 +268,11 @@ export const eventRouter = {
});
}),
- updateEvent: adminProcedure
+ updateEvent: permProcedure
.input(InsertEventSchema)
.mutation(async ({ input, ctx }) => {
+ controlPerms.or(["EDIT_CLUB_EVENT", "EDIT_HACK_EVENT"], ctx);
+
if (!input.id) {
throw new TRPCError({
message: "Event ID is required to update an Event.",
@@ -454,7 +464,7 @@ export const eventRouter = {
})
.where(eq(Event.id, input.id));
}),
- deleteEvent: adminProcedure
+ deleteEvent: permProcedure
.input(
InsertEventSchema.pick({
id: true,
@@ -465,6 +475,8 @@ export const eventRouter = {
}),
)
.mutation(async ({ input, ctx }) => {
+ controlPerms.or(["EDIT_CLUB_EVENT", "EDIT_HACK_EVENT"], ctx);
+
if (!input.id) {
throw new TRPCError({
message: "Event ID is required to delete an Event.",
diff --git a/packages/api/src/routers/forms.ts b/packages/api/src/routers/forms.ts
index c7c99b727..bcc0a3260 100644
--- a/packages/api/src/routers/forms.ts
+++ b/packages/api/src/routers/forms.ts
@@ -23,11 +23,16 @@ import {
} from "@forge/db/schemas/knight-hacks";
import { minioClient } from "../minio/minio-client";
-import { adminProcedure, protectedProcedure, publicProcedure } from "../trpc";
-import { generateJsonSchema, log, regenerateMediaUrls } from "../utils";
+import { permProcedure, protectedProcedure } from "../trpc";
+import {
+ controlPerms,
+ generateJsonSchema,
+ log,
+ regenerateMediaUrls,
+} from "../utils";
export const formsRouter = {
- createForm: adminProcedure
+ createForm: permProcedure
.input(
FormSchemaSchema.omit({
id: true,
@@ -38,7 +43,9 @@ export const formsRouter = {
formValidatorJson: true,
}).extend({ formData: FormSchemaValidator }),
)
- .mutation(async ({ input }) => {
+ .mutation(async ({ input, ctx }) => {
+ controlPerms.or(["EDIT_FORMS"], ctx);
+
const jsonSchema = generateJsonSchema(input.formData);
const slug_name = input.formData.name.toLowerCase().replaceAll(" ", "-");
@@ -70,7 +77,7 @@ export const formsRouter = {
});
}),
- updateForm: adminProcedure
+ updateForm: permProcedure
.input(
FormSchemaSchema.omit({
name: true,
@@ -80,7 +87,8 @@ export const formsRouter = {
formValidatorJson: true,
}).extend({ formData: FormSchemaValidator }),
)
- .mutation(async ({ input }) => {
+ .mutation(async ({ input, ctx }) => {
+ controlPerms.or(["EDIT_FORMS"], ctx);
const jsonSchema = generateJsonSchema(input.formData);
console.log(input);
@@ -113,9 +121,10 @@ export const formsRouter = {
});
}),
- getForm: publicProcedure
+ getForm: permProcedure
.input(z.object({ slug_name: z.string() }))
- .query(async ({ input }) => {
+ .query(async ({ input, ctx }) => {
+ controlPerms.or(["READ_FORMS", "EDIT_FORMS"], ctx);
console.log(input);
const form = await db.query.FormsSchemas.findFirst({
where: (t, { eq }) => eq(t.slugName, input.slug_name),
@@ -146,9 +155,10 @@ export const formsRouter = {
};
}),
- deleteForm: adminProcedure
+ deleteForm: permProcedure
.input(z.object({ slug_name: z.string() }))
- .mutation(async ({ input }) => {
+ .mutation(async ({ input, ctx }) => {
+ controlPerms.or(["EDIT_FORMS"], ctx);
// find the form to delete duh
const form = await db.query.FormsSchemas.findFirst({
where: (t, { eq }) => eq(t.slugName, input.slug_name),
@@ -171,14 +181,15 @@ export const formsRouter = {
.returning({ slugName: FormsSchemas.slugName });
}),
- getForms: publicProcedure
+ getForms: permProcedure
.input(
z.object({
limit: z.number().min(1).max(100).default(10),
cursor: z.string().nullish(),
}),
)
- .query(async ({ input }) => {
+ .query(async ({ input, ctx }) => {
+ controlPerms.or(["READ_FORMS", "EDIT_FORMS"], ctx);
const { cursor } = input;
const limit = input.limit;
@@ -208,9 +219,10 @@ export const formsRouter = {
};
}),
- addConnection: adminProcedure
+ addConnection: permProcedure
.input(TrpcFormConnectionSchema)
- .mutation(async ({ input }) => {
+ .mutation(async ({ input, ctx }) => {
+ controlPerms.or(["EDIT_FORMS"], ctx);
try {
await db.insert(TrpcFormConnection).values({ ...input });
} catch {
@@ -221,9 +233,10 @@ export const formsRouter = {
}
}),
- getConnections: protectedProcedure
+ getConnections: permProcedure
.input(z.object({ id: z.string() }))
- .query(async ({ input }) => {
+ .query(async ({ input, ctx }) => {
+ controlPerms.or(["EDIT_FORMS", "READ_FORMS"], ctx);
try {
const connections = db.query.TrpcFormConnection.findMany({
where: (t, { eq }) => eq(t.form, input.id),
@@ -237,9 +250,10 @@ export const formsRouter = {
}
}),
- deleteConnection: adminProcedure
+ deleteConnection: permProcedure
.input(z.object({ id: z.string() }))
- .mutation(async ({ input }) => {
+ .mutation(async ({ input, ctx }) => {
+ controlPerms.or(["EDIT_FORMS"], ctx);
try {
await db
.delete(TrpcFormConnection)
@@ -317,9 +331,10 @@ export const formsRouter = {
});
}),
- getResponses: adminProcedure
+ getResponses: permProcedure
.input(z.object({ form: z.string() }))
- .query(async ({ input }) => {
+ .query(async ({ input, ctx }) => {
+ controlPerms.or(["READ_FORMS", "EDIT_FORMS"], ctx);
return await db
.select({
submittedAt: FormResponse.createdAt,
@@ -407,7 +422,7 @@ export const formsRouter = {
}),
// Generate presigned upload URL for direct MinIO upload
- getUploadUrl: adminProcedure
+ getUploadUrl: permProcedure
.input(
z.object({
fileName: z.string(),
@@ -415,7 +430,8 @@ export const formsRouter = {
mediaType: z.enum(["image", "video"]),
}),
)
- .mutation(async ({ input }) => {
+ .mutation(async ({ input, ctx }) => {
+ controlPerms.or(["EDIT_FORMS"], ctx);
const { fileName, formId, mediaType } = input;
const safeFileName = fileName.replace(/[^a-zA-Z0-9.\-_]/g, "_");
@@ -456,13 +472,14 @@ export const formsRouter = {
}
}),
- deleteMedia: adminProcedure
+ deleteMedia: permProcedure
.input(
z.object({
objectName: z.string(),
}),
)
- .mutation(async ({ input }) => {
+ .mutation(async ({ input, ctx }) => {
+ controlPerms.or(["EDIT_FORMS"], ctx);
const { objectName } = input;
try {
diff --git a/packages/api/src/routers/hacker.ts b/packages/api/src/routers/hacker.ts
index f97d42a0d..319e89fa7 100644
--- a/packages/api/src/routers/hacker.ts
+++ b/packages/api/src/routers/hacker.ts
@@ -26,9 +26,10 @@ import {
} from "@forge/db/schemas/knight-hacks";
import { minioClient } from "../minio/minio-client";
-import { adminProcedure, checkInProcedure, protectedProcedure } from "../trpc";
+import { permProcedure, protectedProcedure } from "../trpc";
import {
addRoleToMember,
+ controlPerms,
isDiscordVIP,
log,
resolveDiscordUserId,
@@ -100,7 +101,11 @@ export const hackerRouter = {
};
}),
- getHackers: adminProcedure.input(z.string()).query(async ({ input }) => {
+ getHackers: permProcedure.input(z.string()).query(async ({ ctx, input }) => {
+ // CHECKIN_HACK_EVENT is here because people trying to check-in
+ // need to retrieve the member list for manual entry
+ controlPerms.or(["READ_HACKERS", "CHECKIN_HACK_EVENT"], ctx);
+
const hackers = await db
.select({
id: Hacker.id,
@@ -145,9 +150,11 @@ export const hackerRouter = {
return hackers;
}),
- getAllHackers: adminProcedure
+ getAllHackers: permProcedure
.input(z.object({ hackathonName: z.string().optional() }))
- .query(async ({ input }) => {
+ .query(async ({ ctx, input }) => {
+ controlPerms.or(["READ_HACKERS", "CHECKIN_HACK_EVENT"], ctx);
+
let hackathon;
if (input.hackathonName) {
@@ -617,7 +624,7 @@ export const hackerRouter = {
});
}),
- giveHackerPoints: adminProcedure
+ giveHackerPoints: permProcedure
.input(
z.object({
id: z.string(),
@@ -626,6 +633,8 @@ export const hackerRouter = {
}),
)
.mutation(async ({ input, ctx }) => {
+ controlPerms.or(["EDIT_HACKERS"], ctx);
+
if (!input.id) {
throw new TRPCError({
message: "Hacker ID is required to update a member's status!",
@@ -686,7 +695,7 @@ export const hackerRouter = {
});
}),
- updateHackerStatus: adminProcedure
+ updateHackerStatus: permProcedure
.input(
z.object({
id: z.string(), // This is the hacker ID
@@ -702,6 +711,8 @@ export const hackerRouter = {
}),
)
.mutation(async ({ input, ctx }) => {
+ controlPerms.or(["EDIT_HACKERS"], ctx);
+
if (!input.id) {
throw new TRPCError({
message: "Hacker ID is required to update a member's status!",
@@ -750,7 +761,7 @@ export const hackerRouter = {
userId: ctx.session.user.discordUserId,
});
}),
- deleteHacker: adminProcedure
+ deleteHacker: permProcedure
.input(
InsertHackerSchema.pick({
id: true,
@@ -761,6 +772,8 @@ export const hackerRouter = {
}),
)
.mutation(async ({ input, ctx }) => {
+ controlPerms.or(["EDIT_HACKERS"], ctx);
+
if (!input.id) {
throw new TRPCError({
message: "Hacker ID is required to delete a member!",
@@ -947,9 +960,11 @@ export const hackerRouter = {
),
);
}),
- statusCountByHackathonId: adminProcedure
+ statusCountByHackathonId: permProcedure
.input(z.string())
- .query(async ({ input: hackathonId }) => {
+ .query(async ({ ctx, input: hackathonId }) => {
+ controlPerms.or(["READ_HACK_DATA"], ctx);
+
const results = await Promise.all(
HACKATHON_APPLICATION_STATES.map(async (s) => {
const rows = await db
@@ -997,7 +1012,7 @@ export const hackerRouter = {
return counts;
}),
- eventCheckIn: checkInProcedure
+ eventCheckIn: permProcedure
.input(
z.object({
userId: z.string(),
@@ -1009,6 +1024,8 @@ export const hackerRouter = {
}),
)
.mutation(async ({ input, ctx }) => {
+ controlPerms.or(["CHECKIN_HACK_EVENT", "EDIT_HACKERS"], ctx);
+
const event = await db.query.Event.findFirst({
where: eq(Event.id, input.eventId),
});
diff --git a/packages/api/src/routers/judge.ts b/packages/api/src/routers/judge.ts
index 84663dad5..9119e8d10 100644
--- a/packages/api/src/routers/judge.ts
+++ b/packages/api/src/routers/judge.ts
@@ -17,7 +17,8 @@ import {
} from "@forge/db/schemas/knight-hacks";
import { env } from "../env";
-import { judgeProcedure, officerProcedure, publicProcedure } from "../trpc";
+import { judgeProcedure, permProcedure, publicProcedure } from "../trpc";
+import { controlPerms } from "../utils";
const SESSION_TTL_HOURS = 8;
@@ -554,7 +555,9 @@ export const judgeRouter = {
}),
// Admin: Get all unique rooms with session counts
- getRoomsWithSessionCounts: officerProcedure.query(async () => {
+ getRoomsWithSessionCounts: permProcedure.query(async ({ ctx }) => {
+ controlPerms.or(["IS_OFFICER"], ctx);
+
const now = new Date();
const rooms = await db
.select({
@@ -570,9 +573,11 @@ export const judgeRouter = {
}),
// Admin: Delete all sessions for a specific room
- deleteSessionsByRoom: officerProcedure
+ deleteSessionsByRoom: permProcedure
.input(z.object({ roomName: z.string() }))
- .mutation(async ({ input }) => {
+ .mutation(async ({ ctx, input }) => {
+ controlPerms.or(["IS_OFFICER"], ctx);
+
const result = await db
.delete(JudgeSession)
.where(eq(JudgeSession.roomName, input.roomName));
diff --git a/packages/api/src/routers/member.ts b/packages/api/src/routers/member.ts
index 7db1df3f3..a45abcc42 100644
--- a/packages/api/src/routers/member.ts
+++ b/packages/api/src/routers/member.ts
@@ -31,13 +31,8 @@ import {
} from "@forge/db/schemas/knight-hacks";
import { minioClient } from "../minio/minio-client";
-import {
- adminProcedure,
- checkInProcedure,
- protectedProcedure,
- publicProcedure,
-} from "../trpc";
-import { log } from "../utils";
+import { permProcedure, protectedProcedure, publicProcedure } from "../trpc";
+import { controlPerms, log } from "../utils";
export const memberRouter = {
createMember: protectedProcedure
@@ -325,15 +320,19 @@ export const memberRouter = {
return events;
}),
- getMembers: adminProcedure.query(
- async () => await db.query.Member.findMany(),
- ),
+ getMembers: permProcedure.query(async ({ ctx }) => {
+ // CHECKIN_CLUB_EVENT is here because people trying to check-in
+ // need to retrieve the member list for manual entry
+ controlPerms.or(["READ_MEMBERS", "CHECKIN_CLUB_EVENT"], ctx);
+
+ return await db.query.Member.findMany();
+ }),
getMemberCount: publicProcedure.query(
async () =>
(await db.select({ count: count() }).from(Member))[0]?.count ?? 0,
),
- giveMemberPoints: adminProcedure
+ giveMemberPoints: permProcedure
.input(
z.object({
id: z.string(),
@@ -341,6 +340,8 @@ export const memberRouter = {
}),
)
.mutation(async ({ input, ctx }) => {
+ controlPerms.or(["EDIT_MEMBERS"], ctx);
+
const member = await db.query.Member.findFirst({
where: eq(Member.id, input.id),
});
@@ -365,22 +366,25 @@ export const memberRouter = {
});
}),
- getDuesPayingMembers: adminProcedure.query(
- async () =>
- await db
- .select()
- .from(Member)
- .where(
- exists(
- db
- .select()
- .from(DuesPayment)
- .where(eq(DuesPayment.memberId, Member.id)),
- ),
+ getDuesPayingMembers: permProcedure.query(async ({ ctx }) => {
+ controlPerms.or(["READ_MEMBERS", "READ_CLUB_DATA"], ctx);
+
+ return await db
+ .select()
+ .from(Member)
+ .where(
+ exists(
+ db
+ .select()
+ .from(DuesPayment)
+ .where(eq(DuesPayment.memberId, Member.id)),
),
- ),
+ );
+ }),
+
+ getMemberAttendanceCounts: permProcedure.query(async ({ ctx }) => {
+ controlPerms.or(["READ_MEMBERS", "READ_CLUB_DATA"], ctx);
- getMemberAttendanceCounts: adminProcedure.query(async () => {
// Get attendance count for each member
const memberAttendance = await db
.select({
@@ -404,9 +408,11 @@ export const memberRouter = {
return memberAttendance;
}),
- createDuesPayingMember: adminProcedure
+ createDuesPayingMember: permProcedure
.input(InsertMemberSchema.pick({ id: true }))
.mutation(async ({ input, ctx }) => {
+ controlPerms.or(["EDIT_MEMBERS", "IS_OFFICER"], ctx);
+
if (!input.id)
throw new TRPCError({
message: "Member ID is required to update dues paying status!",
@@ -430,9 +436,11 @@ export const memberRouter = {
});
}),
- deleteDuesPayingMember: adminProcedure
+ deleteDuesPayingMember: permProcedure
.input(InsertMemberSchema.pick({ id: true }))
.mutation(async ({ input, ctx }) => {
+ controlPerms.or(["EDIT_MEMBERS", "IS_OFFICER"], ctx);
+
if (!input.id)
throw new TRPCError({
message: "Member ID is required to update dues paying status!",
@@ -451,7 +459,9 @@ export const memberRouter = {
});
}),
- clearAllDues: adminProcedure.mutation(async ({ ctx }) => {
+ clearAllDues: permProcedure.mutation(async ({ ctx }) => {
+ controlPerms.or(["IS_OFFICER"], ctx);
+
await db.delete(DuesPayment);
await log({
title: "ALL DUES CLEARED",
@@ -462,7 +472,7 @@ export const memberRouter = {
});
}),
- eventCheckIn: checkInProcedure
+ eventCheckIn: permProcedure
.input(
z.object({
userId: z.string(),
@@ -471,6 +481,8 @@ export const memberRouter = {
}),
)
.mutation(async ({ input, ctx }) => {
+ controlPerms.or(["CHECKIN_CLUB_EVENT", "CHECKIN_HACK_EVENT"], ctx);
+
const member = await db.query.Member.findFirst({
where: eq(Member.userId, input.userId),
});
diff --git a/packages/api/src/routers/roles.ts b/packages/api/src/routers/roles.ts
new file mode 100644
index 000000000..4014ca0a2
--- /dev/null
+++ b/packages/api/src/routers/roles.ts
@@ -0,0 +1,576 @@
+import type { TRPCRouterRecord } from "@trpc/server";
+import type { APIGuildMember, APIRole } from "discord-api-types/v10";
+import { TRPCError } from "@trpc/server";
+import { Routes } from "discord-api-types/v10";
+import { z } from "zod";
+
+import type { PermissionKey } from "@forge/consts/knight-hacks";
+import {
+ DEV_KNIGHTHACKS_GUILD_ID,
+ PERMISSIONS,
+ PROD_KNIGHTHACKS_GUILD_ID,
+} from "@forge/consts/knight-hacks";
+import { eq, inArray, sql } from "@forge/db";
+import { db } from "@forge/db/client";
+import { Permissions, Roles, User } from "@forge/db/schemas/auth";
+
+import { env } from "../env";
+import { permProcedure, protectedProcedure } from "../trpc";
+import {
+ addRoleToMember,
+ controlPerms,
+ discord,
+ getPermsAsList,
+ log,
+ removeRoleFromMember,
+} from "../utils";
+
+const KNIGHTHACKS_GUILD_ID =
+ env.NODE_ENV === "production"
+ ? (PROD_KNIGHTHACKS_GUILD_ID as string)
+ : (DEV_KNIGHTHACKS_GUILD_ID as string);
+
+export const rolesRouter = {
+ // ROLES
+
+ createRoleLink: permProcedure
+ .input(
+ z.object({
+ name: z.string(),
+ roleId: z.string(),
+ permissions: z.string(),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ controlPerms.or(["CONFIGURE_ROLES"], ctx);
+
+ // check for duplicate discord role
+ const dupe = await db.query.Roles.findFirst({
+ where: (t, { eq }) => eq(t.discordRoleId, input.roleId),
+ });
+ if (dupe)
+ throw new TRPCError({
+ message: "This role is already linked.",
+ code: "CONFLICT",
+ });
+
+ // Create the role link first
+ const insertedRoles = await db
+ .insert(Roles)
+ .values({
+ name: input.name,
+ discordRoleId: input.roleId,
+ permissions: input.permissions,
+ })
+ .returning();
+
+ const newRole = insertedRoles[0];
+ if (!newRole) {
+ throw new TRPCError({
+ message: "Failed to create role link.",
+ code: "INTERNAL_SERVER_ERROR",
+ });
+ }
+
+ // Sync existing Blade users who have this Discord role
+ let syncedCount = 0;
+ let checkedCount = 0;
+
+ try {
+ const bladeUsers = await db.select().from(User);
+
+ for (const bladeUser of bladeUsers) {
+ try {
+ const guildMember = (await discord.get(
+ Routes.guildMember(KNIGHTHACKS_GUILD_ID, bladeUser.discordUserId),
+ )) as APIGuildMember;
+
+ checkedCount++;
+
+ if (guildMember.roles.includes(input.roleId)) {
+ const existingPerm = await db.query.Permissions.findFirst({
+ where: (t, { eq, and }) =>
+ and(eq(t.userId, bladeUser.id), eq(t.roleId, newRole.id)),
+ });
+
+ if (!existingPerm) {
+ await db.insert(Permissions).values({
+ roleId: newRole.id,
+ userId: bladeUser.id,
+ });
+ syncedCount++;
+ }
+ }
+ } catch {
+ continue;
+ }
+ }
+
+ await log({
+ title: `Created Role: ${input.name}`,
+ message: `Role linked to <@&${input.roleId}>
+ \n**Permissions:** ${getPermsAsList(input.permissions).join(", ")}
+ \n**Auto-synced:** ${syncedCount} user(s) granted (checked ${checkedCount} Blade users)`,
+ color: "blade_purple",
+ userId: ctx.session.user.discordUserId,
+ });
+ } catch {
+ await log({
+ title: `Created Role: ${input.name}`,
+ message: `Role linked to <@&${input.roleId}>
+ \n**Permissions:** ${getPermsAsList(input.permissions).join(", ")}
+ \n**Note:** Auto-sync unavailable. Checked ${checkedCount} users, synced ${syncedCount}.`,
+ color: "blade_purple",
+ userId: ctx.session.user.discordUserId,
+ });
+ }
+ }),
+
+ updateRoleLink: permProcedure
+ .input(
+ z.object({
+ name: z.string(),
+ id: z.string(),
+ roleId: z.string(),
+ permissions: z.string(),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ controlPerms.or(["CONFIGURE_ROLES"], ctx);
+
+ // check for existing role
+ const exist = await db.query.Roles.findFirst({
+ where: (t, { eq }) => eq(t.id, input.id),
+ });
+ if (!exist)
+ throw new TRPCError({
+ message: "Tried to edit a role link that does not exist.",
+ code: "BAD_REQUEST",
+ });
+
+ // check for duplicate discord role
+ const dupe = await db.query.Roles.findFirst({
+ where: (t, { and, eq, not }) =>
+ and(not(eq(t.id, input.id)), eq(t.discordRoleId, input.roleId)),
+ });
+ if (dupe)
+ throw new TRPCError({
+ message: "This role is already linked.",
+ code: "CONFLICT",
+ });
+
+ await db
+ .update(Roles)
+ .set({
+ name: input.name,
+ discordRoleId: input.roleId,
+ permissions: input.permissions,
+ })
+ .where(eq(Roles.id, input.id));
+
+ await log({
+ title: `Updated Role`,
+ message: `The **${exist.name}** Role (<@&${input.roleId}>) role has been updated.
+ \n**Name:** ${exist.name} -> ${input.name}
+ \n**Original Perms:**\n${getPermsAsList(exist.permissions).join("\n")}
+ \n**New Perms:**\n${getPermsAsList(input.permissions).join("\n")}`,
+ color: "blade_purple",
+ userId: ctx.session.user.discordUserId,
+ });
+ }),
+
+ deleteRoleLink: permProcedure
+ .input(z.object({ id: z.string() }))
+ .mutation(async ({ ctx, input }) => {
+ controlPerms.or(["CONFIGURE_ROLES"], ctx);
+
+ // check for existing role
+ const exist = await db.query.Roles.findFirst({
+ where: (t, { eq }) => eq(t.id, input.id),
+ });
+ if (!exist)
+ throw new TRPCError({
+ message: "Tried to delete a role link that does not exist.",
+ code: "BAD_REQUEST",
+ });
+
+ await db.delete(Roles).where(eq(Roles.id, input.id));
+
+ await log({
+ title: `Deleted Role`,
+ message: `The **${exist.name}** Role (<@&${exist.discordRoleId}>) role has been deleted.`,
+ color: "uhoh_red",
+ userId: ctx.session.user.discordUserId,
+ });
+ }),
+
+ getRoleLink: protectedProcedure
+ .input(z.object({ id: z.string() }))
+ .query(async ({ input }) => {
+ return await db.query.Roles.findFirst({
+ where: (t, { eq }) => eq(t.id, input.id),
+ });
+ }),
+
+ getAllLinks: protectedProcedure.query(async () => {
+ return await db.select().from(Roles);
+ }),
+
+ getDiscordRole: protectedProcedure
+ .input(z.object({ roleId: z.string() }))
+ .query(async ({ input }): Promise => {
+ try {
+ return (await discord.get(
+ Routes.guildRole(KNIGHTHACKS_GUILD_ID, input.roleId),
+ )) as APIRole | null;
+ } catch {
+ return null;
+ }
+ }),
+
+ getDiscordRoles: protectedProcedure
+ .input(
+ z.object({ roles: z.array(z.object({ discordRoleId: z.string() })) }),
+ )
+ .query(async ({ input }): Promise<(APIRole | null)[]> => {
+ const ret = [];
+
+ for (const r of input.roles) {
+ try {
+ ret.push(
+ (await discord.get(
+ Routes.guildRole(KNIGHTHACKS_GUILD_ID, r.discordRoleId),
+ )) as APIRole | null,
+ );
+ } catch {
+ ret.push(null);
+ }
+ }
+
+ return ret;
+ }),
+
+ getDiscordRoleCounts: protectedProcedure.query(
+ async (): Promise | null> => {
+ return (await discord.get(
+ `/guilds/${KNIGHTHACKS_GUILD_ID}/roles/member-counts`,
+ )) as Record;
+ },
+ ),
+
+ // PERMS
+
+ // returnes the bitwise OR'd permissions for the given user
+ // if no user is passed, get the current context user
+ getPermissions: protectedProcedure
+ .input(z.optional(z.object({ userId: z.string() })))
+ .query(async ({ ctx, input }) => {
+ const permRows = await db
+ .select({
+ permissions: Roles.permissions,
+ })
+ .from(Roles)
+ .innerJoin(Permissions, eq(Roles.id, Permissions.roleId))
+ .where(
+ sql`cast(${Permissions.userId} as text) = ${input ? input.userId : ctx.session.user.id}`,
+ );
+
+ const permissionsBits = new Array(Object.keys(PERMISSIONS).length).fill(
+ false,
+ ) as boolean[];
+
+ permRows.forEach((v) => {
+ for (let i = 0; i < v.permissions.length; i++) {
+ if (v.permissions.at(i) == "1") permissionsBits[i] = true;
+ }
+ });
+
+ const permissionsMap = Object.keys(PERMISSIONS).reduce(
+ (accumulator, key) => {
+ const index = PERMISSIONS[key];
+ if (index === undefined) return accumulator;
+ accumulator[key] = permissionsBits[index] ?? false;
+
+ return accumulator;
+ },
+ {} as Record,
+ );
+
+ return permissionsMap;
+ }),
+
+ hasPermission: permProcedure
+ .input(
+ z.object({
+ and: z.optional(z.array(z.string())),
+ or: z.optional(z.array(z.string())),
+ }),
+ )
+ .query(({ input, ctx }) => {
+ try {
+ if (input.or) controlPerms.or(input.or, ctx);
+ if (input.and) controlPerms.and(input.and, ctx);
+ } catch {
+ return false;
+ }
+
+ return true;
+ }),
+
+ grantPermission: permProcedure
+ .input(z.object({ roleId: z.string(), userId: z.string() }))
+ .mutation(async ({ input, ctx }) => {
+ controlPerms.or(["ASSIGN_ROLES"], ctx);
+
+ const exists = await db.query.Permissions.findFirst({
+ where: (t, { eq, and }) =>
+ and(eq(t.userId, input.userId), eq(t.roleId, input.roleId)),
+ });
+
+ if (exists)
+ throw new TRPCError({
+ code: "CONFLICT",
+ message: "This permission relation already exists.",
+ });
+
+ const user = await db.query.User.findFirst({
+ where: (t, { eq }) => eq(t.id, input.userId),
+ });
+
+ const role = await db.query.Roles.findFirst({
+ where: (t, { eq }) => eq(t.id, input.roleId),
+ });
+
+ if (!user || !role) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "User or role not found.",
+ });
+ }
+
+ // Add the role to the user on Discord
+ // Note: This may fail due to role hierarchy or bot permissions
+ // We log the error but don't break the flow - Blade permission is still granted
+ try {
+ await addRoleToMember(user.discordUserId, role.discordRoleId);
+ console.log(
+ `Successfully added Discord role ${role.discordRoleId} to user ${user.discordUserId}`,
+ );
+ } catch (error) {
+ console.error(
+ `Failed to add Discord role ${role.discordRoleId} to user ${user.discordUserId}:`,
+ error,
+ );
+ console.error(
+ ` This may be due to role hierarchy or bot permissions. Blade permission will still be granted.`,
+ );
+ }
+
+ await db.insert(Permissions).values({
+ roleId: input.roleId,
+ userId: input.userId,
+ });
+
+ await log({
+ title: `Granted Role`,
+ message: `The **${role.name}** role (<@&${role.discordRoleId}>) has been granted to <@${user.discordUserId}>.`,
+ color: "success_green",
+ userId: ctx.session.user.discordUserId,
+ });
+ }),
+
+ revokePermission: permProcedure
+ .input(z.object({ roleId: z.string(), userId: z.string() }))
+ .mutation(async ({ input, ctx }) => {
+ controlPerms.or(["ASSIGN_ROLES"], ctx);
+
+ const perm = await db.query.Permissions.findFirst({
+ where: (t, { eq, and }) =>
+ and(eq(t.userId, input.userId), eq(t.roleId, input.roleId)),
+ });
+
+ if (!perm)
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message:
+ "The permission relation you are trying to revoke does not exist.",
+ });
+
+ const user = await db.query.User.findFirst({
+ where: (t, { eq }) => eq(t.id, input.userId),
+ });
+
+ const role = await db.query.Roles.findFirst({
+ where: (t, { eq }) => eq(t.id, input.roleId),
+ });
+
+ if (!user || !role) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "User or role not found.",
+ });
+ }
+
+ // Remove the role from the user on Discord
+ // Note: This may fail due to role hierarchy or bot permissions
+ // We log the error but don't break the flow - Blade permission is still revoked
+ try {
+ await removeRoleFromMember(user.discordUserId, role.discordRoleId);
+ console.log(
+ `✅ Successfully removed Discord role ${role.discordRoleId} from user ${user.discordUserId}`,
+ );
+ } catch (error) {
+ console.error(
+ `Failed to remove Discord role ${role.discordRoleId} from user ${user.discordUserId}:`,
+ error,
+ );
+ console.error(
+ ` This may be due to role hierarchy or bot permissions. Blade permission will still be revoked.`,
+ );
+ }
+
+ await db.delete(Permissions).where(eq(Permissions.id, perm.id));
+
+ await log({
+ title: `Revoked Role`,
+ message: `The **${role.name}** role (<@&${role.discordRoleId}>) has been revoked from <@${user.discordUserId}>.`,
+ color: "uhoh_red",
+ userId: ctx.session.user.discordUserId,
+ });
+ }),
+
+ batchManagePermission: permProcedure
+ .input(
+ z.object({
+ roleIds: z.array(z.string()),
+ userIds: z.array(z.string()),
+ revoking: z.boolean(),
+ }),
+ )
+ .mutation(async ({ input, ctx }) => {
+ controlPerms.or(["ASSIGN_ROLES"], ctx);
+
+ interface Return {
+ roleName: string;
+ userName: string;
+ }
+ const failed: Return[] = [];
+ const succeeded: Return[] = [];
+
+ // Cache users with full data for Discord operations
+ const cachedUsers: Record<
+ string,
+ { name: string; discordUserId: string }
+ > = {};
+ const dbUsers = await db
+ .select()
+ .from(User)
+ .where(inArray(User.id, input.userIds));
+ dbUsers.forEach((v) => {
+ cachedUsers[v.id] = {
+ name: v.name ?? "",
+ discordUserId: v.discordUserId,
+ };
+ });
+
+ // Cache roles with full data for Discord operations
+ const cachedRoles: Record<
+ string,
+ { name: string; discordRoleId: string }
+ > = {};
+ const dbRoles = await db
+ .select()
+ .from(Roles)
+ .where(inArray(Roles.id, input.roleIds));
+ dbRoles.forEach((v) => {
+ cachedRoles[v.id] = { name: v.name, discordRoleId: v.discordRoleId };
+ });
+
+ for (const [roleId, roleData] of Object.entries(cachedRoles)) {
+ for (const [userId, userData] of Object.entries(cachedUsers)) {
+ const perm = await db.query.Permissions.findFirst({
+ where: (t, { eq, and }) =>
+ and(eq(t.userId, userId), eq(t.roleId, roleId)),
+ });
+
+ const ret = { roleName: roleData.name, userName: userData.name };
+
+ if (!perm == input.revoking) {
+ failed.push(ret);
+ } else {
+ try {
+ if (!input.revoking) {
+ // Granting role - Discord may fail due to hierarchy/perms
+ try {
+ await addRoleToMember(
+ userData.discordUserId,
+ roleData.discordRoleId,
+ );
+ } catch (discordError) {
+ console.error(
+ `Discord role grant failed for ${userData.name} -> ${roleData.name}:`,
+ discordError,
+ );
+ }
+ await db.insert(Permissions).values({
+ roleId: roleId,
+ userId: userId,
+ });
+ succeeded.push(ret);
+ } else if (perm) {
+ // Revoking role - Discord may fail due to hierarchy/perms
+ try {
+ await removeRoleFromMember(
+ userData.discordUserId,
+ roleData.discordRoleId,
+ );
+ } catch (discordError) {
+ console.error(
+ `Discord role revoke failed for ${userData.name} -> ${roleData.name}:`,
+ discordError,
+ );
+ }
+ await db.delete(Permissions).where(eq(Permissions.id, perm.id));
+ succeeded.push(ret);
+ } else {
+ failed.push(ret);
+ }
+ } catch (error) {
+ // This catches DB errors only (Discord errors are caught above)
+ console.error(
+ `Database error for ${input.revoking ? "revoke" : "grant"} role ${roleData.name} ${input.revoking ? "from" : "to"} ${userData.name}:`,
+ error,
+ );
+ failed.push(ret);
+ }
+ }
+ }
+ }
+
+ const failText =
+ failed.length > 0
+ ? "\n**Failed:**\n" +
+ failed.map((v) => `${v.userName} -> ${v.roleName}`).join("\n")
+ : "";
+
+ await log({
+ title: `${input.revoking ? "Revoked" : "Granted"} Batch Roles`,
+ message:
+ `The following roles have been ${input.revoking ? "revoked from" : "granted to"} the following users:\n\n` +
+ (succeeded.length > 0
+ ? succeeded.map((v) => `${v.userName} -> ${v.roleName}`).join("\n")
+ : "None") +
+ failText,
+ color: input.revoking ? "uhoh_red" : "success_green",
+ userId: ctx.session.user.discordUserId,
+ });
+
+ // Only throw error for database failures (Discord failures are logged but don't break)
+ if (failed.length > 0) {
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: `Database error: Failed to ${input.revoking ? "revoke" : "grant"} ${failed.length} role(s): ${failed.map((v) => `${v.roleName}`).join(", ")}`,
+ });
+ }
+ }),
+} satisfies TRPCRouterRecord;
diff --git a/packages/api/src/routers/user.ts b/packages/api/src/routers/user.ts
index d0d217bcd..d66cc6254 100644
--- a/packages/api/src/routers/user.ts
+++ b/packages/api/src/routers/user.ts
@@ -1,6 +1,28 @@
import type { TRPCRouterRecord } from "@trpc/server";
-import { protectedProcedure } from "../trpc";
+import { db } from "@forge/db/client";
+
+import { permProcedure, protectedProcedure } from "../trpc";
+import { controlPerms } from "../utils";
+
+// // helper schema to check if a value is either of type PermissionKey or PermissionIndex
+// // z.custom doesn't perform any validation by itself, so it will let any type at runtime
+// const PermissionInputSchema = z.custom(
+// (value) => {
+// // check if it's a valid number index
+// if (typeof value === "number") {
+// // check if the number exists as a value in PERMISSIONS object
+// return (Object.values(PERMISSIONS) as number[]).includes(value);
+// }
+
+// // check if it's a valid string key
+// if (typeof value === "string") {
+// return value in PERMISSIONS;
+// }
+
+// return false;
+// },
+// );
export const userRouter = {
getUserAvatar: protectedProcedure.query(({ ctx }) => {
@@ -14,4 +36,16 @@ export const userRouter = {
}
return { avatar: avatarUrl, name: ctx.session.user.name };
}),
+
+ // Also appends roles to returned users
+ getUsers: permProcedure.query(async ({ ctx }) => {
+ controlPerms.or(["CONFIGURE_ROLES"], ctx);
+ const users = await db.query.User.findMany({
+ with: {
+ permissions: true,
+ },
+ });
+
+ return users;
+ }),
} satisfies TRPCRouterRecord;
diff --git a/packages/api/src/trpc.ts b/packages/api/src/trpc.ts
index 8f6a18c11..c3d95b9a3 100644
--- a/packages/api/src/trpc.ts
+++ b/packages/api/src/trpc.ts
@@ -12,14 +12,15 @@ import { ZodError } from "zod";
import type { Session } from "@forge/auth/server";
import { validateToken } from "@forge/auth/server";
+import { PermissionKey, PERMISSIONS } from "@forge/consts/knight-hacks";
+import { eq, sql } from "@forge/db";
+import { db } from "@forge/db/client";
+import { Permissions, Roles } from "@forge/db/schemas/auth";
import {
getJudgeSessionFromCookie,
isDiscordAdmin,
isJudgeAdmin,
- userHasCheckIn,
- userHasFullAdmin,
- userIsOfficer,
} from "./utils";
/**
@@ -141,65 +142,44 @@ export const protectedProcedure = t.procedure
});
});
-export const adminProcedure = protectedProcedure.use(async ({ ctx, next }) => {
- const isValidAdmin = await isDiscordAdmin(ctx.session.user);
- if (!isValidAdmin) {
- throw new TRPCError({ code: "UNAUTHORIZED" });
- }
+export const permProcedure = protectedProcedure.use(async ({ ctx, next }) => {
+ const permRows = await db
+ .select({
+ permissions: Roles.permissions,
+ })
+ .from(Roles)
+ .innerJoin(Permissions, eq(Roles.id, Permissions.roleId))
+ .where(sql`cast(${Permissions.userId} as text) = ${ctx.session.user.id}`);
+
+ const permissionsBits = new Array(Object.keys(PERMISSIONS).length).fill(
+ false,
+ ) as boolean[];
+
+ permRows.forEach((v) => {
+ for (let i = 0; i < v.permissions.length; i++) {
+ if (v.permissions.at(i) == "1") permissionsBits[i] = true;
+ }
+ });
+
+ const permissionsMap = Object.keys(PERMISSIONS).reduce(
+ (accumulator, key) => {
+ const index = PERMISSIONS[key];
+ if (index === undefined) return accumulator;
+ accumulator[key] = permissionsBits[index] ?? false;
+
+ return accumulator;
+ },
+ {} as Record,
+ );
return next({
ctx: {
// infers the `session` as non-nullable
- session: { ...ctx.session, user: ctx.session.user },
+ session: { ...ctx.session, permissions: permissionsMap },
},
});
});
-export const fullAdminProcedure = protectedProcedure.use(
- async ({ ctx, next }) => {
- const hasFullAdmin = await userHasFullAdmin(ctx.session.user);
- if (!hasFullAdmin) {
- throw new TRPCError({ code: "UNAUTHORIZED" });
- }
- return next({
- ctx: {
- // infers the `session` as non-nullable
- session: { ...ctx.session, user: ctx.session.user },
- },
- });
- },
-);
-
-export const checkInProcedure = protectedProcedure.use(
- async ({ ctx, next }) => {
- const hasCheckIn = await userHasCheckIn(ctx.session.user);
- if (!hasCheckIn) {
- throw new TRPCError({ code: "UNAUTHORIZED" });
- }
- return next({
- ctx: {
- // infers the `session` as non-nullable
- session: { ...ctx.session, user: ctx.session.user },
- },
- });
- },
-);
-
-export const officerProcedure = protectedProcedure.use(
- async ({ ctx, next }) => {
- const isOfficer = await userIsOfficer(ctx.session.user);
- if (!isOfficer) {
- throw new TRPCError({ code: "UNAUTHORIZED" });
- }
- return next({
- ctx: {
- // infers the `session` as non-nullable
- session: { ...ctx.session, user: ctx.session.user },
- },
- });
- },
-);
-
export const judgeProcedure = publicProcedure.use(async ({ ctx, next }) => {
let isAdmin;
if (ctx.session) {
diff --git a/packages/api/src/utils.ts b/packages/api/src/utils.ts
index e8bea3349..83bd53285 100644
--- a/packages/api/src/utils.ts
+++ b/packages/api/src/utils.ts
@@ -2,8 +2,9 @@ import type { APIGuildMember } from "discord-api-types/v10";
import type { JSONSchema7 } from "json-schema";
import { cookies } from "next/headers";
import { REST } from "@discordjs/rest";
+import { TRPCError } from "@trpc/server";
import { Routes } from "discord-api-types/v10";
-import { and, eq, gt } from "drizzle-orm";
+import { and, eq, gt, inArray } from "drizzle-orm";
import { Resend } from "resend";
import Stripe from "stripe";
@@ -11,6 +12,7 @@ import type { Session } from "@forge/auth/server";
import type {
FormType,
PermissionIndex,
+ PermissionKey,
ValidatorOptions,
} from "@forge/consts/knight-hacks";
import {
@@ -19,16 +21,15 @@ import {
DEV_KNIGHTHACKS_LOG_CHANNEL,
FORM_ASSETS_BUCKET,
IS_PROD,
- OFFICER_ROLE_ID,
+ PERMISSION_DATA,
PERMISSIONS,
PRESIGNED_URL_EXPIRY,
PROD_DISCORD_ADMIN_ROLE_ID,
PROD_KNIGHTHACKS_GUILD_ID,
PROD_KNIGHTHACKS_LOG_CHANNEL,
- ROLE_PERMISSIONS,
} from "@forge/consts/knight-hacks";
import { db } from "@forge/db/client";
-import { JudgeSession } from "@forge/db/schemas/auth";
+import { JudgeSession, Roles } from "@forge/db/schemas/auth";
import { env } from "./env";
import { minioClient } from "./minio/minio-client";
@@ -37,7 +38,7 @@ const DISCORD_ADMIN_ROLE_ID = IS_PROD
? (PROD_DISCORD_ADMIN_ROLE_ID as string)
: (DEV_DISCORD_ADMIN_ROLE_ID as string);
-const KNIGHTHACKS_GUILD_ID = IS_PROD
+export const KNIGHTHACKS_GUILD_ID = IS_PROD
? (PROD_KNIGHTHACKS_GUILD_ID as string)
: (DEV_KNIGHTHACKS_GUILD_ID as string);
@@ -50,9 +51,7 @@ export const discord = new REST({ version: "10" }).setToken(
const GUILD_ID = IS_PROD ? PROD_KNIGHTHACKS_GUILD_ID : DEV_KNIGHTHACKS_GUILD_ID;
export async function addRoleToMember(discordUserId: string, roleId: string) {
- await discord.put(Routes.guildMemberRole(GUILD_ID, discordUserId, roleId), {
- body: {},
- });
+ await discord.put(Routes.guildMemberRole(GUILD_ID, discordUserId, roleId));
}
export async function removeRoleFromMember(
@@ -89,18 +88,6 @@ export const isDiscordAdmin = async (user: Session["user"]) => {
}
};
-export const userIsOfficer = async (user: Session["user"]) => {
- try {
- const guildMember = (await discord.get(
- Routes.guildMember(KNIGHTHACKS_GUILD_ID, user.discordUserId),
- )) as APIGuildMember;
- return guildMember.roles.includes(OFFICER_ROLE_ID);
- } catch (err) {
- console.error("Error: ", err);
- return false;
- }
-};
-
export const hasPermission = (
userPermissions: string,
permission: PermissionIndex,
@@ -109,57 +96,85 @@ export const hasPermission = (
return permissionBit === "1";
};
-export const getUserPermissions = async (
- user: Session["user"],
-): Promise => {
- try {
- const guildMember = (await discord.get(
- Routes.guildMember(KNIGHTHACKS_GUILD_ID, user.discordUserId),
- )) as APIGuildMember;
-
- const userPermissionArray = new Array(Object.keys(PERMISSIONS).length).fill(
- "0",
- );
+export const parsePermissions = async (discordUserId: string) => {
+ const guildMember = (await discord.get(
+ Routes.guildMember(KNIGHTHACKS_GUILD_ID, discordUserId),
+ )) as APIGuildMember;
- for (const roleId of guildMember.roles) {
- if (roleId in ROLE_PERMISSIONS) {
- const permissionIndex = ROLE_PERMISSIONS[roleId];
- if (permissionIndex !== undefined) {
- userPermissionArray[permissionIndex] = "1";
+ const permissionsLength = Object.keys(PERMISSIONS).length;
+
+ // array of booleans. the boolean value at the index indicates if the user has that permission.
+ // true means the user has the permission, false means the user doesn't have the permission.
+ const permissionsBits = new Array(permissionsLength).fill(false) as boolean[];
+
+ if (guildMember.roles.length > 0) {
+ // get only roles the user has
+ const userDbRoles = await db
+ .select()
+ .from(Roles)
+ .where(inArray(Roles.discordRoleId, guildMember.roles));
+
+ for (const role of userDbRoles) {
+ if (!role.permissions) continue;
+
+ for (
+ let i = 0;
+ i < role.permissions.length && i < permissionsLength;
+ ++i
+ ) {
+ if (role.permissions[i] === "1") {
+ permissionsBits[i] = true;
}
}
}
-
- return userPermissionArray.join("");
- } catch (err) {
- console.error("Error getting user permissions: ", err);
- return "0".repeat(Object.keys(PERMISSIONS).length);
}
+
+ // creates the map of permissions to their boolean values
+ const permissionsMap = Object.keys(PERMISSIONS).reduce(
+ (accumulator, key) => {
+ const index = PERMISSIONS[key];
+ if (index === undefined) return accumulator;
+
+ accumulator[key] = permissionsBits[index] ?? false;
+
+ return accumulator;
+ },
+ {} as Record,
+ );
+
+ return permissionsMap;
};
-export const userHasPermission = async (
- user: Session["user"],
- permission: PermissionIndex,
-): Promise => {
- const userPermissions = await getUserPermissions(user);
+// Mock tRPC context for type-safety
+interface Context {
+ session: {
+ permissions: Record;
+ };
+}
+
+export const controlPerms = {
+ // Returns true if the user has any required permission OR has isOfficer role
+ or: (perms: PermissionKey[], ctx: Context) => {
+ // first check if user has IS_OFFICER
+ if (ctx.session.permissions.IS_OFFICER) return true;
- if (hasPermission(userPermissions, PERMISSIONS.FULL_ADMIN)) {
+ let flag = false;
+ for (const p of perms) if (ctx.session.permissions[p]) flag = true;
+ if (!flag) throw new TRPCError({ code: "UNAUTHORIZED" });
return true;
- }
+ },
- return hasPermission(userPermissions, permission);
-};
+ // Returns true only if the user has ALL required permissions
+ and: (perms: PermissionKey[], ctx: Context) => {
+ // first check if user has IS_OFFICER
+ if (ctx.session.permissions.IS_OFFICER) return true;
-export const userHasFullAdmin = async (
- user: Session["user"],
-): Promise => {
- return userHasPermission(user, PERMISSIONS.FULL_ADMIN);
-};
+ for (const p of perms)
+ if (!ctx.session.permissions[p])
+ throw new TRPCError({ code: "UNAUTHORIZED" });
-export const userHasCheckIn = async (
- user: Session["user"],
-): Promise => {
- return userHasPermission(user, PERMISSIONS.CHECK_IN);
+ return true;
+ },
};
export const isDiscordMember = async (user: Session["user"]) => {
@@ -472,3 +487,16 @@ export async function regenerateMediaUrls(
return updatedQuestions;
}
+
+export function getPermsAsList(perms: string) {
+ const list = [];
+ const permKeys = Object.keys(PERMISSIONS);
+ for (let i = 0; i < perms.length; i++) {
+ const permKey = permKeys.at(i);
+ if (perms[i] == "1" && permKey) {
+ const permissionData = PERMISSION_DATA[permKey];
+ if (permissionData) list.push(permissionData.name);
+ }
+ }
+ return list;
+}
diff --git a/packages/consts/src/knight-hacks.ts b/packages/consts/src/knight-hacks.ts
index c128ddec0..bb880f19e 100644
--- a/packages/consts/src/knight-hacks.ts
+++ b/packages/consts/src/knight-hacks.ts
@@ -254,17 +254,123 @@ export const DEV_DISCORD_ADMIN_ROLE_ID = "1321955700540309645";
export const PROD_DISCORD_VOLUNTEER_ROLE_ID = "1415505872360312974";
export const DEV_DISCORD_VOLUNTEER_ROLE_ID = "1426947077514203279";
-export const PERMISSIONS = {
- FULL_ADMIN: 0,
- CHECK_IN: 1,
- // Future permissions will be added here with incremental indices
- // EVENTS_MANAGE: 2,
- // MEMBERS_MANAGE: 3,
- // etc.
-} as const;
+export interface PermissionDataObj {
+ idx: number;
+ name: string;
+ desc: string;
+}
-export type PermissionKey = keyof typeof PERMISSIONS;
-export type PermissionIndex = (typeof PERMISSIONS)[PermissionKey];
+export const PERMISSION_DATA: Record = {
+ IS_OFFICER: {
+ idx: 0,
+ name: "Is Officer",
+ desc: "Grants access to sensitive club officer pages.",
+ },
+ IS_JUDGE: {
+ idx: 1,
+ name: "Is Judge",
+ desc: "Grants access to the judging system.",
+ },
+ READ_MEMBERS: {
+ idx: 2,
+ name: "Read Members",
+ desc: "Grants access to the list of club members.",
+ },
+ EDIT_MEMBERS: {
+ idx: 3,
+ name: "Edit Members",
+ desc: "Allows editing member data, including deletion.",
+ },
+ READ_HACKERS: {
+ idx: 4,
+ name: "Read Hackers",
+ desc: "Grants access to the list of hackers, and their hackathons.",
+ },
+ EDIT_HACKERS: {
+ idx: 5,
+ name: "Edit Hackers",
+ desc: "Allows editing hacker data, including approval, rejection, deletion, etc.",
+ },
+ READ_CLUB_DATA: {
+ idx: 6,
+ name: "Read Club Data",
+ desc: "Grants access to club statistics, such as demographics.",
+ },
+ READ_HACK_DATA: {
+ idx: 7,
+ name: "Read Hackathon Data",
+ desc: "Grants access to hackathon statistics, such as demographics.",
+ },
+ READ_CLUB_EVENT: {
+ idx: 8,
+ name: "Read Club Events",
+ desc: "Grants access to club event data, such as attendance.",
+ },
+ EDIT_CLUB_EVENT: {
+ idx: 9,
+ name: "Edit Club Events",
+ desc: "Allows creating, editing, or deleting club events.",
+ },
+ CHECKIN_CLUB_EVENT: {
+ idx: 10,
+ name: "Club Event Check-in",
+ desc: "Allows the user to check members into club events.",
+ },
+ READ_HACK_EVENT: {
+ idx: 11,
+ name: "Read Hackathon Events",
+ desc: "Grants access to hackathon event data, such as attendance.",
+ },
+ EDIT_HACK_EVENT: {
+ idx: 12,
+ name: "Edit Hackathon Events",
+ desc: "Allows creating, editing, or deleting hackathon events.",
+ },
+ CHECKIN_HACK_EVENT: {
+ idx: 13,
+ name: "Hackathon Event Check-in",
+ desc: "Allows the user to check hackers into hackathon events, including the primary check-in.",
+ },
+ EMAIL_PORTAL: {
+ idx: 14,
+ name: "Email Portal",
+ desc: "Grants access to the email queue portal.",
+ },
+ READ_FORMS: {
+ idx: 15,
+ name: "Read Forms",
+ desc: "Grants access to created forms, but not their responses.",
+ },
+ READ_FORM_RESPONSES: {
+ idx: 16,
+ name: "Read Form Responses",
+ desc: "Grants access to form responses.",
+ },
+ EDIT_FORMS: {
+ idx: 17,
+ name: "Edit Forms",
+ desc: "Allows creating, editing, or deleting forms.",
+ },
+ ASSIGN_ROLES: {
+ idx: 18,
+ name: "Assign Roles",
+ desc: "Allows assigning or removing roles to Blade users.",
+ },
+ CONFIGURE_ROLES: {
+ idx: 19,
+ name: "Configure Roles",
+ desc: "Allows creating, editing, or deleting roles.",
+ },
+} as const satisfies Record;
+
+export const PERMISSIONS = Object.fromEntries(
+ Object.entries(PERMISSION_DATA).map(([key, value]) => [key, value.idx]),
+) as {
+ [K in keyof typeof PERMISSION_DATA]: (typeof PERMISSION_DATA)[K]["idx"];
+};
+
+export type PermissionKey = keyof typeof PERMISSION_DATA;
+export type PermissionIndex = (typeof PERMISSION_DATA)[PermissionKey]["idx"];
export const PROD_KNIGHTHACKS_GUILD_ID = "486628710443778071";
export const DEV_KNIGHTHACKS_GUILD_ID = "1151877367434850364";
@@ -293,6 +399,9 @@ export const DEV_DISCORD_ROLE_MONSTOLOGIST = "1420819295759237222";
export const PROD_DISCORD_ROLE_ALCHEMIST = "1415702383274491934";
export const DEV_DISCORD_ROLE_ALCHEMIST = "1420819309965611140";
+export const PROD_DISCORD_SUPERADMIN = "486629374758748180";
+export const DEV_DISCORD_SUPERADMIN = "1246637685011906560";
+
export const IS_PROD = process.env.NODE_ENV === "production";
export const KH_EVENT_ROLE_ID = IS_PROD
@@ -311,13 +420,6 @@ export const CLASS_ROLE_ID: Record = {
Alchemist: IS_PROD ? PROD_DISCORD_ROLE_ALCHEMIST : DEV_DISCORD_ROLE_ALCHEMIST,
} as const satisfies Record;
-export const ROLE_PERMISSIONS: Record = {
- [IS_PROD ? PROD_DISCORD_ADMIN_ROLE_ID : DEV_DISCORD_ADMIN_ROLE_ID]:
- PERMISSIONS.FULL_ADMIN,
- [IS_PROD ? PROD_DISCORD_VOLUNTEER_ROLE_ID : DEV_DISCORD_VOLUNTEER_ROLE_ID]:
- PERMISSIONS.CHECK_IN,
-};
-
export const MEMBER_PROFILE_ICON_SIZE = 24;
export const EVENT_FEEDBACK_SLIDER_MINIMUM = 1;
diff --git a/packages/db/scripts/bootstrap-superadmin.ts b/packages/db/scripts/bootstrap-superadmin.ts
new file mode 100644
index 000000000..690178e1d
--- /dev/null
+++ b/packages/db/scripts/bootstrap-superadmin.ts
@@ -0,0 +1,159 @@
+/* eslint-disable no-console */
+/**
+ * ONE-TIME BOOTSTRAP SCRIPT
+// This script creates a superadmin role with all permissions and assigns it to a user.
+// Use this to bootstrap the first admin user who can then manage roles through the UI.
+// Usage:
+// pnpm --filter @forge/db with-env tsx scripts/bootstrap-superadmin.ts
+// Example:
+// pnpm --filter @forge/db with-env tsx scripts/bootstrap-superadmin.ts 1321955700540309645 238081392481665025
+// Arguments:
+// discord-role-id: The Discord role ID to link to (e.g., an Admin role in your Discord server)
+// discord-user-id: The Discord user ID of the person to grant superadmin access
+*/
+import { eq } from "drizzle-orm";
+
+import { PERMISSIONS } from "@forge/consts/knight-hacks";
+
+import { db } from "../src/client";
+import { Permissions, Roles } from "../src/schemas/auth";
+
+async function bootstrapSuperadmin() {
+ const args = process.argv.slice(2);
+
+ if (args.length !== 2) {
+ console.error("Error: Invalid arguments");
+ console.error("\nUsage:");
+ console.error(
+ " pnpm --filter @forge/db with-env tsx scripts/bootstrap-superadmin.ts ",
+ );
+ console.error("\nExample:");
+ console.error(
+ " pnpm --filter @forge/db with-env tsx scripts/bootstrap-superadmin.ts 1321955700540309645 238081392481665025",
+ );
+ process.exit(1);
+ }
+
+ const [discordRoleId, discordUserId] = args;
+
+ console.log("Starting superadmin bootstrap...\n");
+
+ // Create superadmin permission string (all permissions set to "1")
+ const permissionsCount = Object.keys(PERMISSIONS).length;
+ const allPermissions = "1".repeat(permissionsCount);
+
+ console.log(
+ `Permission string (${permissionsCount} permissions): ${allPermissions}`,
+ );
+ console.log(` All permissions enabled:\n`);
+ Object.entries(PERMISSIONS).forEach(([key, index]) => {
+ console.log(` [${index}] ${key}`);
+ });
+ console.log("");
+
+ try {
+ // Check if the Discord role is already linked
+ if (!discordRoleId) {
+ console.error("Error: Discord role ID is required");
+ process.exit(1);
+ }
+
+ const existingRole = await db.query.Roles.findFirst({
+ where: (t, { eq }) => eq(t.discordRoleId, discordRoleId),
+ });
+
+ let roleId: string;
+
+ if (existingRole) {
+ console.log(
+ `Discord role ${discordRoleId} is already linked to role: ${existingRole.name}`,
+ );
+ console.log(` Updating permissions to superadmin level...\n`);
+
+ // Update existing role with all permissions
+ await db
+ .update(Roles)
+ .set({
+ name: "Superadmin",
+ permissions: allPermissions,
+ })
+ .where(eq(Roles.discordRoleId, discordRoleId));
+
+ roleId = existingRole.id;
+ console.log(`Updated role: ${existingRole.name} -> Superadmin`);
+ } else {
+ console.log(
+ `Creating new Superadmin role linked to Discord role ${discordRoleId}...\n`,
+ );
+
+ // Insert new role
+ const [newRole] = await db
+ .insert(Roles)
+ .values({
+ name: "Superadmin",
+ discordRoleId: discordRoleId,
+ permissions: allPermissions,
+ })
+ .returning();
+
+ if (!newRole) {
+ throw new Error("Failed to create role");
+ }
+
+ roleId = newRole.id;
+ console.log(`Created Superadmin role with ID: ${roleId}`);
+ }
+
+ // Find the user by Discord user ID
+ if (!discordUserId) {
+ console.error("Error: Discord user ID is required");
+ process.exit(1);
+ }
+
+ const user = await db.query.User.findFirst({
+ where: (t, { eq }) => eq(t.discordUserId, discordUserId),
+ });
+
+ if (!user) {
+ console.error(`\nError: No user found with Discord ID: ${discordUserId}`);
+ console.error(
+ ` Make sure the user has logged into Blade at least once.`,
+ );
+ process.exit(1);
+ }
+
+ console.log(`\nFound user: ${user.name ?? "Unknown"} (ID: ${user.id})`);
+
+ // Check if permission already exists
+ const existingPermission = await db.query.Permissions.findFirst({
+ where: (t, { and, eq }) =>
+ and(eq(t.userId, user.id), eq(t.roleId, roleId)),
+ });
+
+ if (existingPermission) {
+ console.log(`\nUser already has this role assigned.`);
+ console.log(`✅ Bootstrap complete - no changes needed.\n`);
+ process.exit(0);
+ }
+
+ // Grant the role to the user
+ await db.insert(Permissions).values({
+ roleId: roleId,
+ userId: user.id,
+ });
+
+ console.log(`\nGranted Superadmin role to user ${user.name}`);
+ console.log(
+ `\nBootstrap complete! User ${user.name} now has full superadmin access.`,
+ );
+ console.log(` They can now manage roles through the Blade UI.\n`);
+ } catch (error) {
+ console.error("\nError during bootstrap:");
+ console.error(error);
+ process.exit(1);
+ }
+
+ process.exit(0);
+}
+
+await bootstrapSuperadmin();
diff --git a/packages/db/src/schemas/auth.ts b/packages/db/src/schemas/auth.ts
index 346f1e24e..0226af891 100644
--- a/packages/db/src/schemas/auth.ts
+++ b/packages/db/src/schemas/auth.ts
@@ -1,5 +1,6 @@
import { relations } from "drizzle-orm";
import { pgTableCreator, primaryKey } from "drizzle-orm/pg-core";
+import { createInsertSchema } from "drizzle-zod";
import { Member } from "./knight-hacks";
@@ -22,9 +23,52 @@ export const User = createTable("user", (t) => ({
.notNull(),
}));
+export const Permissions = createTable("permissions", (t) => ({
+ id: t.uuid().notNull().primaryKey().defaultRandom(),
+ roleId: t
+ .uuid()
+ .notNull()
+ .references(() => Roles.id),
+ userId: t
+ .uuid()
+ .notNull()
+ .references(() => User.id),
+}));
+
+export const Roles = createTable("roles", (t) => ({
+ id: t.uuid().notNull().primaryKey().defaultRandom(),
+ name: t.varchar().notNull().default(""),
+ discordRoleId: t.varchar().unique().notNull(),
+ permissions: t.varchar().notNull(),
+}));
+
+export const InsertRolesSchema = createInsertSchema(Roles);
+
export const UserRelations = relations(User, ({ many, one }) => ({
accounts: many(Account),
member: one(Member),
+ permissions: many(Permissions, {
+ relationName: "userPermissionRel",
+ }),
+}));
+
+export const RoleRelations = relations(Roles, ({ many }) => ({
+ permissions: many(Permissions, {
+ relationName: "rolePermissionRel",
+ }),
+}));
+
+export const PermissionRelations = relations(Permissions, ({ one }) => ({
+ role: one(Roles, {
+ fields: [Permissions.roleId],
+ references: [Roles.id],
+ relationName: "rolePermissionRel",
+ }),
+ user: one(User, {
+ fields: [Permissions.userId],
+ references: [User.id],
+ relationName: "userPermissionRel",
+ }),
}));
export const Account = createTable(
diff --git a/packages/db/tsconfig.json b/packages/db/tsconfig.json
index d2d68f9f6..3e0c3033a 100644
--- a/packages/db/tsconfig.json
+++ b/packages/db/tsconfig.json
@@ -1,5 +1,5 @@
{
"extends": "@forge/tsconfig/internal-package.json",
- "include": ["src", "drizzle.config.ts"],
+ "include": ["src", "scripts", "drizzle.config.ts"],
"exclude": ["node_modules"]
}
+
-
-
-
-
+ ))}
- )}
+ ))}
+ Welcome back, {user?.firstName ?? "Admin"}
-
-
-
- Email Dashboard
-
-
-
-
+
+
+ {/* Main Navigation Sections */}
+
- {(hasFullAdmin || hasCheckIn) && (
-
-
- Club
-
-
- {hasFullAdmin && (
- <>
-
-
-
-
-
-
-
-
-
- >
- )}
- {(hasFullAdmin || hasCheckIn) && (
-
-
-
- )}
-
-
- )}
- {(hasFullAdmin || hasCheckIn) && (
-
-
- Hackathon
-
-
- {hasFullAdmin && (
- <>
-
-
-
-
-
-
-
-
-
-
-
-
- >
- )}
- {(hasFullAdmin || hasCheckIn) && (
-
-
-
- )}
-
-
- )}
-
- {hasFullAdmin && (
- + Here's what's happening with Knight Hacks today. +
+
+ {visibleSections.map((section) => (
+
+
+
+
+ {section.items.map((item) => (
+
+
-
-
- )}
-
- {isOfficer && (
-
+
+
+
+
+
+ {section.title}
+
+ {section.description}
+
+
+
+
+ );
+}
diff --git a/apps/blade/src/app/admin/roles/configure/_components/roletable.tsx b/apps/blade/src/app/admin/roles/configure/_components/roletable.tsx
new file mode 100644
index 000000000..dc7ab3ecd
--- /dev/null
+++ b/apps/blade/src/app/admin/roles/configure/_components/roletable.tsx
@@ -0,0 +1,198 @@
+/* eslint-disable @typescript-eslint/no-unnecessary-condition */
+"use client";
+
+import type { APIRole } from "discord-api-types/v10";
+import { useEffect, useState } from "react";
+import {
+ Check,
+ ChevronDown,
+ Copy,
+ Edit,
+ Loader2,
+ Trash,
+ User,
+ X,
+} from "lucide-react";
+
+import { Button } from "@forge/ui/button";
+import { Dialog, DialogContent, DialogTrigger } from "@forge/ui/dialog";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuTrigger,
+} from "@forge/ui/dropdown-menu";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@forge/ui/table";
+import { toast } from "@forge/ui/toast";
+
+import { getPermsAsList } from "~/lib/utils";
+import { api } from "~/trpc/react";
+import RoleEdit from "./roleedit";
+
+export default function RoleTable() {
+ const { data: roles } = api.roles.getAllLinks.useQuery();
+ const discordRolesQ = api.roles.getDiscordRoles.useQuery(
+ { roles: roles ?? [] },
+ { enabled: false, retry: false },
+ );
+ const { data: roleCounts } = api.roles.getDiscordRoleCounts.useQuery();
+ const deleteLinkMutation = api.roles.deleteRoleLink.useMutation();
+
+ const [discordRoles, setDiscordRoles] = useState<
+ (APIRole | null)[] | undefined
+ >();
+ const [copyConfirm, setCopyConfirm] = useState(-1);
+
+ useEffect(() => {
+ async function fetchDiscordRoles() {
+ setDiscordRoles((await discordRolesQ.refetch()).data);
+ }
+
+ if (roles) void fetchDiscordRoles();
+ }, [roles]);
+
+ function deleteRole(id: string) {
+ try {
+ deleteLinkMutation.mutate({ id: id });
+ location.reload();
+ } catch (error) {
+ toast((error as Error).message);
+ }
+ }
+
+ return !roles ? (
+ {`${oldRole ? "Edit" : "Create"} Role`}
+
+
+ setName(e.target.value)}
+ id="name"
+ placeholder="ex. Officer"
+ className={`col-span-2 ${isDupeName && "bg-red-900/25"}`}
+ />
+ {isDupeName && (
+
+
+
+
+ )}
+
+ There is already a role with this name.
+
+
+
+ setRoleID(e.target.value)}
+ id="roleId"
+ placeholder="ex. 1151884200069320805"
+ className="col-span-2 font-mono"
+ />
+
+ {loadingRole || !roles ? (
+
+
+
+ ) : role ? (
+
+ The following role will be linked:
+
+
+
+ ) : (
+
+ {isDupeID ? (
+
+
+
+
+ ) : (
+ "The following role will be linked:"
+ )}
+
+ This role is already linked.
+
+
+
+
+
+
+
+
+ {role.name}
+
+
+
+
+ {roleCounts ? (
+
+ )}
+
+ {roleCounts[role.id] ?? 0}
+ ) : (
+
+
+
+ )}
+
+ Could not find a Discord role with this ID.
+
+
+
+ Permissions
+ + +
+
+ {`${getPermsAsList(permString).length} permission(s) applied`}
+
+
+ There are currently no roles linked.
+
+ ) : (
+ + Role Configuration +
+ ++ Role Management +
+
+
+ );
+}
diff --git a/apps/blade/src/app/dashboard/_components/hackathon-dashboard/point-leaderboard.tsx b/apps/blade/src/app/dashboard/_components/hackathon-dashboard/point-leaderboard.tsx
index 801dba979..ee6941757 100644
--- a/apps/blade/src/app/dashboard/_components/hackathon-dashboard/point-leaderboard.tsx
+++ b/apps/blade/src/app/dashboard/_components/hackathon-dashboard/point-leaderboard.tsx
@@ -46,7 +46,9 @@ export function PointLeaderboard({
const [activeInd, setInd] = useState(-1);
const [activeTop, setTop] = useState(overall);
- const { data: isAdmin } = api.auth.getAdminStatus.useQuery();
+ const { data: isAdmin } = api.roles.hasPermission.useQuery({
+ or: ["READ_CLUB_DATA"],
+ });
const targetDate = new Date("2025-10-25T23:00:00").getTime();
diff --git a/apps/blade/src/app/judge/results/page.tsx b/apps/blade/src/app/judge/results/page.tsx
index a53f9ea50..2ddce1dab 100644
--- a/apps/blade/src/app/judge/results/page.tsx
+++ b/apps/blade/src/app/judge/results/page.tsx
@@ -1,6 +1,7 @@
import type { Metadata } from "next";
+import { redirect } from "next/navigation";
-import { HydrateClient } from "~/trpc/server";
+import { api, HydrateClient } from "~/trpc/server";
import ResultsTable from "./_components/results-table";
export const metadata: Metadata = {
@@ -8,7 +9,13 @@ export const metadata: Metadata = {
description: "Display hackathon results",
};
-export default function ResultsDashboard() {
+export default async function ResultsDashboard() {
+ const hasAccess = await api.roles.hasPermission({
+ or: ["IS_JUDGE"],
+ });
+ if (!hasAccess) {
+ redirect("/");
+ }
return (
+
+ ) : !users ? (
+
+
+ )}
+
+
+
+
+
+
+
+
+
+ {status == "pending" ? (
+
+
+ setSearchTerm(e.target.value)}
+ className="pl-8"
+ />
+
+ setCheckedUsers({})}
+ className="my-auto flex h-full cursor-pointer flex-row gap-1 rounded-lg border px-2 py-1 hover:bg-muted"
+ >
+
+
+
+ {countedUsers}
+
+
+
+
+
+ Select roles to filter by:
+
+ -
+ {!roles ? (
+
-
+
{ + filterRoles[v.id] = c == true; + sUpd(!upd); + }} + /> + +
+ );
+ })
+ )}
+
+ Failed to get users.
+
+ ) : filteredUsers.length == 0 ? (
+
+ Could not find any users matching this search.
+
+ ) : (
+
+
+
+
+
+
+ Controls
+
+
+
+
+
+ -
+ {!roles ? (
+
-
+
{ + checkedRoles[v.id] = c == true; + sUpd(!upd); + }} + /> + +
+ );
+ })
+ )}
+