diff --git a/SKILL.md b/SKILL.md
index d08410e..cc1e65e 100644
--- a/SKILL.md
+++ b/SKILL.md
@@ -31,9 +31,9 @@ This successfully provides a fully responsive, easily configurable, and containe
## 5. Form Module Structure
- **Public Form Viewer:** Located at `app/form/page.tsx` and `app/form/client.tsx`. This is where users see and submit the form. It renders questions and handles user input with validation.
-- **Admin Form Builder:** Located at `app/form/admin/page.tsx` and `app/form/admin/client.tsx`. This interface allows administrators to create and edit questions, change form settings, and specify input types (Text, Textarea, Radio, Checkboxes).
+- **Admin Form Builder:** Located under `app/admin/form/`. This interface allows administrators to create and edit questions, change form settings, and specify input types (Text, Textarea, Radio, Checkboxes).
- **Database Schema:** Defined in `db/schema/form.ts`, which contains definitions for `forms`, `questions`, `submissions`, and `answers`.
-- **Server Actions:** Backend logic for managing the form (such as creating/updating questions and handling submissions) are located in `app/actions/form.ts`, `app/actions/questions.ts`, and `app/actions/submissions.ts`.
+- **Server Actions:** Backend logic for managing forms and submissions is located in `app/admin/form/actions.ts`, `app/actions/questions.ts`, and `app/actions/submissions.ts`. Shared authorization lives in `lib/auth.ts`.
Behavioral guidelines to reduce common LLM coding mistakes. Merge with project-specific instructions as needed.
@@ -97,4 +97,4 @@ Strong success criteria let you loop independently. Weak criteria ("make it work
---
-**These guidelines are working if:** fewer unnecessary changes in diffs, fewer rewrites due to overcomplication, and clarifying questions come before implementation rather than after mistakes.
\ No newline at end of file
+**These guidelines are working if:** fewer unnecessary changes in diffs, fewer rewrites due to overcomplication, and clarifying questions come before implementation rather than after mistakes.
diff --git a/app/actions/discord.ts b/app/actions/discord.ts
index 46d503d..05d41fa 100644
--- a/app/actions/discord.ts
+++ b/app/actions/discord.ts
@@ -1,149 +1,9 @@
"use server";
-import { getServerSession } from "next-auth";
-import { authOptions } from "@/app/api/auth/[...nextauth]/route";
+import { requireAdmin } from "@/lib/auth";
+import { getGuildRolesInternal, type DiscordRole } from "@/lib/discord";
-export interface DiscordRole {
- id: string;
- name: string;
- color: number;
- position: number;
-}
-
-// --- In-memory caches with TTL ---
-interface CacheEntry { data: T; expiresAt: number }
-const rolesCache: { entry: CacheEntry | null } = { entry: null };
-const profileCache = new Map>();
-const CACHE_TTL = 60_000; // 60 seconds
-
-function getCached(entry: CacheEntry | null | undefined): T | null {
- if (entry && Date.now() < entry.expiresAt) return entry.data;
- return null;
-}
-
-/** Internal: fetch guild roles without auth check (for use by other server functions). */
-async function getGuildRolesInternal(): Promise {
- const cached = getCached(rolesCache.entry);
- if (cached) return cached;
-
- const token = process.env.DISCORD_BOT_TOKEN;
- const guildId = process.env.DISCORD_GUILD_ID;
- if (!token || !guildId) return [];
-
- try {
- const res = await fetch(`https://discord.com/api/v10/guilds/${guildId}/roles`, {
- headers: { Authorization: `Bot ${token}` },
- signal: AbortSignal.timeout(5000),
- });
-
- if (!res.ok) return [];
-
- const roles: any[] = await res.json();
- const result = roles.map(r => ({
- id: r.id,
- name: r.name,
- color: r.color,
- position: r.position,
- })).sort((a, b) => b.position - a.position);
-
- rolesCache.entry = { data: result, expiresAt: Date.now() + CACHE_TTL };
- return result;
- } catch {
- return [];
- }
-}
-
-/** Public: fetch guild roles with admin auth check. */
export async function getGuildRoles(): Promise {
- 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");
- }
-
+ await requireAdmin();
return getGuildRolesInternal();
}
-
-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
- signal: AbortSignal.timeout(5000),
- });
-
- if (!res.ok) return [];
-
- const member = await res.json();
- return member.roles || [];
- } catch {
- return [];
- }
-}
-
-export async function getDiscordMemberProfile(discordId: string) {
- const cached = getCached(profileCache.get(discordId));
- if (cached) return cached;
-
- const token = process.env.DISCORD_BOT_TOKEN;
- const guildId = process.env.DISCORD_GUILD_ID;
- if (!token || !guildId || !discordId) return null;
-
- try {
- const [memberRes, allRoles] = await Promise.all([
- fetch(`https://discord.com/api/v10/guilds/${guildId}/members/${discordId}`, {
- headers: { Authorization: `Bot ${token}` },
- cache: "no-store",
- signal: AbortSignal.timeout(5000),
- }),
- getGuildRolesInternal(),
- ]);
-
- if (!memberRes.ok) return null;
- const member = await memberRes.json();
-
- // Map member role IDs to actual role objects
- const memberRoleIds = member.roles || [];
- const roles = memberRoleIds
- .map((id: string) => allRoles.find(r => r.id === id))
- .filter(Boolean);
-
- // Sort roles by position (descending)
- roles.sort((a: any, b: any) => (b?.position || 0) - (a?.position || 0));
-
- const user = member.user;
-
- let avatarUrl = null;
- if (user.avatar) {
- const ext = user.avatar.startsWith("a_") ? "gif" : "png";
- avatarUrl = `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}.${ext}?size=128`;
- }
-
- let bannerUrl = null;
- if (user.banner) {
- const ext = user.banner.startsWith("a_") ? "gif" : "png";
- bannerUrl = `https://cdn.discordapp.com/banners/${user.id}/${user.banner}.${ext}?size=512`;
- }
-
- const result = {
- id: user.id,
- username: user.username,
- globalName: user.global_name || null,
- avatarUrl,
- bannerUrl,
- accentColor: user.accent_color || null,
- roles,
- };
-
- profileCache.set(discordId, { data: result, expiresAt: Date.now() + CACHE_TTL });
- return result;
- } catch (e) {
- console.error("Failed to fetch member profile", e);
- return null;
- }
-}
diff --git a/app/actions/questions.ts b/app/actions/questions.ts
index 39e7f3f..b78e042 100644
--- a/app/actions/questions.ts
+++ b/app/actions/questions.ts
@@ -7,17 +7,8 @@ import { questions } from "@/db/schema";
import type { QuestionType } from "@/db/schema";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
-import { getServerSession } from "next-auth";
-import { authOptions } from "@/app/api/auth/[...nextauth]/route";
-
-async function assertAdmin() {
- 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;
- if (!discordId || !adminIds.includes(discordId)) {
- throw new Error("Unauthorized");
- }
-}
+import { requireAdmin } from "@/lib/auth";
+import { sanitizeHtml } from "@/lib/sanitize-html";
export async function createQuestion(
formId: string,
@@ -29,18 +20,18 @@ export async function createQuestion(
options?: string[];
}
) {
- await assertAdmin();
+ await requireAdmin();
await db.insert(questions).values({
formId,
type: data.type,
- label: data.label,
+ label: sanitizeHtml(data.label),
required: data.required,
displayOrder: data.displayOrder,
options: data.options ?? [],
allowOther: false,
});
revalidatePath("/form");
- revalidatePath("/form/admin");
+ revalidatePath("/admin/form");
}
export async function updateQuestion(
@@ -56,7 +47,7 @@ export async function updateQuestion(
allowOther: boolean;
}>
) {
- await assertAdmin();
+ await requireAdmin();
if (data.imageUrl !== undefined) {
const q = await db.query.questions.findFirst({
where: (q, { eq }) => eq(q.id, id),
@@ -65,9 +56,14 @@ export async function updateQuestion(
await deleteImageFile(q.imageUrl);
}
}
- await db.update(questions).set(data).where(eq(questions.id, id));
+ const nextData = {
+ ...data,
+ label: data.label === undefined ? undefined : sanitizeHtml(data.label),
+ };
+
+ await db.update(questions).set(nextData).where(eq(questions.id, id));
revalidatePath("/form");
- revalidatePath("/form/admin");
+ revalidatePath("/admin/form");
}
export async function bulkUpdateQuestions(
@@ -82,7 +78,7 @@ export async function bulkUpdateQuestions(
allowOther: boolean;
}[]
) {
- await assertAdmin();
+ await requireAdmin();
// Use Promise.all to update all questions concurrently
await Promise.all(
@@ -90,7 +86,7 @@ export async function bulkUpdateQuestions(
db
.update(questions)
.set({
- label: u.label,
+ label: sanitizeHtml(u.label),
type: u.type,
required: u.required,
displayOrder: u.displayOrder,
@@ -102,7 +98,7 @@ export async function bulkUpdateQuestions(
);
revalidatePath("/form");
- revalidatePath("/form/admin");
+ revalidatePath("/admin/form");
}
async function deleteImageFile(imageUrl: string | null) {
@@ -119,7 +115,7 @@ async function deleteImageFile(imageUrl: string | null) {
}
export async function deleteQuestion(id: string, formId: string) {
- await assertAdmin();
+ await requireAdmin();
const q = await db.query.questions.findFirst({
where: (q, { eq }) => eq(q.id, id),
});
@@ -128,14 +124,14 @@ export async function deleteQuestion(id: string, formId: string) {
}
await db.delete(questions).where(eq(questions.id, id));
revalidatePath("/form");
- revalidatePath("/form/admin");
+ revalidatePath(`/admin/form/${formId}`);
}
export async function reorderQuestions(
formId: string,
orderedIds: string[]
) {
- await assertAdmin();
+ await requireAdmin();
await Promise.all(
orderedIds.map((id, index) =>
db
@@ -145,5 +141,5 @@ export async function reorderQuestions(
)
);
revalidatePath("/form");
- revalidatePath("/form/admin");
+ revalidatePath(`/admin/form/${formId}`);
}
diff --git a/app/actions/submissions.ts b/app/actions/submissions.ts
index 38846ee..44c883d 100644
--- a/app/actions/submissions.ts
+++ b/app/actions/submissions.ts
@@ -6,6 +6,7 @@ import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
+import { requireAdmin, requireDiscordId, requireFormAccess } from "@/lib/auth";
export async function submitForm(
formId: string,
@@ -14,85 +15,76 @@ export async function submitForm(
const session = await getServerSession(authOptions);
if (!session?.user) throw new Error("Not authenticated");
- const discordId = (session.user as { discordId?: string }).discordId;
- if (!discordId) throw new Error("No Discord ID found");
-
- const form = await db.query.forms.findFirst({
- where: (f, { eq }) => eq(f.id, formId),
- });
- if (!form) throw new Error("Form not found");
+ const discordId = await requireDiscordId();
+ const userName = session.user.name ?? null;
+ const form = await requireFormAccess(formId, discordId);
if (!form.isOpen) throw new Error("This form is disabled for now");
- const existingSubmission = await db.query.submissions.findFirst({
- where: (s, { eq, and }) =>
- and(eq(s.formId, formId), eq(s.userDiscordId, discordId)),
- });
-
- let submissionId = "";
- const isUpdate = !!existingSubmission;
- let oldAnswersDict: Record = {};
-
- if (existingSubmission) {
- submissionId = existingSubmission.id;
-
- // Fetch old answers
- const oldAnswersList = await db.query.answers.findMany({
- where: (a, { eq }) => eq(a.submissionId, submissionId),
+ const result = await db.transaction(async (tx) => {
+ const existingSubmission = await tx.query.submissions.findFirst({
+ where: (submission, { eq, and }) =>
+ and(eq(submission.formId, formId), eq(submission.userDiscordId, discordId)),
});
- oldAnswersDict = oldAnswersList.reduce((acc, curr) => {
- acc[curr.questionId] = curr.value;
- return acc;
- }, {} as Record);
+ let submissionId = "";
+ const isUpdate = Boolean(existingSubmission);
+ let oldAnswersDict: Record = {};
- // Ensure editHistory is an array
- let history = existingSubmission.editHistory;
- if (!Array.isArray(history)) {
- history = [];
+ if (existingSubmission) {
+ submissionId = existingSubmission.id;
+
+ const oldAnswersList = await tx.query.answers.findMany({
+ where: (answer, { eq }) => eq(answer.submissionId, submissionId),
+ });
+
+ oldAnswersDict = oldAnswersList.reduce>((acc, curr) => {
+ acc[curr.questionId] = curr.value;
+ return acc;
+ }, {});
+
+ const history = Array.isArray(existingSubmission.editHistory)
+ ? [...existingSubmission.editHistory]
+ : [];
+ history.push({
+ editedAt: new Date().toISOString(),
+ oldAnswers: oldAnswersDict,
+ });
+
+ await tx
+ .update(submissions)
+ .set({ editHistory: history })
+ .where(eq(submissions.id, submissionId));
+
+ await tx.delete(answers).where(eq(answers.submissionId, submissionId));
+ } else {
+ const [inserted] = await tx
+ .insert(submissions)
+ .values({
+ formId,
+ userDiscordId: discordId,
+ userName,
+ })
+ .returning();
+ submissionId = inserted.id;
}
- // Push the old answers to history
- history.push({
- editedAt: new Date().toISOString(),
- oldAnswers: oldAnswersDict,
- });
+ if (answersList.length > 0) {
+ await tx.insert(answers).values(
+ answersList.map((answer) => ({
+ submissionId,
+ questionId: answer.questionId,
+ value: answer.value,
+ }))
+ );
+ }
- // Update submission record
- await db
- .update(submissions)
- .set({
- editHistory: history,
- })
- .where(eq(submissions.id, submissionId));
-
- // Delete old answers
- await db.delete(answers).where(eq(answers.submissionId, submissionId));
- } else {
- // Insert new submission
- const [inserted] = await db
- .insert(submissions)
- .values({
- formId,
- userDiscordId: discordId,
- userName: session.user.name ?? null,
- })
- .returning();
- submissionId = inserted.id;
- }
-
- // Insert new/updated answers
- if (answersList.length > 0) {
- await db.insert(answers).values(
- answersList.map((a) => ({
- submissionId,
- questionId: a.questionId,
- value: a.value,
- }))
- );
- }
+ return { submissionId, isUpdate, oldAnswersDict };
+ });
// Send Discord Webhook
- const templateToUse = isUpdate ? (form as any).discordWebhookUpdateTemplate : form.discordWebhookTemplate;
+ const templateToUse = result.isUpdate
+ ? form.discordWebhookUpdateTemplate
+ : form.discordWebhookTemplate;
if (form.discordWebhookUrl && templateToUse) {
try {
@@ -114,10 +106,10 @@ export async function submitForm(
}, {} as Record);
let updatesText = "";
- if (isUpdate) {
+ if (result.isUpdate) {
const changed: string[] = [];
for (const ans of answersList) {
- const oldVal = oldAnswersDict[ans.questionId];
+ const oldVal = result.oldAnswersDict[ans.questionId];
if (oldVal !== ans.value) {
const qTitle = questionsList.find(q => q.id === ans.questionId)?.label || `Question`;
changed.push(`**${qTitle}**: \`${oldVal || "Empty"}\` ➡️ \`${ans.value || "Empty"}\``);
@@ -156,22 +148,20 @@ export async function submitForm(
}
}
- revalidatePath("/form/admin/result");
+ revalidatePath(`/admin/form/${formId}/result`);
return { ok: true };
}
export async function deleteSubmission(id: string) {
- const session = await getServerSession(authOptions);
- if (!session?.user) throw new Error("Not authenticated");
+ await requireAdmin();
await db.delete(submissions).where(eq(submissions.id, id));
- revalidatePath("/form/admin/result");
+ revalidatePath("/admin/form");
return { ok: true };
}
export async function deleteAllSubmissions(formId: string) {
- const session = await getServerSession(authOptions);
- if (!session?.user) throw new Error("Not authenticated");
+ await requireAdmin();
await db.delete(submissions).where(eq(submissions.formId, formId));
revalidatePath(`/admin/form/${formId}/result`);
@@ -179,11 +169,7 @@ export async function deleteAllSubmissions(formId: string) {
}
export async function deleteOwnSubmission(formId: string) {
- const session = await getServerSession(authOptions);
- if (!session?.user) throw new Error("Not authenticated");
-
- const discordId = (session.user as { discordId?: string }).discordId;
- if (!discordId) throw new Error("No Discord ID found");
+ const discordId = await requireDiscordId();
const { and } = await import("drizzle-orm");
@@ -194,6 +180,6 @@ export async function deleteOwnSubmission(formId: string) {
)
);
revalidatePath(`/form`);
- revalidatePath("/form/admin/result");
+ revalidatePath(`/admin/form/${formId}/result`);
return { ok: true };
}
diff --git a/app/admin/backend/actions.ts b/app/admin/backend/actions.ts
index d8b7284..a299127 100644
--- a/app/admin/backend/actions.ts
+++ b/app/admin/backend/actions.ts
@@ -3,6 +3,8 @@
import { revalidateTag } from "next/cache";
const validPlatforms = ["youtube", "roblox", "discord", "tiktok"];
+type FollowerFetcher = () => Promise;
+
export async function refetchPlatform(
platform: string
): Promise<{ count: number | null }> {
@@ -15,10 +17,10 @@ export async function refetchPlatform(
let tiktokProfile = null;
try {
- const mod = await import(`@/lib/followers/${platform}`);
+ const mod = (await import(`@/lib/followers/${platform}`)) as Record;
const fns = Object.entries(mod).filter(
- ([, v]) => typeof v === "function"
- ) as [string, Function][];
+ (entry): entry is [string, FollowerFetcher] => typeof entry[1] === "function"
+ );
// Prefer function with "Count" or "Member" in name
const countFn = fns.find(([name]) => /count|member/i.test(name));
@@ -33,7 +35,8 @@ export async function refetchPlatform(
} else if (
result &&
typeof result === "object" &&
- "followerCount" in result
+ "followerCount" in result &&
+ (typeof result.followerCount === "number" || result.followerCount === null)
) {
count = result.followerCount;
tiktokProfile = result;
diff --git a/app/admin/form/[id]/client.tsx b/app/admin/form/[id]/client.tsx
index cd6dbec..9e90c6a 100644
--- a/app/admin/form/[id]/client.tsx
+++ b/app/admin/form/[id]/client.tsx
@@ -39,6 +39,7 @@ import { RichTextEditor } from "@/components/rich-text-editor";
import { FormattedText } from "@/components/formatted-text";
import { ThemeToggle } from "@/components/theme-toggle";
import { DropdownMenuAvatar, DiscordMemberProfile } from "@/components/dropdown-menu-avatar";
+import { getErrorMessage } from "@/lib/errors";
interface Question {
id: string;
@@ -84,10 +85,12 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC
const formIsOpenRef = useRef(formIsOpen);
const questionsRef = useRef(questions);
- formTitleRef.current = formTitle;
- formDescriptionRef.current = formDescription;
- formIsOpenRef.current = formIsOpen;
- questionsRef.current = questions;
+ useEffect(() => {
+ formTitleRef.current = formTitle;
+ formDescriptionRef.current = formDescription;
+ formIsOpenRef.current = formIsOpen;
+ questionsRef.current = questions;
+ }, [formDescription, formIsOpen, formTitle, questions]);
const autoSaveForm = useCallback(() => {
if (formTimerRef.current) clearTimeout(formTimerRef.current);
@@ -170,7 +173,7 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC
options: [],
});
router.refresh();
- } catch (err: any) {
+ } catch (err: unknown) {
console.error("Failed to add question:", err);
}
};
@@ -186,7 +189,7 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC
try {
await deleteQuestion(id, form.id);
router.refresh();
- } catch (err: any) {
+ } catch (err: unknown) {
console.error("Failed to delete question:", err);
}
};
@@ -243,7 +246,7 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC
{
loading: "Uploading image...",
success: "Image uploaded!",
- error: (err: any) => err.message || "Failed to upload image.",
+ error: (err: unknown) => getErrorMessage(err, "Failed to upload image."),
}
);
};
@@ -259,7 +262,7 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC
{
loading: "Removing image...",
success: "Image removed.",
- error: (err: any) => err.message || "Failed to remove image.",
+ error: (err: unknown) => getErrorMessage(err, "Failed to remove image."),
}
);
};
@@ -283,8 +286,9 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC
handleUpdateQuestion(question.id, { options });
};
- React.useEffect(() => {
- setQuestions(initialQuestions);
+ useEffect(() => {
+ const timeout = setTimeout(() => setQuestions(initialQuestions), 0);
+ return () => clearTimeout(timeout);
}, [initialQuestions]);
return (
@@ -360,7 +364,7 @@ export default function FormEditorClient({ form, initialQuestions }: FormEditorC
)}
diff --git a/app/admin/form/[id]/extra/client.tsx b/app/admin/form/[id]/extra/client.tsx
index 0cfcfbe..dd657f9 100644
--- a/app/admin/form/[id]/extra/client.tsx
+++ b/app/admin/form/[id]/extra/client.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useState, useTransition, useEffect, useRef } from "react";
+import { useCallback, useState, useTransition, useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
@@ -10,7 +10,8 @@ import { Label } from "@/components/ui/label";
import { toast } from "sonner";
import { updateForm } from "@/app/admin/form/actions";
import { Loader2, ChevronDown, Check } from "lucide-react";
-import { getGuildRoles, DiscordRole } from "@/app/actions/discord";
+import { getGuildRoles } from "@/app/actions/discord";
+import type { DiscordRole } from "@/lib/discord";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
@@ -18,6 +19,7 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { ScrollArea } from "@/components/ui/scroll-area"
+import { getErrorMessage } from "@/lib/errors";
interface FormExtraClientProps {
form: {
@@ -43,7 +45,7 @@ export default function FormExtraClient({ form }: FormExtraClientProps) {
const [roles, setRoles] = useState([]);
const [loadingRoles, setLoadingRoles] = useState(true);
- const [isSaving, startSaveTransition] = useTransition();
+ const [, startSaveTransition] = useTransition();
const [saveStatus, setSaveStatus] = useState("idle");
const [lastEdited, setLastEdited] = useState<"access" | "webhook" | null>(null);
@@ -57,7 +59,7 @@ export default function FormExtraClient({ form }: FormExtraClientProps) {
.finally(() => setLoadingRoles(false));
}, []);
- const handleSave = (silent = false) => {
+ const handleSave = useCallback(() => {
setSaveStatus("saving");
startSaveTransition(async () => {
try {
@@ -71,12 +73,21 @@ export default function FormExtraClient({ form }: FormExtraClientProps) {
setSaveStatus("saved");
setTimeout(() => setSaveStatus("idle"), 2000);
router.refresh();
- } catch (err: any) {
+ } catch (err: unknown) {
setSaveStatus("idle");
- toast.error(err.message || "Failed to update settings.");
+ toast.error(getErrorMessage(err, "Failed to update settings."));
}
});
- };
+ }, [
+ allowedRoles,
+ deniedRoles,
+ form.id,
+ router,
+ startSaveTransition,
+ webhookTemplate,
+ webhookUpdateTemplate,
+ webhookUrl,
+ ]);
useEffect(() => {
if (isFirstRender.current) {
@@ -87,13 +98,13 @@ export default function FormExtraClient({ form }: FormExtraClientProps) {
if (saveTimeout.current) clearTimeout(saveTimeout.current);
saveTimeout.current = setTimeout(() => {
- handleSave(true);
+ handleSave();
}, 1000);
return () => {
if (saveTimeout.current) clearTimeout(saveTimeout.current);
};
- }, [webhookUrl, webhookTemplate, webhookUpdateTemplate, allowedRoles, deniedRoles]);
+ }, [handleSave]);
const toggleRole = (list: string[], setList: (v: string[]) => void, roleId: string) => {
setLastEdited("access");
diff --git a/app/admin/form/[id]/layout.tsx b/app/admin/form/[id]/layout.tsx
index c6f75b3..6654053 100644
--- a/app/admin/form/[id]/layout.tsx
+++ b/app/admin/form/[id]/layout.tsx
@@ -3,7 +3,8 @@ import { AdminSidebar } from "./client";
import { Metadata } from "next";
import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
-import { getDiscordMemberProfile } from "@/app/actions/discord";
+import { getSessionDiscordId } from "@/lib/auth";
+import { getDiscordMemberProfile } from "@/lib/discord";
export const metadata: Metadata = {
title: {
@@ -22,7 +23,7 @@ export default async function AdminFormLayout(
const session = await getServerSession(authOptions);
let profile = null;
- const discordId = (session?.user as any)?.discordId;
+ const discordId = getSessionDiscordId(session);
if (discordId) {
profile = await getDiscordMemberProfile(discordId);
}
@@ -45,4 +46,4 @@ export default async function AdminFormLayout(
);
-}
\ No newline at end of file
+}
diff --git a/app/admin/form/[id]/result/[submissionId]/client.tsx b/app/admin/form/[id]/result/[submissionId]/client.tsx
index 89e859a..32c5b70 100644
--- a/app/admin/form/[id]/result/[submissionId]/client.tsx
+++ b/app/admin/form/[id]/result/[submissionId]/client.tsx
@@ -1,9 +1,8 @@
"use client";
-import React, { useState, useEffect, useRef } from "react";
+import React, { useState } from "react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
-import { Label } from "@/components/ui/label";
import {
ContextMenu,
ContextMenuTrigger,
@@ -14,7 +13,7 @@ import {
ContextMenuGroup
} from "@/components/ui/context-menu";
import { Copy, Check, Users, Calendar, Clock } from "lucide-react";
-import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
+import { ScrollArea } from "@/components/ui/scroll-area";
import Image from 'next/image';
import { stripHtml } from "@/lib/utils";
import { HtmlDisplay } from "@/components/html-display";
diff --git a/app/admin/form/actions.ts b/app/admin/form/actions.ts
index 39a3310..cd94f0b 100644
--- a/app/admin/form/actions.ts
+++ b/app/admin/form/actions.ts
@@ -5,49 +5,55 @@ import { forms } from "@/db/schema";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
-import { getServerSession } from "next-auth";
-import { authOptions } from "@/app/api/auth/[...nextauth]/route";
+import { requireAdmin } from "@/lib/auth";
+import { sanitizeHtml } from "@/lib/sanitize-html";
export async function createForm(formData: FormData) {
- 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;
- if (!discordId || !adminIds.includes(discordId)) {
- throw new Error("Unauthorized");
- }
+ await requireAdmin();
const title = formData.get("title") as string;
const description = formData.get("description") as string | null;
const [form] = await db
.insert(forms)
- .values({ title, description: description ?? null })
+ .values({ title, description: description ? sanitizeHtml(description) : null })
.returning();
- revalidatePath("/form/admin");
- redirect(`/form/admin/${form.id}/edit`);
+ revalidatePath("/admin/form");
+ redirect(`/admin/form/${form.id}`);
+}
+
+export async function createDefaultForm() {
+ await requireAdmin();
+
+ const [form] = await db
+ .insert(forms)
+ .values({ title: "New Form", description: "" })
+ .returning();
+
+ revalidatePath("/admin/form");
+ redirect(`/admin/form/${form.id}`);
}
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;
- if (!discordId || !adminIds.includes(discordId)) {
- throw new Error("Unauthorized");
- }
+ await requireAdmin();
- await db.update(forms).set(data).where(eq(forms.id, id));
+ const nextData = {
+ ...data,
+ description: data.description === undefined
+ ? undefined
+ : data.description
+ ? sanitizeHtml(data.description)
+ : data.description,
+ };
+
+ await db.update(forms).set(nextData).where(eq(forms.id, id));
revalidatePath("/form");
- revalidatePath("/form/admin");
+ revalidatePath("/admin/form");
}
export async function deleteForm(id: 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;
- if (!discordId || !adminIds.includes(discordId)) {
- throw new Error("Unauthorized");
- }
+ await requireAdmin();
await db.delete(forms).where(eq(forms.id, id));
revalidatePath("/admin/form");
diff --git a/app/admin/form/page.tsx b/app/admin/form/page.tsx
index bb0b246..764422b 100644
--- a/app/admin/form/page.tsx
+++ b/app/admin/form/page.tsx
@@ -11,9 +11,9 @@ import {
} from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { Plus } from "lucide-react";
-import { redirect } from "next/navigation";
import { DeleteFormButton } from "@/components/delete-buttons";
import { AdminShellServer } from "@/components/admin-shell-server";
+import { createDefaultForm } from "./actions";
export const dynamic = "force-dynamic";
@@ -32,15 +32,7 @@ export default async function AdminFormsPage() {
Manage your custom forms and view submissions.
-