Harden form auth and submission handling
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarGroup,
|
||||
SidebarGroupLabel,
|
||||
SidebarGroupContent,
|
||||
SidebarMenu,
|
||||
SidebarMenuItem,
|
||||
@@ -113,4 +112,4 @@ export function AdminShell({
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user