refactor: streamline authentication handling and remove deprecated components, enhancing session management in Next.js middleware

This commit is contained in:
sudipnext 2026-04-22 12:43:17 +05:45
parent effa9ad026
commit c6e7f6bb78
8 changed files with 173 additions and 231 deletions

View file

@ -1,16 +1,13 @@
import React from "react";
import ProtectedRouteGuard from "@/components/Auth/ProtectedRouteGuard";
import { requireAppSession } from "@/utils/serverAuth";
import { ConfigurationInitializer } from "../ConfigurationInitializer";
const layout = ({ children }: { children: React.ReactNode }) => {
export default async function Layout({ children }: { children: React.ReactNode }) {
await requireAppSession();
return (
<div>
<ProtectedRouteGuard>
<ConfigurationInitializer>{children}</ConfigurationInitializer>
</ProtectedRouteGuard>
<ConfigurationInitializer>{children}</ConfigurationInitializer>
</div>
);
};
export default layout;
}

View file

@ -1,6 +1,6 @@
import type { Metadata } from "next";
import localFont from "next/font/local";
import { Syne, Unbounded } from "next/font/google";
import { Syne } from "next/font/google";
import "./globals.css";
import { Providers } from "./providers";
import MixpanelInitializer from "./MixpanelInitializer";
@ -22,13 +22,6 @@ const syne = Syne({
variable: "--font-syne",
});
const unbounded = Unbounded({
subsets: ["latin"],
weight: ["400", "500", "600", "700", "800"],
variable: "--font-unbounded",
});
export const metadata: Metadata = {
metadataBase: new URL("https://presenton.ai"),
title: "Presenton - Open Source AI presentation generator",
@ -82,7 +75,7 @@ export default function RootLayout({
return (
<html lang="en">
<body
className={`${inter.variable} ${unbounded.variable} ${syne.variable} antialiased`}
className={`${inter.variable} ${syne.variable} antialiased`}
>
<Providers>
<MixpanelInitializer>

View file

@ -1,39 +1,51 @@
import React from 'react';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import type { Metadata } from "next";
import Link from "next/link";
import { Button } from "@/components/ui/button";
const NotFound = () => {
return (
<div className="min-h-screen flex flex-col items-center justify-center bg-gray-100 text-center p-6">
<div className="max-w-lg mx-auto bg-white shadow-md rounded-lg p-8">
<img
src="/404.svg"
alt="Page not found"
className="w-3/4 mx-auto mb-6"
/>
<h1 className="text-3xl font-bold text-gray-800 mb-4">
Oops! Page Not Found
</h1>
<p className="text-lg text-gray-600 mb-4">
It seems you've found a page that doesn't exist. But don't worry, every great presentation starts with a blank slide!
</p>
<div className="flex justify-center space-x-4 mb-8">
<Link href="/dashboard">
<Button className="bg-indigo-600 text-white px-6 py-2 rounded-md hover:bg-indigo-700">
Go to Homepage
</Button>
</Link>
<Link href="/contact">
<Button className="bg-gray-600 text-white px-6 py-2 rounded-md hover:bg-gray-700">
Contact Support
</Button>
</Link>
</div>
</div>
</div>
);
export const metadata: Metadata = {
title: "Page not found | Presenton",
};
export default NotFound;
/**
* Unknown routes only. Keep the 404.svg inside a fixed max height + object-contain
* so the illustration never scales to full-viewport (the old w-3/4-only layout could).
*/
export default function NotFound() {
return (
<div className="flex min-h-screen flex-col items-center justify-center bg-gray-100 p-6 text-center">
<div className="mx-auto w-full max-w-lg rounded-lg bg-white p-8 shadow-md">
<div className="mx-auto mb-6 flex h-48 w-full max-w-[300px] items-center justify-center overflow-hidden sm:h-56 sm:max-w-sm">
<img
src="/404.svg"
alt="Page not found"
width={500}
height={500}
className="h-full w-full object-contain object-center"
loading="eager"
decoding="async"
/>
</div>
<h1 className="mb-4 font-syne text-2xl font-bold text-gray-800 sm:text-3xl">
Oops! Page Not Found
</h1>
<p className="mb-4 text-base text-gray-600 sm:text-lg">
It seems you&apos;ve found a page that doesn&apos;t exist. But don&apos;t worry, every
great presentation starts with a blank slide!
</p>
<div className="mb-8 flex flex-col justify-center gap-3 sm:flex-row sm:space-x-4">
<Link href="/dashboard" className="inline-flex sm:flex-1 sm:justify-center">
<Button className="w-full rounded-md bg-indigo-600 px-6 py-2 text-white hover:bg-indigo-700 sm:w-auto">
Go to Homepage
</Button>
</Link>
<Link href="/" className="inline-flex sm:flex-1 sm:justify-center">
<Button className="w-full rounded-md bg-gray-600 px-6 py-2 text-white hover:bg-gray-700 sm:w-auto">
Back to start
</Button>
</Link>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,11 @@
import type { ReactNode } from "react";
import { requireAppSession } from "@/utils/serverAuth";
/**
* /schema is outside the (presentation-generator) group; same session gate as the main app.
*/
export default async function SchemaLayout({ children }: { children: ReactNode }) {
await requireAppSession();
return <>{children}</>;
}

View file

@ -5,6 +5,7 @@ import { ConfigurationInitializer } from "@/app/ConfigurationInitializer";
import Home from "@/components/Home";
import { getApiUrl } from "@/utils/api";
import { formatFastApiDetail, UNAUTHORIZED_DETAIL } from "@/utils/authErrors";
import { toast } from "sonner";
type AuthStatus = {
configured: boolean;
@ -26,8 +27,6 @@ export default function AuthGate() {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [blockedAccessMessage, setBlockedAccessMessage] = useState<string | null>(null);
const isSetupMode = useMemo(() => !status.configured, [status.configured]);
useEffect(() => {
@ -40,9 +39,12 @@ export default function AuthGate() {
}
const params = new URLSearchParams(window.location.search);
if (params.get("reason") === "unauthorized") {
setBlockedAccessMessage(UNAUTHORIZED_DETAIL);
const clean = `${window.location.pathname}`;
window.history.replaceState({}, "", clean);
toast.error("Unauthorized", {
id: "auth-unauthorized-redirect",
description: "Sign in to view this page.",
duration: 5000,
});
window.history.replaceState({}, "", window.location.pathname);
}
}, []);
@ -78,7 +80,6 @@ export default function AuthGate() {
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setError(null);
setBlockedAccessMessage(null);
const cleanedUsername = username.trim();
if (cleanedUsername.length < 3) {
@ -154,7 +155,7 @@ export default function AuthGate() {
<div className="rounded-2xl border border-white/40 bg-white/80 p-8 text-center shadow-xl backdrop-blur-sm">
<img src="/Logo.png" alt="Presenton" className="mx-auto mb-5 h-12 opacity-95" />
<div className="mx-auto mb-4 h-1 w-16 rounded-full bg-gradient-to-r from-[#5146E5] to-[#7C51F8]" />
<h1 className="font-unbounded text-lg font-semibold text-black">Presenton</h1>
<h1 className="font-syne text-lg font-semibold text-black">Presenton</h1>
<p className="mt-3 font-syne text-sm text-[#000000CC]">Preparing your workspace</p>
<div className="mt-6 flex justify-center gap-1.5">
<span className="h-2 w-2 animate-pulse rounded-full bg-[#5146E5]" />
@ -203,7 +204,7 @@ export default function AuthGate() {
<p className="font-syne text-[10px] font-semibold uppercase tracking-[0.14em] text-[#7A5AF8]">
Secure instance
</p>
<h1 className="mt-1 font-unbounded text-2xl font-normal leading-tight text-black sm:text-[26px]">
<h1 className="mt-1 font-syne text-2xl font-semibold leading-tight text-black sm:text-[26px]">
{isSetupMode ? "Create your admin login" : "Sign in to continue"}
</h1>
</div>
@ -216,12 +217,6 @@ export default function AuthGate() {
: "This deployment is protected. Enter your credentials to open the app."}
</p>
{blockedAccessMessage ? (
<div className="mt-6 rounded-[11px] border border-red-200 bg-red-50 px-4 py-3 font-syne text-sm font-medium text-red-800">
{blockedAccessMessage}. All other routes require a valid session.
</div>
) : null}
<form onSubmit={handleSubmit} className="mt-8 space-y-5">
<div className="space-y-2">
<label htmlFor="username" className="block font-syne text-sm font-medium text-black">

View file

@ -1,77 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { usePathname, useRouter } from "next/navigation";
import { getApiUrl } from "@/utils/api";
/**
* Defense in depth: if a protected page ever renders without a valid session
* (stale tab, manual history navigation, etc.), send the user back to the
* login screen. Edge middleware is the primary gate; this catches client-only
* edge cases.
*/
export default function ProtectedRouteGuard({
children,
}: {
children: React.ReactNode;
}) {
const router = useRouter();
const pathname = usePathname();
const [allowed, setAllowed] = useState(false);
useEffect(() => {
let cancelled = false;
const verify = async () => {
try {
const response = await fetch(getApiUrl("/api/v1/auth/status"), {
method: "GET",
credentials: "include",
cache: "no-store",
});
if (!response.ok || cancelled) {
if (!cancelled) {
router.replace("/?reason=unauthorized");
}
return;
}
const data = (await response.json()) as {
authenticated?: boolean;
};
if (cancelled) {
return;
}
if (!data.authenticated) {
router.replace("/?reason=unauthorized");
return;
}
setAllowed(true);
} catch {
if (!cancelled) {
router.replace("/?reason=unauthorized");
}
}
};
void verify();
return () => {
cancelled = true;
};
}, [pathname, router]);
if (!allowed) {
return (
<div className="flex min-h-[40vh] items-center justify-center bg-gradient-to-br from-[#E9E8F8] via-[#F5F4FF] to-[#E0DFF7] font-syne text-sm text-[#494A4D]">
Verifying session
</div>
);
}
return <>{children}</>;
}

View file

@ -1,73 +1,22 @@
import { NextRequest, NextResponse } from "next/server";
const PUBLIC_PATHS = new Set([
"/",
"/favicon.ico",
"/apple-icon.png",
"/icon1.svg",
"/icon2.png",
"/api/telemetry-status",
]);
const PUBLIC_PREFIXES = ["/_next/", "/api/v1/auth/"];
/**
* Build the URL the browser used to reach the app. When nginx proxies to
* Next on :3000, `request.nextUrl.origin` is often `http://localhost:3000`
* (wrong for redirects). Prefer reverse-proxy headers instead.
* API-only: session required for all /api/* except auth and telemetry.
* Page routes are protected in server layouts (unknown URLs still 404; login uses relative redirects).
*/
function getExternalOrigin(request: NextRequest): string {
const xfHost =
request.headers.get("x-forwarded-host")?.split(",")[0]?.trim() ?? "";
if (xfHost) {
const xfProto =
request.headers.get("x-forwarded-proto")?.split(",")[0]?.trim().toLowerCase() ??
"";
const proto =
xfProto === "https" || xfProto === "http" ? xfProto : "http";
return `${proto}://${xfHost}`;
}
return request.nextUrl.origin;
}
function isPublicRequest(pathname: string): boolean {
if (PUBLIC_PATHS.has(pathname)) {
return true;
}
if (PUBLIC_PREFIXES.some((prefix) => pathname.startsWith(prefix))) {
return true;
}
// Allow requests for static files in /public.
if (/\.[a-zA-Z0-9]+$/.test(pathname)) {
return true;
}
return false;
}
function getFastApiBaseUrl(request: NextRequest): string {
// Server-side-only override. Used by the Docker runtime so the Next.js
// middleware can reach FastAPI directly inside the container (nginx's
// port is not reachable from inside the Next.js process).
const internal = process.env.FAST_API_INTERNAL_URL?.trim();
if (internal) {
return internal.replace(/\/+$/, "");
}
const configured = process.env.NEXT_PUBLIC_FAST_API?.trim();
if (configured) {
return configured.replace(/\/+$/, "");
}
if (process.env.NODE_ENV === "development") {
return "http://127.0.0.1:8000";
}
// Fallback: reuse the incoming origin (works when Next.js and FastAPI
// are served from the same origin, e.g. behind nginx on the same host).
return request.nextUrl.origin;
return "http://127.0.0.1:8000";
}
type AuthStatus = {
@ -78,38 +27,35 @@ type AuthStatus = {
async function getAuthStatus(request: NextRequest): Promise<AuthStatus> {
const cookieHeader = request.headers.get("cookie");
const authStatusUrl = `${getFastApiBaseUrl(request)}/api/v1/auth/status`;
try {
const response = await fetch(authStatusUrl, {
method: "GET",
headers: cookieHeader ? { Cookie: cookieHeader } : undefined,
cache: "no-store",
});
if (!response.ok) {
return {
configured: true,
authenticated: false,
};
return { configured: true, authenticated: false };
}
const payload = (await response.json()) as Partial<AuthStatus>;
return {
configured: Boolean(payload.configured),
authenticated: Boolean(payload.authenticated),
};
} catch {
return {
configured: true,
authenticated: false,
};
return { configured: true, authenticated: false };
}
}
function isApiAuthExempt(pathname: string): boolean {
return (
pathname.startsWith("/api/v1/auth/") || pathname === "/api/telemetry-status"
);
}
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
if (request.method === "OPTIONS" || isPublicRequest(pathname)) {
if (request.method === "OPTIONS" || isApiAuthExempt(pathname)) {
return NextResponse.next();
}
@ -117,31 +63,18 @@ export async function middleware(request: NextRequest) {
if (authStatus.authenticated) {
return NextResponse.next();
}
if (pathname.startsWith("/api/")) {
const statusCode = authStatus.configured ? 401 : 428;
const detail = authStatus.configured
? "Unauthorized"
: "Login setup is required";
if (!authStatus.configured) {
return NextResponse.json(
{ detail },
{
status: statusCode,
headers: {
"Cache-Control": "no-store, must-revalidate",
},
}
{ detail: "Login setup is required", setup_required: true },
{ status: 428, headers: { "Cache-Control": "no-store" } }
);
}
const redirectUrl = new URL("/", getExternalOrigin(request));
if (pathname !== "/") {
redirectUrl.searchParams.set("reason", "unauthorized");
}
return NextResponse.redirect(redirectUrl);
return NextResponse.json(
{ detail: "Unauthorized" },
{ status: 401, headers: { "Cache-Control": "no-store" } }
);
}
export const config = {
matcher: ["/:path*"],
matcher: ["/api/:path*"],
};

View file

@ -0,0 +1,78 @@
import { headers } from "next/headers";
import { redirect } from "next/navigation";
type AuthStatus = {
configured: boolean;
authenticated: boolean;
username: string | null;
};
/**
* Resolves the FastAPI base used from Next server components (same as start.js).
*/
function getServerFastApiBase(): string {
const internal = process.env.FAST_API_INTERNAL_URL?.trim();
if (internal) {
return internal.replace(/\/+$/, "");
}
const fromEnv = process.env.NEXT_PUBLIC_FAST_API?.trim();
if (fromEnv) {
return fromEnv.replace(/\/+$/, "");
}
if (process.env.NODE_ENV === "development") {
return "http://127.0.0.1:8000";
}
return "http://127.0.0.1:8000";
}
/**
* Calls the same /api/v1/auth/status as the browser, using the incoming request cookies.
* Used by server layouts so 404/unknown routes are not conflated with unauthenticated access
* (the layout only runs for routes that exist and sit under the layouts segment).
*/
export async function getServerAuthStatus(): Promise<AuthStatus> {
const h = await headers();
const cookie = h.get("cookie") ?? "";
try {
const response = await fetch(`${getServerFastApiBase()}/api/v1/auth/status`, {
method: "GET",
headers: cookie ? { cookie } : undefined,
cache: "no-store",
});
if (!response.ok) {
return {
configured: true,
authenticated: false,
username: null,
};
}
const data = (await response.json()) as Partial<AuthStatus>;
return {
configured: Boolean(data.configured),
authenticated: Boolean(data.authenticated),
username: data.username ?? null,
};
} catch {
return {
configured: true,
authenticated: false,
username: null,
};
}
}
/**
* If credentials are not configured yet, send the user to `/` (setup in AuthGate).
* If configured but not signed in, send to login with a query flag the client turns into a toast.
*/
export async function requireAppSession() {
const s = await getServerAuthStatus();
if (!s.configured) {
redirect("/");
}
if (!s.authenticated) {
redirect("/?reason=unauthorized");
}
}