refactor: streamline authentication handling and remove deprecated components, enhancing session management in Next.js middleware
This commit is contained in:
parent
effa9ad026
commit
c6e7f6bb78
8 changed files with 173 additions and 231 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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've found a page that doesn't exist. But don'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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
11
servers/nextjs/app/schema/layout.tsx
Normal file
11
servers/nextjs/app/schema/layout.tsx
Normal 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}</>;
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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}</>;
|
||||
}
|
||||
|
|
@ -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*"],
|
||||
};
|
||||
|
|
|
|||
78
servers/nextjs/utils/serverAuth.ts
Normal file
78
servers/nextjs/utils/serverAuth.ts
Normal 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 layout’s 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");
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue