Harden form auth and submission handling

This commit is contained in:
2026-07-09 23:53:40 +07:00 Unverified
parent 2c82d55839
commit 8c0cc4492b
43 changed files with 747 additions and 534 deletions
+3 -2
View File
@@ -1,11 +1,12 @@
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";
import { AdminShell } from "@/components/admin-shell";
export async function AdminShellServer({ children }: { children: React.ReactNode }) {
const session = await getServerSession(authOptions);
const discordId = (session?.user as any)?.discordId;
const discordId = getSessionDiscordId(session);
let profile = null;
if (discordId) {
+1 -2
View File
@@ -7,7 +7,6 @@ import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupLabel,
SidebarGroupContent,
SidebarMenu,
SidebarMenuItem,
@@ -113,4 +112,4 @@ export function AdminShell({
</div>
</SidebarProvider>
);
}
}
+4 -3
View File
@@ -2,15 +2,16 @@
import React, { useState } from "react";
import { Check, Copy } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Button, buttonVariants } from "@/components/ui/button";
import { copyToClipboard } from "@/lib/utils";
import { cn } from "@/lib/utils";
import type { VariantProps } from "class-variance-authority";
export interface CopyButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
text?: string | (() => string);
onCopy?: () => void;
iconOnly?: boolean;
variant?: any;
variant?: VariantProps<typeof buttonVariants>["variant"];
}
export const CopyButton = React.forwardRef<HTMLButtonElement, CopyButtonProps>(
@@ -51,4 +52,4 @@ export const CopyButton = React.forwardRef<HTMLButtonElement, CopyButtonProps>(
);
}
);
CopyButton.displayName = "CopyButton";
CopyButton.displayName = "CopyButton";
+7 -6
View File
@@ -28,6 +28,7 @@ import {
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Spinner } from "@/components/ui/spinner";
import { getErrorMessage } from "@/lib/errors";
interface DeleteFormButtonProps {
formId: string;
@@ -46,9 +47,9 @@ export function DeleteFormButton({ formId }: DeleteFormButtonProps) {
setDeleting(false);
return "Form deleted successfully!";
},
error: (err: any) => {
error: (err: unknown) => {
setDeleting(false);
return err.message || "Failed to delete form.";
return getErrorMessage(err, "Failed to delete form.");
},
});
};
@@ -102,9 +103,9 @@ export function DeleteSubmissionButton({ id, redirectUrl }: { id: string, redire
setLoading(false);
return "Submission deleted";
},
error: (err: any) => {
error: (err: unknown) => {
setLoading(false);
return err.message || "Failed to delete submission";
return getErrorMessage(err, "Failed to delete submission");
},
});
};
@@ -146,9 +147,9 @@ export function DeleteAllSubmissionsButton({ formId }: { formId: string }) {
setLoading(false);
return "All submissions deleted";
},
error: (err: any) => {
error: (err: unknown) => {
setLoading(false);
return err.message || "Failed to delete submissions";
return getErrorMessage(err, "Failed to delete submissions");
},
});
};
+12 -17
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useMemo } from "react";
import { useState } from "react";
import { usePathname } from "next/navigation";
import Image from "next/image";
import { LogOut } from "lucide-react";
@@ -12,7 +12,7 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
import { DiscordRole } from "@/app/actions/discord";
import type { DiscordRole } from "@/lib/discord";
export interface DiscordMemberProfile {
id: string;
@@ -60,20 +60,15 @@ export function DropdownMenuAvatar({
const displayName = profile?.globalName || userName;
const username = profile?.username || userName;
const bannerStyle = useMemo(
() => ({
height: 100,
backgroundColor: profile?.accentColor
? toHex(profile.accentColor)
: "hsl(var(--muted))",
backgroundImage: profile?.bannerUrl
? `url(${profile.bannerUrl})`
: "none",
backgroundSize: "cover" as const,
backgroundPosition: "center" as const,
}),
[profile?.accentColor, profile?.bannerUrl]
);
const bannerStyle = {
height: 100,
backgroundColor: profile?.accentColor
? toHex(profile.accentColor)
: "hsl(var(--muted))",
backgroundImage: profile?.bannerUrl ? `url(${profile.bannerUrl})` : "none",
backgroundSize: "cover" as const,
backgroundPosition: "center" as const,
};
return (
<DropdownMenu>
@@ -163,4 +158,4 @@ export function DropdownMenuAvatar({
</DropdownMenuContent>
</DropdownMenu>
);
}
}
+2 -1
View File
@@ -1,5 +1,6 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import { sanitizeHtml } from "@/lib/sanitize-html"
export interface HtmlDisplayProps extends React.HTMLAttributes<HTMLDivElement> {
html?: string | null;
@@ -11,7 +12,7 @@ export function HtmlDisplay({ html, className, ...props }: HtmlDisplayProps) {
return (
<div
className={cn("prose prose-sm dark:prose-invert max-w-none [&>p]:m-0 leading-snug", className)}
dangerouslySetInnerHTML={{ __html: html }}
dangerouslySetInnerHTML={{ __html: sanitizeHtml(html) }}
{...props}
/>
)
+11 -8
View File
@@ -1,6 +1,6 @@
"use client"
import { Suspense, useEffect, useState } from "react"
import { Suspense, useEffect, useRef, useState } from "react"
import { usePathname, useSearchParams } from "next/navigation"
import { Progress } from "@/components/ui/progress"
@@ -9,6 +9,7 @@ function PageProgressContent() {
const searchParams = useSearchParams()
const [progress, setProgress] = useState(0)
const [isVisible, setIsVisible] = useState(false)
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
// Start progress on click
useEffect(() => {
@@ -39,9 +40,7 @@ function PageProgressContent() {
return prev + (95 - prev) * 0.1
})
}, 100)
// Store interval so we can clear it on route change
;(window as any).__page_progress_interval = interval
intervalRef.current = interval
}
}
@@ -51,17 +50,21 @@ function PageProgressContent() {
// Finish progress on route change
useEffect(() => {
if (typeof window !== "undefined" && (window as any).__page_progress_interval) {
clearInterval((window as any).__page_progress_interval)
if (intervalRef.current) {
clearInterval(intervalRef.current)
intervalRef.current = null
}
if (isVisible) {
setProgress(100)
const finishTimeout = setTimeout(() => setProgress(100), 0)
const timeout = setTimeout(() => {
setIsVisible(false)
setTimeout(() => setProgress(0), 300) // Reset after hiding
}, 400)
return () => clearTimeout(timeout)
return () => {
clearTimeout(finishTimeout)
clearTimeout(timeout)
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pathname, searchParams])
+13 -4
View File
@@ -10,6 +10,7 @@ import { Bold, Italic, Underline as UnderlineIcon, Strikethrough, Link as LinkIc
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { sanitizeHtml, sanitizeUrl } from "@/lib/sanitize-html";
interface RichTextEditorProps {
value: string;
@@ -18,7 +19,7 @@ interface RichTextEditorProps {
className?: string;
}
export function RichTextEditor({ value, onChange, placeholder, className }: RichTextEditorProps) {
export function RichTextEditor({ value, onChange, className }: RichTextEditorProps) {
const [isLinkDialogOpen, setIsLinkDialogOpen] = useState(false);
const [linkUrl, setLinkUrl] = useState("");
const [linkText, setLinkText] = useState("");
@@ -40,7 +41,7 @@ export function RichTextEditor({ value, onChange, placeholder, className }: Rich
// Get HTML output. If empty, maybe output empty string to avoid empty p tags.
let html = editor.getHTML();
if (html === "<p></p>") html = "";
onChange(html);
onChange(sanitizeHtml(html));
},
editorProps: {
attributes: {
@@ -71,10 +72,18 @@ export function RichTextEditor({ value, onChange, placeholder, className }: Rich
if (linkUrl === "") {
editor.chain().focus().extendMarkRange("link").unsetLink().run();
} else {
const safeUrl = sanitizeUrl(linkUrl);
if (!safeUrl) {
return;
}
if (linkText) {
editor.chain().focus().insertContent(`<a href="${linkUrl}">${linkText}</a>`).run();
editor.chain().focus().insertContent({
type: "text",
text: linkText,
marks: [{ type: "link", attrs: { href: safeUrl } }],
}).run();
} else {
editor.chain().focus().extendMarkRange("link").setLink({ href: linkUrl }).run();
editor.chain().focus().extendMarkRange("link").setLink({ href: safeUrl }).run();
}
}
setIsLinkDialogOpen(false);
+6 -1
View File
@@ -1,7 +1,12 @@
"use client"
import { ThemeProvider as NextThemesProvider, type ThemeProviderProps } from "next-themes"
import type { ReactNode } from "react"
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
type Props = ThemeProviderProps & {
children: ReactNode;
}
export function ThemeProvider({ children, ...props }: Props) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}
+3 -2
View File
@@ -31,7 +31,8 @@ export function ThemeToggle() {
const [mounted, setMounted] = React.useState(false)
React.useEffect(() => {
setMounted(true)
const timeout = setTimeout(() => setMounted(true), 0)
return () => clearTimeout(timeout)
}, [])
// Once mounted, resolve "system" to its actual rendered theme for the icon
@@ -62,4 +63,4 @@ export function ThemeToggle() {
</DropdownMenuContent>
</DropdownMenu>
)
}
}