Fix public host root redirect (#5363)

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
ZIce
2026-07-14 09:11:21 +08:00
committed by GitHub
parent 39474ee3aa
commit e1d0d68c53
5 changed files with 78 additions and 12 deletions

View File

@@ -6,17 +6,20 @@ import { useQuery } from "@tanstack/react-query";
import { useAuthStore } from "@multica/core/auth";
import { workspaceListOptions } from "@multica/core/workspace";
import { resolvePostAuthDestination, useHasOnboarded } from "@multica/core/paths";
import { isOfficialMarketingHost } from "@/lib/public-host";
/**
* Client-side fallback redirect for authenticated visitors on the landing page.
*
* The primary path for logged-in users hitting `/` is a server-side redirect
* in the Next.js proxy/middleware, driven by the `last_workspace_slug` cookie.
* That cookie is set by the workspace layout on every visit. But on *first
* login* — before the user has ever visited a workspace — the cookie is
* absent, so the proxy falls through to the landing page. This component
* covers that gap: once auth is resolved and the workspace list has loaded,
* push the user into their workspace (or /onboarding if they have none).
* The primary path for logged-in users hitting an app host's `/` is a
* server-side redirect in the Next.js proxy/middleware, driven by the
* `last_workspace_slug` cookie. That cookie is set by the workspace layout on
* every visit. But on *first login* — before the user has ever visited a
* workspace — the cookie is absent, so the proxy falls through to the landing
* page. This component covers that gap on app/self-host origins.
*
* On the official marketing origins, `/` must remain public even for logged-in
* users. Explicit workspace routes still open the app.
*
* Renders nothing. Uses `router.replace` so the landing page never enters
* browser history for authenticated users.
@@ -34,6 +37,7 @@ export function RedirectIfAuthenticated() {
useEffect(() => {
if (isLoading || !user || !isFetched) return;
if (isOfficialMarketingHost(window.location.hostname)) return;
router.replace(resolvePostAuthDestination(list, hasOnboarded));
}, [isLoading, user, isFetched, list, hasOnboarded, router]);

View File

@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { isOfficialMarketingHost } from "./public-host";
describe("isOfficialMarketingHost", () => {
it.each(["multica.ai", "www.multica.ai", "MULTICA.AI", "multica.ai."])(
"recognizes %s as an official marketing host",
(host) => {
expect(isOfficialMarketingHost(host)).toBe(true);
},
);
it.each(["app.multica.ai", "api.multica.ai", "localhost", "multica.test"])(
"does not treat %s as the public marketing host",
(host) => {
expect(isOfficialMarketingHost(host)).toBe(false);
},
);
});

View File

@@ -0,0 +1,6 @@
const OFFICIAL_MARKETING_HOSTS = new Set(["multica.ai", "www.multica.ai"]);
export function isOfficialMarketingHost(hostname: string): boolean {
const normalized = hostname.trim().toLowerCase().replace(/\.$/, "");
return OFFICIAL_MARKETING_HOSTS.has(normalized);
}

View File

@@ -2,18 +2,26 @@ import { describe, expect, it } from "vitest";
import { NextRequest } from "next/server";
import { proxy } from "./proxy";
function makeRequest(path: string, cookies: Record<string, string> = {}) {
function makeRequest(
path: string,
cookies: Record<string, string> = {},
host = "app.multica.test",
) {
const cookieHeader = Object.entries(cookies)
.map(([key, value]) => `${key}=${value}`)
.join("; ");
return new NextRequest(`https://app.multica.test${path}`, {
return new NextRequest(`https://${host}${path}`, {
headers: cookieHeader ? { cookie: cookieHeader } : undefined,
});
}
function redirectLocation(path: string, cookies: Record<string, string> = {}) {
return proxy(makeRequest(path, cookies)).headers.get("location");
function redirectLocation(
path: string,
cookies: Record<string, string> = {},
host?: string,
) {
return proxy(makeRequest(path, cookies, host)).headers.get("location");
}
describe("proxy legacy workspace route redirects", () => {
@@ -61,4 +69,23 @@ describe("proxy legacy workspace route redirects", () => {
it("does not redirect workspace-scoped URLs whose first segment is already a slug", () => {
expect(redirectLocation("/acme/squads", sessionCookies)).toBeNull();
});
it("redirects app-host root URLs to the last workspace", () => {
expect(redirectLocation("/", sessionCookies)).toBe(
"https://app.multica.test/acme/issues",
);
});
it.each(["multica.ai", "www.multica.ai"])(
"does not redirect public marketing root on %s",
(host) => {
expect(redirectLocation("/", sessionCookies, host)).toBeNull();
},
);
it("still redirects explicit legacy app routes on the public marketing host", () => {
expect(redirectLocation("/issues/ABC-123", sessionCookies, "multica.ai")).toBe(
"https://multica.ai/acme/issues/ABC-123",
);
});
});

View File

@@ -4,6 +4,7 @@ import {
MULTICA_LOCALE_HEADER,
resolveLocaleFromSignals,
} from "./lib/locale-routing";
import { isOfficialMarketingHost } from "./lib/public-host";
// Old workspace-scoped route segments that existed before the URL refactor
// (pre-#1131). Any URL with these as the FIRST segment is a legacy URL that
@@ -75,7 +76,16 @@ export function proxy(req: NextRequest) {
}
// --- Root path: redirect logged-in users to their last workspace ---
if (pathname === "/" && hasSession && lastSlug) {
// The official cloud host also serves the public marketing site. Visiting
// https://multica.ai/ must remain a public-site navigation even when a local
// desktop/runtime session has fresh auth cookies; explicit app routes such
// as /acme/issues and legacy /issues still route to the workspace app.
if (
pathname === "/" &&
hasSession &&
lastSlug &&
!isOfficialMarketingHost(req.nextUrl.hostname)
) {
const url = req.nextUrl.clone();
url.pathname = `/${lastSlug}/issues`;
return NextResponse.redirect(url);