From 964894d574fc0e53f0269a0a3b62ce285df4b02a Mon Sep 17 00:00:00 2001 From: Leander Furumo Date: Sun, 22 Jun 2025 01:18:34 +0200 Subject: [PATCH] require specified role to use website --- env.example | 5 +- src/app/api/auth/[...nextauth]/route.ts | 15 ++++++ src/app/auth/error/AuthErrorClient.tsx | 35 ++++++++++++++ src/app/auth/error/page.tsx | 20 ++++++++ src/app/lib/discord.ts | 61 +++++++++++++++++++++++++ src/app/translations.ts | 6 +++ 6 files changed, 141 insertions(+), 1 deletion(-) create mode 100644 src/app/auth/error/AuthErrorClient.tsx create mode 100644 src/app/auth/error/page.tsx create mode 100644 src/app/lib/discord.ts diff --git a/env.example b/env.example index e676a10..2808150 100644 --- a/env.example +++ b/env.example @@ -1,3 +1,6 @@ DISCORD_CLIENT_ID= DISCORD_CLIENT_SECRET= -NEXTAUTH_SECRET= \ No newline at end of file +NEXTAUTH_SECRET= +DISCORD_GUILD_ID= +DISCORD_REQUIRED_ROLE_ID= +DISCORD_BOT_TOKEN= \ No newline at end of file diff --git a/src/app/api/auth/[...nextauth]/route.ts b/src/app/api/auth/[...nextauth]/route.ts index 99d4a81..447a9b8 100644 --- a/src/app/api/auth/[...nextauth]/route.ts +++ b/src/app/api/auth/[...nextauth]/route.ts @@ -1,3 +1,4 @@ +import { verifyUserRole } from "@/lib/discord"; import type { NextAuthOptions } from "next-auth"; import NextAuth from "next-auth"; import DiscordProvider, { type DiscordProfile } from "next-auth/providers/discord"; @@ -24,6 +25,20 @@ export const authOptions: NextAuthOptions = { }, }), ], + callbacks: { + async signIn({ user }) { + const hasRequiredRole = await verifyUserRole(user.id); + + if (!hasRequiredRole) { + return false; + } + + return true; + }, + }, + pages: { + error: "/auth/error", + }, secret: process.env.NEXTAUTH_SECRET, }; diff --git a/src/app/auth/error/AuthErrorClient.tsx b/src/app/auth/error/AuthErrorClient.tsx new file mode 100644 index 0000000..e010689 --- /dev/null +++ b/src/app/auth/error/AuthErrorClient.tsx @@ -0,0 +1,35 @@ +"use client"; + +import LanguageMenu from "@/components/LanguageMenu"; +import { useLanguage } from "@/LanguageContext"; +import { translations } from "@/translations"; +import { signIn } from "next-auth/react"; +import { FaDiscord } from "react-icons/fa"; + +export default function AuthErrorClient() { + const { language } = useLanguage(); + const t = translations[language]; + + return ( + <> +
+ +
+
+
+

{t.accessDenied}

+

{t.accessDeniedMessage}

+
+ +
+
+
+ + ); +} diff --git a/src/app/auth/error/page.tsx b/src/app/auth/error/page.tsx new file mode 100644 index 0000000..74bcf01 --- /dev/null +++ b/src/app/auth/error/page.tsx @@ -0,0 +1,20 @@ +import { authOptions } from "@/api/auth/[...nextauth]/route"; +import { LanguageProvider } from "@/LanguageContext"; +import { getServerSession } from "next-auth"; +import { redirect } from "next/navigation"; +import AuthErrorClient from "./AuthErrorClient"; + +export default async function AuthErrorPage() { + const session = await getServerSession(authOptions); + + // If user is already authenticated, redirect to home + if (session) { + redirect("/"); + } + + return ( + + + + ); +} diff --git a/src/app/lib/discord.ts b/src/app/lib/discord.ts new file mode 100644 index 0000000..3d2a49b --- /dev/null +++ b/src/app/lib/discord.ts @@ -0,0 +1,61 @@ +interface DiscordGuildMember { + user: { + id: string; + username: string; + discriminator: string; + avatar: string | null; + }; + roles: string[]; + joined_at: string; +} + +export async function verifyUserRole(userId: string): Promise { + const guildId = process.env.DISCORD_GUILD_ID; + const requiredRoleId = process.env.DISCORD_REQUIRED_ROLE_ID; + + if (!guildId || !requiredRoleId) { + console.error("Missing DISCORD_GUILD_ID or DISCORD_REQUIRED_ROLE_ID environment variables"); + return false; + } + + try { + // Get the bot token from the Discord application + const botToken = process.env.DISCORD_BOT_TOKEN; + if (!botToken) { + console.error("Missing DISCORD_BOT_TOKEN environment variable"); + return false; + } + + // Fetch the user's guild member information + const response = await fetch(`https://discord.com/api/v10/guilds/${guildId}/members/${userId}`, { + headers: { + Authorization: `Bot ${botToken}`, + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + if (response.status === 404) { + // User is not a member of the guild + console.log(`User ${userId} is not a member of guild ${guildId}`); + return false; + } + if (response.status === 403) { + console.error("Bot does not have permission to view guild members"); + return false; + } + throw new Error(`Discord API error: ${response.status} ${response.statusText}`); + } + + const member: DiscordGuildMember = await response.json(); + + // Check if the user has the required role + const hasRole = member.roles.includes(requiredRoleId); + console.log(`User ${userId} has role ${requiredRoleId}: ${hasRole}`); + + return hasRole; + } catch (error) { + console.error("Error verifying user role:", error); + return false; + } +} diff --git a/src/app/translations.ts b/src/app/translations.ts index 80807e6..7df4979 100644 --- a/src/app/translations.ts +++ b/src/app/translations.ts @@ -15,6 +15,9 @@ export const translations = { subject: "UiO Gamers", loginPrompt: "Please log in to play", loginWithDiscord: "Login with Discord", + accessDenied: "Access Denied", + accessDeniedMessage: "You don't have the paying member role in the UiO Gaming Discord server.", + tryAgain: "Try again logging in again", }, no: { submittedAnswers: "Dine svar", @@ -32,5 +35,8 @@ export const translations = { subject: "UiO-Gamere", loginPrompt: "Vennligst logg inn for å spille", loginWithDiscord: "Logg inn med Discord", + accessDenied: "Tilgang nektet", + accessDeniedMessage: 'Du har ikke rollen "Betalende medlem" i UiO Gaming sin Discord-server.', + tryAgain: "Logg på igjen", }, };