Move auth protection to middleware and server layout

Replace client-side session checks (useSession + router.replace) with
a two-layer server-side approach: Next.js middleware performs a fast
cookie presence check for all protected routes, and the (pages) layout
does a full server-side session validation before rendering.

Move login/register into a dedicated (auth) route group with its own
minimal layout so they sit outside the authenticated shell. Lift the
Toaster to the root layout so it remains available across all route
groups.
This commit is contained in:
2026-03-02 14:38:54 -05:00
parent c47fe97733
commit f94ceaf233
9 changed files with 39 additions and 23 deletions
+3 -10
View File
@@ -8,7 +8,6 @@ import {
import { motion } from "motion/react";
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import { DashboardSkeleton } from "@/components/skeletons";
import { StatsSummary } from "@/components/stats-summary";
@@ -72,8 +71,7 @@ const sectionVariants = {
};
export default function DashboardPage() {
const { data: session, isPending } = useSession();
const router = useRouter();
const { data: session } = useSession();
const [continueWatching, setContinueWatching] = useState<
ContinueWatchingItem[]
>([]);
@@ -98,15 +96,10 @@ export default function DashboardPage() {
}, []);
useEffect(() => {
if (isPending) return;
if (!session?.user) {
router.replace("/login");
return;
}
fetchFeeds();
}, [session, isPending, router, fetchFeeds]);
}, [fetchFeeds]);
if (isPending || loading) {
if (loading) {
return <DashboardSkeleton />;
}
+7 -5
View File
@@ -1,17 +1,20 @@
"use client";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { CommandPalette } from "@/components/command-palette";
import { KeyboardHelpDialog } from "@/components/keyboard-help-dialog";
import { KeyboardProvider } from "@/components/keyboard-provider";
import { MobileTabBar } from "@/components/mobile-tab-bar";
import { NavBar } from "@/components/nav-bar";
import { Toaster } from "@/components/ui/sonner";
import { auth } from "@/lib/auth/server";
export default function PagesLayout({
export default async function PagesLayout({
children,
}: {
children: React.ReactNode;
}) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) redirect("/login");
return (
<KeyboardProvider>
<div className="min-h-screen overflow-x-hidden pb-14 sm:pb-0">
@@ -25,7 +28,6 @@ export default function PagesLayout({
<MobileTabBar />
<CommandPalette />
<KeyboardHelpDialog />
<Toaster position="bottom-right" />
</KeyboardProvider>
);
}
-9
View File
@@ -1,9 +0,0 @@
import { AuthForm } from "@/components/auth-form";
export default function LoginPage() {
return (
<div className="flex min-h-[80vh] items-center justify-center px-4">
<AuthForm mode="login" />
</div>
);
}
-69
View File
@@ -1,69 +0,0 @@
"use client";
import { IconLock } from "@tabler/icons-react";
import { motion } from "motion/react";
import Link from "next/link";
import { useEffect, useState } from "react";
import { AuthForm } from "@/components/auth-form";
export default function RegisterPage() {
const [registrationOpen, setRegistrationOpen] = useState<boolean | null>(
null,
);
useEffect(() => {
fetch("/api/registration/status")
.then((res) => res.json())
.then((data) => setRegistrationOpen(data.registrationOpen))
.catch(() => setRegistrationOpen(false));
}, []);
if (registrationOpen === null) {
return <div className="flex min-h-[80vh] items-center justify-center" />;
}
if (!registrationOpen) {
return (
<div className="flex min-h-[80vh] items-center justify-center px-4">
<div className="relative mx-auto w-full max-w-sm">
<div className="absolute -inset-4 rounded-2xl bg-primary/3 blur-2xl" />
<motion.div
className="relative space-y-6 rounded-xl border border-border/50 bg-card/80 p-8 text-center backdrop-blur-sm"
initial={{ opacity: 0, y: 20, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{
type: "spring" as const,
stiffness: 200,
damping: 20,
}}
>
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-primary/10">
<IconLock size={24} className="text-primary" />
</div>
<div className="space-y-2">
<h1 className="font-display text-xl tracking-tight">
Registration Closed
</h1>
<p className="text-sm text-muted-foreground">
New accounts are not being accepted right now. Contact the admin
if you need access.
</p>
</div>
<Link
href="/login"
className="inline-flex h-10 items-center rounded-lg bg-primary px-6 text-sm font-medium text-primary-foreground transition-all hover:shadow-md hover:shadow-primary/20"
>
Sign in instead
</Link>
</motion.div>
</div>
</div>
);
}
return (
<div className="flex min-h-[80vh] items-center justify-center px-4">
<AuthForm mode="register" />
</div>
);
}
+2 -6
View File
@@ -354,17 +354,13 @@ export default function SettingsPage() {
useEffect(() => {
if (isPending) return;
if (!session?.user) {
router.replace("/login");
return;
}
fetchWebhooks();
if (session.user.role === "admin") {
if (session?.user?.role === "admin") {
fetchSettings();
} else {
setLoadingSettings(false);
}
}, [session, isPending, router, fetchSettings, fetchWebhooks]);
}, [session, isPending, fetchSettings, fetchWebhooks]);
async function handleToggleRegistration(checked: boolean) {
setToggling(true);