mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-24 11:10:25 +02:00
Fix public host root redirect (#5363)
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -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]);
|
||||
|
||||
|
||||
19
apps/web/lib/public-host.test.ts
Normal file
19
apps/web/lib/public-host.test.ts
Normal 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);
|
||||
},
|
||||
);
|
||||
});
|
||||
6
apps/web/lib/public-host.ts
Normal file
6
apps/web/lib/public-host.ts
Normal 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);
|
||||
}
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user