diff --git a/apps/blade/src/app/_components/bad-perms.tsx b/apps/blade/src/app/_components/bad-perms.tsx new file mode 100644 index 000000000..0ba015aca --- /dev/null +++ b/apps/blade/src/app/_components/bad-perms.tsx @@ -0,0 +1,24 @@ +import { ShieldX } from "lucide-react"; + +import { PERMISSION_DATA, PermissionKey } from "@forge/consts/knight-hacks"; + +export function BadPerms({ perms }: { perms: PermissionKey[] }) { + const permNames: string[] = []; + perms.forEach((v) => { + const permissionData = PERMISSION_DATA[v]; + if (permissionData) permNames.push(permissionData.name); + }); + + return ( +
+
+ +
+

Access Denied

+
+ This action requires the following permissions: +
+
{permNames.join(", ")}
+
+ ); +} diff --git a/apps/blade/src/app/_components/navigation/reusable-user-dropdown.tsx b/apps/blade/src/app/_components/navigation/reusable-user-dropdown.tsx index db4eb9214..aef60f732 100644 --- a/apps/blade/src/app/_components/navigation/reusable-user-dropdown.tsx +++ b/apps/blade/src/app/_components/navigation/reusable-user-dropdown.tsx @@ -3,6 +3,7 @@ import { ChartPie, FormInput, Hotel, + Mail, Settings, ShieldCheck, Swords, @@ -11,17 +12,26 @@ import { Users, } from "lucide-react"; +import type { PermissionKey } from "@forge/consts/knight-hacks"; + import { USER_DROPDOWN_ICON_COLOR, USER_DROPDOWN_ICON_SIZE } from "~/consts"; /* * name = the text to be displayed * component = the corresponding icon for the name * route = the specific route you want the user to enter + * requiredPermissions = permissions needed to access this item + * - or: user needs at least ONE of these permissions + * - and: user needs ALL of these permissions */ export interface roleItems { name: string; component: React.JSX.Element; route: string; + requiredPermissions?: { + or?: PermissionKey[]; + and?: PermissionKey[]; + }; } // Use these as a reference for creating new items and remember to import them into ./user-dropdown @@ -36,7 +46,13 @@ export const adminItems: roleItems[] = [ /> ), route: "/admin", + requiredPermissions: { + or: ["IS_OFFICER"], + }, }, +]; + +export const systemItems: roleItems[] = [ { name: "Forms", component: ( @@ -46,16 +62,55 @@ export const adminItems: roleItems[] = [ /> ), route: "/admin/forms", + requiredPermissions: { + or: ["READ_FORMS", "EDIT_FORMS", "IS_OFFICER"], + }, + }, + { + name: "Email", + component: ( + + ), + route: "/admin/email", + requiredPermissions: { + or: ["EMAIL_PORTAL", "IS_OFFICER"], + }, + }, + { + name: "Assign Roles", + component: ( + + ), + route: "/admin/roles/manage", + requiredPermissions: { + or: ["ASSIGN_ROLES", "IS_OFFICER"], + }, + }, + { + name: "Configure Roles", + component: ( + + ), + route: "/admin/roles/configure", + requiredPermissions: { + or: ["CONFIGURE_ROLES", "IS_OFFICER"], + }, }, ]; -export const adminClubItems: roleItems[] = [ +export const clubItems: roleItems[] = [ { name: "Members", component: ( ), route: "/admin/club/members", + requiredPermissions: { + or: ["READ_MEMBERS", "EDIT_MEMBERS", "IS_OFFICER"], + }, }, { name: "Events", @@ -66,6 +121,9 @@ export const adminClubItems: roleItems[] = [ /> ), route: "/admin/club/events", + requiredPermissions: { + or: ["READ_CLUB_EVENT", "EDIT_CLUB_EVENT", "IS_OFFICER"], + }, }, { name: "Check-in", @@ -76,6 +134,9 @@ export const adminClubItems: roleItems[] = [ /> ), route: "/admin/club/check-in", + requiredPermissions: { + or: ["CHECKIN_CLUB_EVENT", "IS_OFFICER"], + }, }, { name: "Data", @@ -86,16 +147,22 @@ export const adminClubItems: roleItems[] = [ /> ), route: "/admin/club/data", + requiredPermissions: { + or: ["READ_CLUB_DATA", "IS_OFFICER"], + }, }, ]; -export const adminHackathonItems: roleItems[] = [ +export const hackathonItems: roleItems[] = [ { name: "Hackers", component: ( ), route: "/admin/hackathon/hackers", + requiredPermissions: { + or: ["READ_HACKERS", "EDIT_HACKERS", "IS_OFFICER"], + }, }, { name: "Events", @@ -106,6 +173,9 @@ export const adminHackathonItems: roleItems[] = [ /> ), route: "/admin/hackathon/events", + requiredPermissions: { + or: ["READ_HACK_EVENT", "EDIT_HACK_EVENT", "IS_OFFICER"], + }, }, { name: "Check-in", @@ -116,6 +186,9 @@ export const adminHackathonItems: roleItems[] = [ /> ), route: "/admin/hackathon/check-in", + requiredPermissions: { + or: ["CHECKIN_HACK_EVENT", "IS_OFFICER"], + }, }, { name: "Data", @@ -126,6 +199,9 @@ export const adminHackathonItems: roleItems[] = [ /> ), route: "/admin/hackathon/data", + requiredPermissions: { + or: ["READ_HACK_DATA", "IS_OFFICER"], + }, }, { name: "Room Assignment", @@ -133,6 +209,9 @@ export const adminHackathonItems: roleItems[] = [ ), route: "/admin/hackathon/roomAssignment", + requiredPermissions: { + or: ["IS_OFFICER"], + }, }, { name: "Judge Assignment", @@ -140,45 +219,9 @@ export const adminHackathonItems: roleItems[] = [ ), route: "/admin/hackathon/judge-assignment", - }, -]; - -export const checkInOnlyItems: roleItems[] = [ - { - name: "Events", - component: ( - - ), - route: "/admin/club/events", - }, -]; - -export const scannerOnlyClubItems: roleItems[] = [ - { - name: "Check-in", - component: ( - - ), - route: "/admin/club/check-in", - }, -]; - -export const scannerOnlyHackathonItems: roleItems[] = [ - { - name: "Check-in", - component: ( - - ), - route: "/admin/hackathon/check-in", + requiredPermissions: { + or: ["IS_JUDGE", "IS_OFFICER"], + }, }, ]; diff --git a/apps/blade/src/app/_components/navigation/session-navbar.tsx b/apps/blade/src/app/_components/navigation/session-navbar.tsx index 285ec5d1d..08179665e 100644 --- a/apps/blade/src/app/_components/navigation/session-navbar.tsx +++ b/apps/blade/src/app/_components/navigation/session-navbar.tsx @@ -1,5 +1,11 @@ import Link from "next/link"; +import { ChevronDown, Shield } from "lucide-react"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, +} from "@forge/ui/dropdown-menu"; import { NavigationMenu, NavigationMenuItem, @@ -7,13 +13,20 @@ import { } from "@forge/ui/navigation-menu"; import { Separator } from "@forge/ui/separator"; +import { getPermsAsList } from "~/lib/utils"; import { api } from "~/trpc/server"; import ClubLogo from "./club-logo"; import { UserDropdown } from "./user-dropdown"; export async function SessionNavbar() { - const hasCheckIn = await api.auth.hasCheckIn(); - const hasFullAdmin = await api.auth.hasFullAdmin(); + const perms = await api.roles.getPermissions(); + + let permString = ""; + Object.values(perms).forEach((v) => { + permString += v ? "1" : "0"; + }); + + const permList = getPermsAsList(permString); return (
@@ -24,9 +37,40 @@ export async function SessionNavbar() { - + + {permList.length > 0 && ( + + +
+ +
{permList.length}
+ +
+
+ +

+ You have the following permissions: +

+
    + {permList.map((p, index) => { + return ( +
  • + {p} +
  • + ); + })} +
+
+
+ )} - +
diff --git a/apps/blade/src/app/_components/navigation/user-dropdown.tsx b/apps/blade/src/app/_components/navigation/user-dropdown.tsx index 24e2e51d9..667678f64 100644 --- a/apps/blade/src/app/_components/navigation/user-dropdown.tsx +++ b/apps/blade/src/app/_components/navigation/user-dropdown.tsx @@ -1,9 +1,9 @@ "use client"; -import React from "react"; import { useRouter } from "next/navigation"; import { LayoutDashboard } from "lucide-react"; +import type { PermissionKey } from "@forge/consts/knight-hacks"; import { signOut } from "@forge/auth"; import { Avatar, AvatarFallback, AvatarImage } from "@forge/ui/avatar"; import { Button } from "@forge/ui/button"; @@ -21,41 +21,64 @@ import type { roleItems } from "./reusable-user-dropdown"; import { USER_DROPDOWN_ICON_COLOR, USER_DROPDOWN_ICON_SIZE } from "~/consts"; import { api } from "~/trpc/react"; import { - adminClubItems, - adminHackathonItems, adminItems, - scannerOnlyClubItems, - scannerOnlyHackathonItems, + clubItems, + hackathonItems, + systemItems, userItems, } from "./reusable-user-dropdown"; interface UserDropdownProps { - hasCheckIn: boolean; - hasFullAdmin: boolean; + permissions: Record; } -export function UserDropdown({ hasCheckIn, hasFullAdmin }: UserDropdownProps) { +/** + * Filters role items based on user permissions + */ +function filterItemsByPermissions( + items: roleItems[], + permissions: Record, +): roleItems[] { + return items.filter((item) => { + // If no permissions required, show the item + if (!item.requiredPermissions) return true; + + const { or, and } = item.requiredPermissions; + + // Check OR permissions - user needs at least one + if (or && or.length > 0) { + const hasOrPermission = or.some((perm) => permissions[perm]); + if (!hasOrPermission) return false; + } + + // Check AND permissions - user needs all of them + if (and && and.length > 0) { + const hasAllAndPermissions = and.every((perm) => permissions[perm]); + if (!hasAllAndPermissions) return false; + } + + return true; + }); +} + +export function UserDropdown({ permissions }: UserDropdownProps) { const utils = api.useUtils(); const router = useRouter(); const { data } = api.user.getUserAvatar.useQuery(); void utils.member.getMember.prefetch(); - const canAccessClub = hasFullAdmin || hasCheckIn; - const canAccessHackathon = hasFullAdmin || hasCheckIn; - const canAccessAdmin = hasFullAdmin || hasCheckIn; - - // Determine which items to show based on permissions - const clubItems = hasFullAdmin - ? adminClubItems - : hasCheckIn - ? scannerOnlyClubItems - : []; - const hackathonItems = hasFullAdmin - ? adminHackathonItems - : hasCheckIn - ? scannerOnlyHackathonItems - : []; + // Filter items based on user permissions + const filteredAdminItems = filterItemsByPermissions(adminItems, permissions); + const filteredSystemItems = filterItemsByPermissions( + systemItems, + permissions, + ); + const filteredClubItems = filterItemsByPermissions(clubItems, permissions); + const filteredHackathonItems = filterItemsByPermissions( + hackathonItems, + permissions, + ); return ( @@ -72,17 +95,25 @@ export function UserDropdown({ hasCheckIn, hasFullAdmin }: UserDropdownProps) { {data ? data.name : "My Account"} - {canAccessAdmin && } - {canAccessClub && clubItems.length > 0 && ( + {filteredAdminItems.length > 0 && ( + + )} + {filteredSystemItems.length > 0 && ( + <> + System + + + )} + {filteredClubItems.length > 0 && ( <> Club - + )} - {canAccessHackathon && hackathonItems.length > 0 && ( + {filteredHackathonItems.length > 0 && ( <> Hackathon - + )} Loading...
; - // if (error) return
Error: {error?.message}
return (
diff --git a/apps/blade/src/app/admin/email/page.tsx b/apps/blade/src/app/admin/email/page.tsx index e5559cbc1..96aec3a60 100644 --- a/apps/blade/src/app/admin/email/page.tsx +++ b/apps/blade/src/app/admin/email/page.tsx @@ -12,15 +12,11 @@ export default async function AdminEmail() { redirect(SIGN_IN_PATH); } - const hasCheckIn = await api.auth.hasCheckIn(); - const hasFullAdmin = await api.auth.hasFullAdmin(); + const hasAccess = await api.roles.hasPermission({ + or: ["EMAIL_PORTAL"], + }); - if (!hasCheckIn && !hasFullAdmin) { - redirect("/"); - } - - const user = await api.member.getMember(); - if (!user) { + if (!hasAccess) { redirect("/"); } diff --git a/apps/blade/src/app/admin/forms/[slug]/page.tsx b/apps/blade/src/app/admin/forms/[slug]/page.tsx index 2e41c8a77..778607ef4 100644 --- a/apps/blade/src/app/admin/forms/[slug]/page.tsx +++ b/apps/blade/src/app/admin/forms/[slug]/page.tsx @@ -20,8 +20,10 @@ export default async function FormEditorPage({ redirect("/"); } - const isAdmin = await api.auth.getAdminStatus(); - if (!isAdmin) { + const hasAccess = await api.roles.hasPermission({ + or: ["EDIT_FORMS"], + }); + if (!hasAccess) { redirect("/"); } } diff --git a/apps/blade/src/app/admin/forms/[slug]/responses/page.tsx b/apps/blade/src/app/admin/forms/[slug]/responses/page.tsx index f6f745c1d..ac6f49f74 100644 --- a/apps/blade/src/app/admin/forms/[slug]/responses/page.tsx +++ b/apps/blade/src/app/admin/forms/[slug]/responses/page.tsx @@ -26,8 +26,10 @@ export default async function FormResponsesPage({ redirect(SIGN_IN_PATH); } - const isAdmin = await api.auth.getAdminStatus(); - if (!isAdmin) { + const hasAccess = await api.roles.hasPermission({ + or: ["READ_FORMS", "EDIT_FORMS"], + }); + if (!hasAccess) { redirect("/"); } diff --git a/apps/blade/src/app/admin/forms/page.tsx b/apps/blade/src/app/admin/forms/page.tsx index 37f67cf42..64282f694 100644 --- a/apps/blade/src/app/admin/forms/page.tsx +++ b/apps/blade/src/app/admin/forms/page.tsx @@ -16,8 +16,10 @@ export default async function Page() { redirect(SIGN_IN_PATH); } - const isAdmin = await api.auth.getAdminStatus(); - if (!isAdmin) { + const hasAccess = await api.roles.hasPermission({ + or: ["READ_FORMS", "EDIT_FORMS"], + }); + if (!hasAccess) { redirect("/"); } diff --git a/apps/blade/src/app/admin/hackathon/check-in/page.tsx b/apps/blade/src/app/admin/hackathon/check-in/page.tsx index b7696337e..c109f12c7 100644 --- a/apps/blade/src/app/admin/hackathon/check-in/page.tsx +++ b/apps/blade/src/app/admin/hackathon/check-in/page.tsx @@ -19,8 +19,9 @@ export default async function HackathonCheckIn() { redirect(SIGN_IN_PATH); } - // Check if the user has access to the scanner - const hasAccess = await api.auth.hasCheckIn(); + const hasAccess = await api.roles.hasPermission({ + or: ["CHECKIN_HACK_EVENT"], + }); if (!hasAccess) { redirect("/"); } diff --git a/apps/blade/src/app/admin/hackathon/control-room/page.tsx b/apps/blade/src/app/admin/hackathon/control-room/page.tsx index 990f10f0c..444b54c06 100644 --- a/apps/blade/src/app/admin/hackathon/control-room/page.tsx +++ b/apps/blade/src/app/admin/hackathon/control-room/page.tsx @@ -11,8 +11,8 @@ export default async function ControlRoom() { redirect("/"); } - const isOfficer = await api.auth.getOfficerStatus(); - if (!isOfficer) { + const hasAccess = await api.roles.hasPermission({ and: ["IS_OFFICER"] }); + if (!hasAccess) { redirect("/"); } diff --git a/apps/blade/src/app/admin/hackathon/data/page.tsx b/apps/blade/src/app/admin/hackathon/data/page.tsx index 20ec351e7..9336d3a9c 100644 --- a/apps/blade/src/app/admin/hackathon/data/page.tsx +++ b/apps/blade/src/app/admin/hackathon/data/page.tsx @@ -15,14 +15,15 @@ export const metadata: Metadata = { }; export default async function HackathonData() { - // authentication const session = await auth(); if (!session) { redirect(SIGN_IN_PATH); } - const isAdmin = await api.auth.getAdminStatus(); - if (!isAdmin) { + const hasAccess = await api.roles.hasPermission({ + or: ["READ_HACK_DATA"], + }); + if (!hasAccess) { redirect("/"); } diff --git a/apps/blade/src/app/admin/hackathon/events/page.tsx b/apps/blade/src/app/admin/hackathon/events/page.tsx index 3b5b2f049..eb6b6fb6d 100644 --- a/apps/blade/src/app/admin/hackathon/events/page.tsx +++ b/apps/blade/src/app/admin/hackathon/events/page.tsx @@ -21,10 +21,11 @@ export default async function HackathonEvents() { redirect(SIGN_IN_PATH); } - // Check if the user has access to Events - const hasFullAdmin = await api.auth.hasFullAdmin(); + const hasAccess = await api.roles.hasPermission({ + or: ["READ_HACK_EVENT", "EDIT_HACK_EVENT"], + }); - if (!hasFullAdmin) { + if (!hasAccess) { redirect("/"); } diff --git a/apps/blade/src/app/admin/hackathon/hackers/page.tsx b/apps/blade/src/app/admin/hackathon/hackers/page.tsx index 03d047719..982001686 100644 --- a/apps/blade/src/app/admin/hackathon/hackers/page.tsx +++ b/apps/blade/src/app/admin/hackathon/hackers/page.tsx @@ -16,8 +16,10 @@ export default async function Hackers() { const session = await auth(); if (!session) redirect(SIGN_IN_PATH); - const isAdmin = await api.auth.getAdminStatus(); - if (!isAdmin) redirect("/"); + const hasAccess = await api.roles.hasPermission({ + or: ["READ_HACKERS", "EDIT_HACKERS"], + }); + if (!hasAccess) redirect("/"); const currentActiveHackathon = await api.hackathon.getCurrentHackathon(); diff --git a/apps/blade/src/app/admin/hackathon/judge-assignment/page.tsx b/apps/blade/src/app/admin/hackathon/judge-assignment/page.tsx index 4379db3c5..87f972e91 100644 --- a/apps/blade/src/app/admin/hackathon/judge-assignment/page.tsx +++ b/apps/blade/src/app/admin/hackathon/judge-assignment/page.tsx @@ -16,8 +16,10 @@ export default async function Judges() { const session = await auth(); if (!session) redirect(SIGN_IN_PATH); - const isAdmin = await api.auth.getAdminStatus(); - if (!isAdmin) redirect("/"); + const hasAccess = await api.roles.hasPermission({ + or: ["IS_JUDGE"], + }); + if (!hasAccess) redirect("/"); return ; } diff --git a/apps/blade/src/app/admin/hackathon/roomAssignment/page.tsx b/apps/blade/src/app/admin/hackathon/roomAssignment/page.tsx index dcf32de74..089e25295 100644 --- a/apps/blade/src/app/admin/hackathon/roomAssignment/page.tsx +++ b/apps/blade/src/app/admin/hackathon/roomAssignment/page.tsx @@ -16,8 +16,10 @@ export default async function Hackers() { const session = await auth(); if (!session) redirect(SIGN_IN_PATH); - const isAdmin = await api.auth.getAdminStatus(); - if (!isAdmin) redirect("/"); + const hasAccess = await api.roles.hasPermission({ + or: ["IS_JUDGE"], + }); + if (!hasAccess) redirect("/"); const currentHackathon = await api.hackathon.getCurrentHackathon(); if (!currentHackathon) return

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 ( -
-
-

- 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

+
+ updateString(form.getValues())} + onSubmit={form.handleSubmit(updateString)} + className="flex max-h-[40vh] flex-col overflow-y-scroll rounded-lg border" + > + {Object.entries(PERMISSION_DATA).map((v) => ( + ( + + + + + +
{v[1].name}
+
+ {v[1].desc} +
+
+
+ )} + /> + ))} + + +
+
+
{`${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"] }