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:
m4
2026-08-11 15:31:13 +08:00
parent c6a2c347f8
commit 63f3310fa0
10 changed files with 188 additions and 25 deletions
+9
View File
@@ -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);
+10
View File
@@ -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(
+8
View File
@@ -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);
});
+6 -2
View File
@@ -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.
+56
View File
@@ -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);
});
});
+25
View File
@@ -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
View File
@@ -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);
}