feat(webui): move pages under [locale], compose intl + auth proxy
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
// Unmatched URLs inside a matched [locale] segment ("/en/typo") bubble to the
|
||||
// root _not-found otherwise — Next only renders a segment's not-found.tsx on
|
||||
// a programmatic notFound() call. This catch-all throws it so the translated
|
||||
// [locale]/not-found.tsx handles unknown paths (next-intl recommended pattern).
|
||||
export default function CatchAllPage() {
|
||||
notFound();
|
||||
}
|
||||
@@ -1,25 +1,37 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { hasLocale, NextIntlClientProvider } from "next-intl";
|
||||
import { NuqsAdapter } from "nuqs/adapters/next/app";
|
||||
import { ThemeProvider, ThemedToaster } from "@/providers/ThemeProvider";
|
||||
import { AuthRedirectInstaller } from "./components/AuthRedirectInstaller";
|
||||
import { AuthRedirectInstaller } from "../components/AuthRedirectInstaller";
|
||||
import { THEME_STORAGE_KEY } from "@/lib/theme";
|
||||
import { routing } from "@/i18n/routing";
|
||||
import "katex/dist/katex.min.css";
|
||||
import "@mescius/spread-sheets/styles/gc.spread.sheets.excel2013white.css";
|
||||
import localFont from "next/font/local";
|
||||
import "./globals.css";
|
||||
import "../globals.css";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { locale: rawLocale } = await params;
|
||||
const { getSystemConfig, brandingVersion } = await import(
|
||||
"@/lib/server/systemConfig"
|
||||
);
|
||||
const locale = hasLocale(routing.locales, rawLocale)
|
||||
? rawLocale
|
||||
: routing.defaultLocale;
|
||||
const t = await getTranslations({ locale, namespace: "common" });
|
||||
const config = getSystemConfig();
|
||||
const faviconVersion = brandingVersion("favicon");
|
||||
return {
|
||||
title: `${config.branding.wordmark} WebUI`,
|
||||
description:
|
||||
"Web UI for EvoScientist — a self-evolving AI scientist built on DeepAgents/LangGraph.",
|
||||
description: t("metadataDescription"),
|
||||
icons:
|
||||
faviconVersion > 0
|
||||
? { icon: `/api/system/branding/asset/favicon?v=${faviconVersion}` }
|
||||
@@ -38,7 +50,7 @@ export const viewport: Viewport = {
|
||||
// Self-hosted Inter variable font; exposes --font-inter, consumed by globals.css.
|
||||
// No CJK glyphs — Chinese text falls through to PingFang/YaHei in the stack.
|
||||
const inter = localFont({
|
||||
src: "./fonts/InterVariable.woff2",
|
||||
src: "../fonts/InterVariable.woff2",
|
||||
variable: "--font-inter",
|
||||
display: "swap",
|
||||
});
|
||||
@@ -50,14 +62,20 @@ const themeScript = `(function(){var k=${JSON.stringify(
|
||||
THEME_STORAGE_KEY
|
||||
)};var t="system";try{t=localStorage.getItem(k)||"system";}catch(_){}var d=t==="dark"||(t!=="light"&&window.matchMedia("(prefers-color-scheme: dark)").matches);var e=document.documentElement;e.classList.toggle("dark",d);e.style.colorScheme=d?"dark":"light";})();`;
|
||||
|
||||
export default function RootLayout({
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
params,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
if (!hasLocale(routing.locales, locale)) {
|
||||
notFound();
|
||||
}
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
lang={locale}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<body
|
||||
@@ -65,13 +83,15 @@ export default function RootLayout({
|
||||
className={inter.variable}
|
||||
>
|
||||
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
|
||||
<NuqsAdapter>
|
||||
<ThemeProvider>
|
||||
<AuthRedirectInstaller />
|
||||
{children}
|
||||
<ThemedToaster />
|
||||
</ThemeProvider>
|
||||
</NuqsAdapter>
|
||||
<NextIntlClientProvider locale={locale}>
|
||||
<NuqsAdapter>
|
||||
<ThemeProvider>
|
||||
<AuthRedirectInstaller />
|
||||
{children}
|
||||
<ThemedToaster />
|
||||
</ThemeProvider>
|
||||
</NuqsAdapter>
|
||||
</NextIntlClientProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
@@ -4,6 +4,7 @@ import { FormEvent, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { MarkdownContent } from "@/app/components/MarkdownContent";
|
||||
import { localizedPath } from "@/lib/localePath";
|
||||
import {
|
||||
DEFAULT_WORDMARK,
|
||||
logoSrc,
|
||||
@@ -70,7 +71,9 @@ export default function LoginPage() {
|
||||
}
|
||||
throw new Error(body.error || "Unable to sign in.");
|
||||
}
|
||||
window.location.replace(body.redirectTo || "/");
|
||||
window.location.replace(
|
||||
body.redirectTo || localizedPath("/", window.location.pathname)
|
||||
);
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : "Unable to sign in.");
|
||||
setSubmitting(false);
|
||||
@@ -0,0 +1,10 @@
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export default function NotFound() {
|
||||
const t = useTranslations("common");
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center bg-background p-5">
|
||||
<p className="text-sm text-muted-foreground">{t("notFound")}</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -55,6 +55,7 @@ import {
|
||||
usePublicSystemConfig,
|
||||
} from "@/app/hooks/usePublicSystemConfig";
|
||||
import type { MainChatReporter } from "@/lib/asyncAgents";
|
||||
import { localizedPath } from "@/lib/localePath";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const FALLBACK_ASSISTANT_ID = "EvoScientist";
|
||||
@@ -217,7 +218,7 @@ function HomePageInner({ authEnabled, isAdmin }: HomePageInnerProps) {
|
||||
credentials: "same-origin",
|
||||
});
|
||||
} finally {
|
||||
window.location.assign("/login");
|
||||
window.location.assign(localizedPath("/login", window.location.pathname));
|
||||
}
|
||||
}, []);
|
||||
const handleDashboardNav = useCallback(
|
||||
@@ -32,6 +32,14 @@ describe("shouldRedirectFor", () => {
|
||||
expect(shouldRedirectFor("/api/auth/me", 401, "/login")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not redirect when already on a localized login page", () => {
|
||||
expect(shouldRedirectFor("/api/threads", 401, "/zh/login")).toBe(false);
|
||||
});
|
||||
|
||||
it("still redirects for API 401s from a localized page", () => {
|
||||
expect(shouldRedirectFor("/api/threads", 401, "/zh")).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores unparseable urls", () => {
|
||||
expect(shouldRedirectFor("http://[", 401, "/")).toBe(false);
|
||||
});
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { localizedPath, stripLocalePrefix } from "@/lib/localePath";
|
||||
|
||||
export function shouldRedirectFor(
|
||||
url: string,
|
||||
status: number,
|
||||
pathname: string
|
||||
): boolean {
|
||||
if (status !== 401) return false;
|
||||
if (pathname.startsWith("/login")) return false;
|
||||
if (stripLocalePrefix(pathname).startsWith("/login")) return false;
|
||||
let path: string;
|
||||
try {
|
||||
path = new URL(url, "http://localhost").pathname;
|
||||
@@ -41,7 +43,9 @@ export function installAuthRedirect(): () => void {
|
||||
const next = encodeURIComponent(
|
||||
window.location.pathname + window.location.search
|
||||
);
|
||||
window.location.replace(`/login?next=${next}`);
|
||||
window.location.replace(
|
||||
`${localizedPath("/login", window.location.pathname)}?next=${next}`
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Redirect failures must not break the caller's response handling.
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
hasFileExtension,
|
||||
localePrefixOf,
|
||||
localizedPath,
|
||||
stripLocalePrefix,
|
||||
} from "./localePath";
|
||||
|
||||
describe("stripLocalePrefix", () => {
|
||||
it.each([
|
||||
["/zh/login", "/login"],
|
||||
["/en", "/"],
|
||||
["/zh", "/"],
|
||||
["/zh/", "/"],
|
||||
["/en/chat", "/chat"],
|
||||
["/login", "/login"],
|
||||
["/", "/"],
|
||||
["/fr/login", "/fr/login"],
|
||||
["/zho", "/zho"],
|
||||
["/api/system/config", "/api/system/config"],
|
||||
])("%s → %s", (input, expected) => {
|
||||
expect(stripLocalePrefix(input)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("localePrefixOf", () => {
|
||||
it.each([
|
||||
["/zh/login", "/zh"],
|
||||
["/zh", "/zh"],
|
||||
["/en/chat", "/en"],
|
||||
["/login", "/en"],
|
||||
["/", "/en"],
|
||||
])("%s → %s", (input, expected) => {
|
||||
expect(localePrefixOf(input)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("localizedPath", () => {
|
||||
it("prefixes a locale-less path with the current locale", () => {
|
||||
expect(localizedPath("/login", "/zh/chat")).toBe("/zh/login");
|
||||
expect(localizedPath("/", "/zh/chat")).toBe("/zh");
|
||||
expect(localizedPath("/login", "/chat")).toBe("/en/login");
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasFileExtension", () => {
|
||||
it.each([
|
||||
["/icon.png", true],
|
||||
["/evoscientist-logo.png", true],
|
||||
["/zh/chat", false],
|
||||
["/zh", false],
|
||||
["/api/workspace/render/x.html", true],
|
||||
])("%s → %s", (input, expected) => {
|
||||
expect(hasFileExtension(input)).toBe(expected);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { routing } from "@/i18n/routing";
|
||||
|
||||
const LOCALES_RE = new RegExp(`^/(${routing.locales.join("|")})(?=/|$)`);
|
||||
|
||||
/** "/zh/login" → "/login"; paths without an en/zh prefix are returned as-is. */
|
||||
export function stripLocalePrefix(pathname: string): string {
|
||||
const match = LOCALES_RE.exec(pathname);
|
||||
return match ? pathname.slice(match[0].length) || "/" : pathname;
|
||||
}
|
||||
|
||||
/** "/zh/x" → "/zh"; unprefixed paths yield the default locale's prefix. */
|
||||
export function localePrefixOf(pathname: string): string {
|
||||
const match = LOCALES_RE.exec(pathname);
|
||||
return match ? `/${match[1]}` : `/${routing.defaultLocale}`;
|
||||
}
|
||||
|
||||
/** Prefix a locale-less app path with the locale found in currentPathname. */
|
||||
export function localizedPath(path: string, currentPathname: string): string {
|
||||
return `${localePrefixOf(currentPathname)}${path === "/" ? "" : path}`;
|
||||
}
|
||||
|
||||
/** Static-asset lookalikes ("/icon.png") bypass the intl middleware. */
|
||||
export function hasFileExtension(pathname: string): boolean {
|
||||
return /\.[^/]+$/.test(pathname);
|
||||
}
|
||||
+33
-6
@@ -1,10 +1,13 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import createMiddleware from "next-intl/middleware";
|
||||
import {
|
||||
AUTH_COOKIE_NAME,
|
||||
AUTH_LOGIN_PATH,
|
||||
authSecret,
|
||||
} from "@/lib/auth";
|
||||
import { effectiveSecurity } from "@/lib/server/systemConfig";
|
||||
import { routing } from "@/i18n/routing";
|
||||
import { hasFileExtension, stripLocalePrefix } from "@/lib/localePath";
|
||||
|
||||
interface SessionPayload {
|
||||
username: string;
|
||||
@@ -102,24 +105,48 @@ function configurationError(request: NextRequest) {
|
||||
});
|
||||
}
|
||||
|
||||
const intlMiddleware = createMiddleware(routing);
|
||||
|
||||
// Page paths get locale routing; API routes and static-asset lookalikes keep
|
||||
// the pre-i18n behavior. Auth decisions run against the locale-stripped path
|
||||
// so /zh/login is public exactly like /login, and the login redirect carries
|
||||
// the visitor's current locale prefix.
|
||||
export async function proxy(request: NextRequest) {
|
||||
if (!effectiveSecurity().authEnabled || isPublicPath(request.nextUrl.pathname)) {
|
||||
return NextResponse.next();
|
||||
const { pathname } = request.nextUrl;
|
||||
const isApi = pathname.startsWith("/api/");
|
||||
const authPath = stripLocalePrefix(pathname);
|
||||
const passThrough = () =>
|
||||
isApi || hasFileExtension(pathname)
|
||||
? NextResponse.next()
|
||||
: intlMiddleware(request);
|
||||
|
||||
if (!effectiveSecurity().authEnabled || isPublicPath(authPath)) {
|
||||
return passThrough();
|
||||
}
|
||||
if (!authSecret() || authSecret()!.length < 32) {
|
||||
return configurationError(request);
|
||||
}
|
||||
const token = request.cookies.get(AUTH_COOKIE_NAME)?.value;
|
||||
if (await isValidSession(token)) return NextResponse.next();
|
||||
if (await isValidSession(token)) return passThrough();
|
||||
|
||||
if (request.nextUrl.pathname.startsWith("/api/")) {
|
||||
if (isApi) {
|
||||
return NextResponse.json(
|
||||
{ error: "Authentication required." },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
const loginUrl = new URL(AUTH_LOGIN_PATH, request.url);
|
||||
const returnTo = `${request.nextUrl.pathname}${request.nextUrl.search}`;
|
||||
// Prefer the URL's locale; else the remembered NEXT_LOCALE cookie; else the
|
||||
// default. Accept-Language negotiation happens once the browser lands on
|
||||
// the login page (public → intl middleware handles it).
|
||||
const cookieLocale = request.cookies.get("NEXT_LOCALE")?.value;
|
||||
const prefix =
|
||||
authPath !== pathname
|
||||
? `/${pathname.split("/")[1]}`
|
||||
: cookieLocale === "en" || cookieLocale === "zh"
|
||||
? `/${cookieLocale}`
|
||||
: `/${routing.defaultLocale}`;
|
||||
const loginUrl = new URL(`${prefix}${AUTH_LOGIN_PATH}`, request.url);
|
||||
const returnTo = `${pathname}${request.nextUrl.search}`;
|
||||
loginUrl.searchParams.set("next", returnTo);
|
||||
return NextResponse.redirect(loginUrl);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user