From 10162c936309602efb3793acf2893ef2c3001258 Mon Sep 17 00:00:00 2001 From: gunshiz Date: Fri, 3 Jul 2026 21:58:10 +0700 Subject: [PATCH] feat: add Discord role-based access control for forms with custom dropdown UI --- .gitignore | 3 +- app/actions/discord.ts | 63 ++++++++++++ app/admin/form/[id]/extra/client.tsx | 145 +++++++++++++++++++++++++-- app/admin/form/actions.ts | 2 +- app/form/[id]/page.tsx | 24 +++++ db/schema/form.ts | 2 + drizzle/meta/_journal.json | 7 ++ 7 files changed, 236 insertions(+), 10 deletions(-) create mode 100644 app/actions/discord.ts diff --git a/.gitignore b/.gitignore index f7e5220..34573de 100644 --- a/.gitignore +++ b/.gitignore @@ -40,4 +40,5 @@ yarn-error.log* *.tsbuildinfo next-env.d.ts -public/form \ No newline at end of file +public/form +drizzle \ No newline at end of file diff --git a/app/actions/discord.ts b/app/actions/discord.ts new file mode 100644 index 0000000..30aa09b --- /dev/null +++ b/app/actions/discord.ts @@ -0,0 +1,63 @@ +"use server"; + +import { getServerSession } from "next-auth"; +import { authOptions } from "@/app/api/auth/[...nextauth]/route"; + +export interface DiscordRole { + id: string; + name: string; + color: number; + position: number; +} + +export async function getGuildRoles(): Promise { + const token = process.env.DISCORD_BOT_TOKEN; + const guildId = process.env.DISCORD_GUILD_ID; + if (!token || !guildId) return []; + + const session = await getServerSession(authOptions); + const discordId = (session?.user as any)?.discordId; + const admins = (process.env.ADMIN_DISCORD_IDS || "").split(","); + if (!discordId || !admins.includes(discordId)) { + throw new Error("Unauthorized"); + } + + try { + const res = await fetch(`https://discord.com/api/v10/guilds/${guildId}/roles`, { + headers: { Authorization: `Bot ${token}` }, + next: { revalidate: 60 }, // Cache for 60s + }); + + if (!res.ok) return []; + + const roles: any[] = await res.json(); + return roles.map(r => ({ + id: r.id, + name: r.name, + color: r.color, + position: r.position, + })).sort((a, b) => b.position - a.position); + } catch { + return []; + } +} + +export async function getGuildMemberRoles(discordId: string): Promise { + const token = process.env.DISCORD_BOT_TOKEN; + const guildId = process.env.DISCORD_GUILD_ID; + if (!token || !guildId || !discordId) return []; + + try { + const res = await fetch(`https://discord.com/api/v10/guilds/${guildId}/members/${discordId}`, { + headers: { Authorization: `Bot ${token}` }, + cache: "no-store", // Always fresh for access control + }); + + if (!res.ok) return []; + + const member = await res.json(); + return member.roles || []; + } catch { + return []; + } +} diff --git a/app/admin/form/[id]/extra/client.tsx b/app/admin/form/[id]/extra/client.tsx index f526e40..823a6fb 100644 --- a/app/admin/form/[id]/extra/client.tsx +++ b/app/admin/form/[id]/extra/client.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useTransition } from "react"; +import React, { useState, useTransition, useEffect } from "react"; import { useRouter } from "next/navigation"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; @@ -9,7 +9,15 @@ import { Textarea } from "@/components/ui/textarea"; import { Label } from "@/components/ui/label"; import { toast } from "sonner"; import { updateForm } from "@/app/admin/form/actions"; -import { Loader2, Save } from "lucide-react"; +import { Loader2, Save, ChevronDown } from "lucide-react"; +import { getGuildRoles, DiscordRole } from "@/app/actions/discord"; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area" interface FormExtraClientProps { form: { @@ -17,6 +25,8 @@ interface FormExtraClientProps { discordWebhookUrl: string | null; discordWebhookTemplate: string | null; discordWebhookUpdateTemplate: string | null; + allowedRoles?: string[] | null; + deniedRoles?: string[] | null; }; } @@ -25,8 +35,21 @@ export default function FormExtraClient({ form }: FormExtraClientProps) { const [webhookUrl, setWebhookUrl] = useState(form.discordWebhookUrl || ""); const [webhookTemplate, setWebhookTemplate] = useState(form.discordWebhookTemplate || ""); const [webhookUpdateTemplate, setWebhookUpdateTemplate] = useState(form.discordWebhookUpdateTemplate || ""); + + const [allowedRoles, setAllowedRoles] = useState(form.allowedRoles || []); + const [deniedRoles, setDeniedRoles] = useState(form.deniedRoles || []); + const [roles, setRoles] = useState([]); + const [loadingRoles, setLoadingRoles] = useState(true); + const [isSaving, startSaveTransition] = useTransition(); + useEffect(() => { + getGuildRoles() + .then(r => setRoles(r)) + .catch(() => toast.error("Failed to load Discord roles.")) + .finally(() => setLoadingRoles(false)); + }, []); + const handleSave = () => { startSaveTransition(async () => { try { @@ -34,6 +57,8 @@ export default function FormExtraClient({ form }: FormExtraClientProps) { discordWebhookUrl: webhookUrl.trim() || null, discordWebhookTemplate: webhookTemplate.trim() || null, discordWebhookUpdateTemplate: webhookUpdateTemplate.trim() || null, + allowedRoles, + deniedRoles, }); toast.success("Extra settings saved!"); router.refresh(); @@ -43,8 +68,111 @@ export default function FormExtraClient({ form }: FormExtraClientProps) { }); }; + const toggleRole = (list: string[], setList: (v: string[]) => void, roleId: string) => { + if (list.includes(roleId)) { + setList(list.filter(id => id !== roleId)); + } else { + setList([...list, roleId]); + } + }; + + const renderRoleList = (list: string[], setList: (v: string[]) => void, label: string, desc: string) => { + const selectedRoles = roles.filter(r => list.includes(r.id)); + + return ( +
+ +

{desc}

+ {loadingRoles ? ( +
+ Loading roles... +
+ ) : ( + + + + + + +
+ {roles.map(role => { + const colorHex = role.color === 0 ? "#99aab5" : `#${role.color.toString(16).padStart(6, '0')}`; + return ( + toggleRole(list, setList, role.id)} + onSelect={(e) => e.preventDefault()} + > +
+
+ {role.name} +
+ + ); + })} + {roles.length === 0 && ( +
No roles found.
+ )} +
+ + + + )} +
+ ); +}; + return (
+ + + Access Control + + Restrict form access to specific Discord roles. + + + + {renderRoleList( + allowedRoles, + setAllowedRoles, + "Allowed Roles", + "Users must have at least one of these roles to access the form. Leave empty to allow all." + )} + {renderRoleList( + deniedRoles, + setDeniedRoles, + "Denied Roles", + "Users with any of these roles will be blocked from accessing the form, even if they have an allowed role." + )} + + + Discord Webhook @@ -89,14 +217,15 @@ export default function FormExtraClient({ form }: FormExtraClientProps) { Used when a user updates their existing submission. Use {`{1}`}, {`{2}`} for answers, and {`{username}`}, {`{displayname}`}, {`{id}`} for user info.

-
- -
+ +
+ +
); } diff --git a/app/admin/form/actions.ts b/app/admin/form/actions.ts index 616e7ac..39a3310 100644 --- a/app/admin/form/actions.ts +++ b/app/admin/form/actions.ts @@ -28,7 +28,7 @@ export async function createForm(formData: FormData) { redirect(`/form/admin/${form.id}/edit`); } -export async function updateForm(id: string, data: { title?: string; description?: string | null; isOpen?: boolean; discordWebhookUrl?: string | null; discordWebhookTemplate?: string | null; discordWebhookUpdateTemplate?: string | null }) { +export async function updateForm(id: string, data: { title?: string; description?: string | null; isOpen?: boolean; discordWebhookUrl?: string | null; discordWebhookTemplate?: string | null; discordWebhookUpdateTemplate?: string | null; allowedRoles?: string[]; deniedRoles?: string[] }) { const session = await getServerSession(authOptions); const adminIds = (process.env.ADMIN_DISCORD_IDS ?? "").split(",").map((s) => s.trim()).filter(Boolean); const discordId = (session?.user as { discordId?: string } | undefined)?.discordId; diff --git a/app/form/[id]/page.tsx b/app/form/[id]/page.tsx index 79fa53b..0a1ffbf 100644 --- a/app/form/[id]/page.tsx +++ b/app/form/[id]/page.tsx @@ -3,6 +3,7 @@ import { FormFillerClient } from "../client"; import { getServerSession } from "next-auth"; import { authOptions } from "@/app/api/auth/[...nextauth]/route"; import { notFound } from "next/navigation"; +import { getGuildMemberRoles } from "@/app/actions/discord"; export const dynamic = "force-dynamic"; @@ -23,6 +24,29 @@ export default async function FormsSubmitPage( return notFound(); } + if (discordId) { + const allowedRoles = form.allowedRoles || []; + const deniedRoles = form.deniedRoles || []; + + if (allowedRoles.length > 0 || deniedRoles.length > 0) { + const userRoles = await getGuildMemberRoles(discordId); + + let isAllowed = allowedRoles.length === 0; + if (allowedRoles.length > 0) { + isAllowed = userRoles.some(r => allowedRoles.includes(r)); + } + + let isDenied = false; + if (deniedRoles.length > 0) { + isDenied = userRoles.some(r => deniedRoles.includes(r)); + } + + if (!isAllowed || isDenied) { + return notFound(); + } + } + } + const questionsList = await db.query.questions.findMany({ where: (q, { eq }) => eq(q.formId, form.id), orderBy: (q, { asc }) => [asc(q.displayOrder)], diff --git a/db/schema/form.ts b/db/schema/form.ts index 0ea067f..22d389d 100644 --- a/db/schema/form.ts +++ b/db/schema/form.ts @@ -21,6 +21,8 @@ export const forms = formSchema.table("form", { discordWebhookUrl: text("discord_webhook_url"), discordWebhookTemplate: text("discord_webhook_template"), discordWebhookUpdateTemplate: text("discord_webhook_update_template"), + allowedRoles: jsonb("allowed_roles").$type().default([]), + deniedRoles: jsonb("denied_roles").$type().default([]), }); export type QuestionType = "text" | "textarea" | "radio" | "checkbox"; diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 082736f..be5acee 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -71,6 +71,13 @@ "when": 1782731954122, "tag": "0009_slippery_nomad", "breakpoints": true + }, + { + "idx": 10, + "version": "7", + "when": 1783089081925, + "tag": "0010_rainy_speedball", + "breakpoints": true } ] } \ No newline at end of file