fix(dashboard): derive display base URL from origin instead of hardcoding localhost (#1960)

Integrated into release/v3.7.9
This commit is contained in:
Jean Brito
2026-05-04 21:02:29 -03:00
committed by GitHub
parent 6a45ea2c97
commit 89b740d9f7
16 changed files with 303 additions and 18 deletions

View File

@@ -209,6 +209,14 @@ CLOUD_URL=
# Public-facing base URL — CRITICAL for reverse proxy / OAuth callback setups.
# Used by: OAuth redirect_uri computation, Dashboard UI links, cloud/model sync.
# Set to your public URL when behind nginx/Caddy (e.g., https://omniroute.example.com).
#
# Dashboard display behavior: when this variable is unset, the dashboard
# auto-detects the base URL shown in curl examples and CLI tool snippets
# from window.location.origin (the host the user is browsing). Setting it
# explicitly is only required when running behind a reverse proxy with a
# different public hostname, or when OAuth callbacks must point to a
# canonical URL.
#
# Default: http://localhost:20128
NEXT_PUBLIC_BASE_URL=http://localhost:20128

View File

@@ -21,6 +21,7 @@ import {
CustomCliCard,
} from "./components";
import { useTranslations } from "next-intl";
import { DEFAULT_DISPLAY_BASE_URL } from "@/shared/hooks";
const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;
const AUTO_CONFIGURED_TOOL_IDS = new Set([
@@ -226,7 +227,7 @@ export default function CLIToolsPageClient({ machineId: _machineId }) {
if (typeof window !== "undefined") {
return window.location.origin;
}
return "http://localhost:20128";
return DEFAULT_DISPLAY_BASE_URL;
};
if (loading || !statusesLoaded) {

View File

@@ -5,6 +5,7 @@ import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/comp
import Image from "next/image";
import CliStatusBadge from "./CliStatusBadge";
import { useTranslations } from "next-intl";
import { DEFAULT_DISPLAY_BASE_URL } from "@/shared/hooks";
const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;
@@ -147,7 +148,7 @@ export default function ClineToolCard({
const getEffectiveBaseUrl = () => {
if (customBaseUrl) return customBaseUrl;
return baseUrl || "http://localhost:20128";
return baseUrl || DEFAULT_DISPLAY_BASE_URL;
};
const handleApply = async () => {

View File

@@ -9,6 +9,7 @@ import {
buildCustomCliJsonConfig,
normalizeOpenAiBaseUrl,
} from "./customCliConfig";
import { DEFAULT_DISPLAY_BASE_URL } from "@/shared/hooks";
interface ModelOption {
value: string;
@@ -59,7 +60,7 @@ export default function CustomCliCard({
(!cloudEnabled
? "sk_omniroute"
: translateOrFallback("yourApiKeyPlaceholder", "sk-your-omniroute-key"));
const baseUrlWithV1 = normalizeOpenAiBaseUrl(baseUrl || "http://localhost:20128");
const baseUrlWithV1 = normalizeOpenAiBaseUrl(baseUrl || DEFAULT_DISPLAY_BASE_URL);
const chatCompletionsEndpoint = `${baseUrlWithV1}/chat/completions`;
const envScript = useMemo(

View File

@@ -7,6 +7,7 @@ import { useTranslations } from "next-intl";
import { copyToClipboard } from "@/shared/utils/clipboard";
import { buildOpenCodeConfigDocument } from "@/shared/services/opencodeConfig";
import { useTheme } from "@/shared/hooks/useTheme";
import { DEFAULT_DISPLAY_BASE_URL } from "@/shared/hooks";
export default function DefaultToolCard({
toolId,
@@ -90,7 +91,7 @@ export default function DefaultToolCard({
[getSelectedModelEntries]
);
const normalizedBaseUrl = baseUrl || "http://localhost:20128";
const normalizedBaseUrl = baseUrl || DEFAULT_DISPLAY_BASE_URL;
const baseUrlWithV1 = normalizedBaseUrl.endsWith("/v1")
? normalizedBaseUrl
: `${normalizedBaseUrl}/v1`;

View File

@@ -5,6 +5,7 @@ import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/comp
import Image from "next/image";
import CliStatusBadge from "./CliStatusBadge";
import { useTranslations } from "next-intl";
import { DEFAULT_DISPLAY_BASE_URL } from "@/shared/hooks";
const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;
@@ -133,7 +134,7 @@ export default function KiloToolCard({
const getEffectiveBaseUrl = () => {
if (customBaseUrl) return customBaseUrl;
return baseUrl || "http://localhost:20128";
return baseUrl || DEFAULT_DISPLAY_BASE_URL;
};
const handleApply = async () => {

View File

@@ -1,3 +1,5 @@
import { DEFAULT_DISPLAY_BASE_URL } from "@/shared/hooks";
export interface CustomCliAliasMapping {
alias: string;
model: string;
@@ -12,7 +14,7 @@ export interface CustomCliConfigInput {
}
export function normalizeOpenAiBaseUrl(baseUrl: string): string {
const trimmed = (baseUrl || "http://localhost:20128").trim().replace(/\/+$/, "");
const trimmed = (baseUrl || DEFAULT_DISPLAY_BASE_URL).trim().replace(/\/+$/, "");
return trimmed.endsWith("/v1") ? trimmed : `${trimmed}/v1`;
}

View File

@@ -2,6 +2,7 @@
import { useState, useEffect, useMemo } from "react";
import { Card } from "@/shared/components";
import { useDisplayBaseUrl } from "@/shared/hooks";
/* ─── Types ──────────────────────────────────────────── */
interface Endpoint {
@@ -65,6 +66,7 @@ const WEBHOOK_EVENTS = [
/* ─── Main Component ─────────────────────────────────── */
export default function ApiEndpointsTab() {
const baseUrl = useDisplayBaseUrl();
const [catalog, setCatalog] = useState<CatalogData | null>(null);
const [catalogError, setCatalogError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
@@ -536,7 +538,7 @@ export default function ApiEndpointsTab() {
Example
</p>
<code className="text-[11px] font-mono text-text-main break-all">
curl -X {ep.method} http://localhost:20128
curl -X {ep.method} {baseUrl}
{ep.path.replace("/api/", "/")}
{ep.security ? ' -H "Authorization: Bearer YOUR_KEY"' : ""}
{ep.requestBody

View File

@@ -4,6 +4,7 @@ import { useState, useEffect, useMemo, useCallback } from "react";
import Link from "next/link";
import { Card, Button, Input, Modal, CardSkeleton, SegmentedControl } from "@/shared/components";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
import { useDisplayBaseUrl } from "@/shared/hooks";
import { AI_PROVIDERS, getProviderByAlias } from "@/shared/constants/providers";
import { useTranslations } from "next-intl";
@@ -1007,7 +1008,8 @@ export default function APIPageClient({ machineId }: APIPageClientProps) {
}
}, [fetchTailscaleStatus, handleTailscaleEnable, tailscalePassword, translateOrFallback]);
const [baseUrl, setBaseUrl] = useState("/v1");
const displayBaseUrl = useDisplayBaseUrl();
const baseUrl = `${displayBaseUrl}/v1`;
const normalizedCloudBaseUrl = cloudBaseUrl
? resolvedMachineId && !cloudBaseUrl.endsWith(`/${resolvedMachineId}`)
? `${cloudBaseUrl}/${resolvedMachineId}`
@@ -1015,14 +1017,6 @@ export default function APIPageClient({ machineId }: APIPageClientProps) {
: null;
const cloudEndpointNew = normalizedCloudBaseUrl ? `${normalizedCloudBaseUrl}/v1` : null;
// Hydration fix: Only access window on client side
useEffect(() => {
if (typeof window !== "undefined") {
const defaultOrigin = process.env.NEXT_PUBLIC_BASE_URL || window.location.origin;
setBaseUrl(`${defaultOrigin}/v1`);
}
}, []);
if (loading) {
return (
<div className="flex flex-col gap-8">

View File

@@ -104,4 +104,48 @@ describe("ApiEndpointsTab", () => {
expect(document.body.textContent).toContain("1 endpoints across 1 categories");
expect(document.body.textContent).toContain("/api/v1/chat/completions");
});
it("renders curl example using window.location.origin when NEXT_PUBLIC_BASE_URL is unset", async () => {
fetchMock.mockResolvedValue(
jsonResponse({
info: { title: "OmniRoute API", version: "3.7.6" },
servers: [],
tags: [{ name: "Chat" }],
endpoints: [
{
method: "POST",
path: "/api/v1/chat/completions",
tags: ["Chat"],
summary: "Create chat completion",
description: "Create chat completion",
security: false,
parameters: [],
requestBody: false,
responses: ["200"],
},
],
schemas: [],
})
);
renderApiEndpointsTab();
await waitForText("OmniRoute API");
// Expand the endpoint to reveal the curl example
const endpointRow = document.body.querySelector("code.font-mono.flex-1");
if (endpointRow?.parentElement) {
await act(async () => {
endpointRow.parentElement!.click();
});
}
// After mount the hook swaps DEFAULT_DISPLAY_BASE_URL for window.location.origin.
// In jsdom the default origin is "http://localhost".
const expectedOrigin = window.location.origin; // "http://localhost" in jsdom
await waitForText(`curl -X POST ${expectedOrigin}/v1/chat/completions`);
expect(document.body.textContent).toContain(
`curl -X POST ${expectedOrigin}/v1/chat/completions`
);
});
});

View File

@@ -3,6 +3,7 @@
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useDisplayBaseUrl } from "@/shared/hooks";
const STEP_IDS = ["welcome", "security", "provider", "test", "done"];
const STEP_ICONS = ["waving_hand", "lock", "dns", "play_circle", "check_circle"];
@@ -20,9 +21,10 @@ export default function OnboardingWizard() {
const router = useRouter();
const t = useTranslations("onboarding");
const tc = useTranslations("common");
const baseUrl = useDisplayBaseUrl();
const [step, setStep] = useState(0);
const [loading, setLoading] = useState(true);
const [apiEndpoint, setApiEndpoint] = useState("http://localhost:20128/api/v1");
const [apiEndpoint, setApiEndpoint] = useState(`${baseUrl}/api/v1`);
// Security step state
const [password, setPassword] = useState("");

View File

@@ -2,12 +2,13 @@
import { useState } from "react";
import { useTranslations } from "next-intl";
import { copyToClipboard } from "@/shared/utils/clipboard";
import { useDisplayBaseUrl } from "@/shared/hooks";
export default function GetStarted() {
const t = useTranslations("landing");
const [copied, setCopied] = useState(false);
const endpoint = "http://localhost:20128";
const endpoint = useDisplayBaseUrl();
const dashboardUrl = `${endpoint}/dashboard`;
const command = "npx omniroute";

View File

@@ -0,0 +1,176 @@
// @vitest-environment jsdom
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_DISPLAY_BASE_URL } from "../useDisplayBaseUrl";
const cleanupCallbacks: Array<() => void> = [];
function makeContainer(): HTMLElement {
const container = document.createElement("div");
document.body.appendChild(container);
cleanupCallbacks.push(() => {
container.remove();
});
return container;
}
describe("useDisplayBaseUrl", () => {
beforeEach(() => {
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
});
afterEach(() => {
while (cleanupCallbacks.length > 0) {
cleanupCallbacks.pop()?.();
}
document.body.innerHTML = "";
vi.unstubAllEnvs();
vi.unstubAllGlobals();
});
it("returns env value on first render and after mount when NEXT_PUBLIC_BASE_URL is set", async () => {
vi.stubEnv("NEXT_PUBLIC_BASE_URL", "https://example.com");
const { useDisplayBaseUrl } = await import("../useDisplayBaseUrl");
const container = makeContainer();
const root = createRoot(container);
function C() {
const url = useDisplayBaseUrl();
return <span data-testid="value">{url}</span>;
}
// Synchronous act: commits render and flushes synchronous effects.
// The queueMicrotask in useEffect has not yet fired.
act(() => {
root.render(<C />);
});
// Env set: first render shows env value (useEffect no-ops when envValue is set)
expect(container.querySelector('[data-testid="value"]')?.textContent).toBe(
"https://example.com"
);
// Flush microtasks and any remaining async work
await act(async () => {});
// Env still wins after mount
expect(container.querySelector('[data-testid="value"]')?.textContent).toBe(
"https://example.com"
);
});
it("returns DEFAULT_DISPLAY_BASE_URL on first render and origin after mount when env unset", async () => {
vi.stubEnv("NEXT_PUBLIC_BASE_URL", "");
const { useDisplayBaseUrl } = await import("../useDisplayBaseUrl");
const container = makeContainer();
const root = createRoot(container);
function C() {
const url = useDisplayBaseUrl();
return <span data-testid="value">{url}</span>;
}
// Synchronous act commits render. useEffect fires but queueMicrotask
// schedules setState for after this act() call returns.
act(() => {
root.render(<C />);
});
// Pre-microtask: DOM still shows the initial state (DEFAULT_DISPLAY_BASE_URL)
expect(container.querySelector('[data-testid="value"]')?.textContent).toBe(
DEFAULT_DISPLAY_BASE_URL
);
// Flush queueMicrotask callback (setState) and resulting re-render
await act(async () => {});
// After mount: swaps to window.location.origin
expect(container.querySelector('[data-testid="value"]')?.textContent).toBe(
window.location.origin
);
});
it("trims and strips trailing slash from env value", async () => {
vi.stubEnv("NEXT_PUBLIC_BASE_URL", " https://x.com/ ");
const { useDisplayBaseUrl } = await import("../useDisplayBaseUrl");
const container = makeContainer();
const root = createRoot(container);
function C() {
const url = useDisplayBaseUrl();
return <span data-testid="value">{url}</span>;
}
await act(async () => {
root.render(<C />);
});
expect(container.querySelector('[data-testid="value"]')?.textContent).toBe("https://x.com");
});
it("strips trailing slash from window.location.origin after mount", async () => {
vi.stubEnv("NEXT_PUBLIC_BASE_URL", "");
// Stub window.location with trailing slash on origin
vi.stubGlobal("location", { origin: "http://192.168.13.62:20128/" });
const { useDisplayBaseUrl } = await import("../useDisplayBaseUrl");
const container = makeContainer();
const root = createRoot(container);
function C() {
const url = useDisplayBaseUrl();
return <span data-testid="value">{url}</span>;
}
// Render and flush all effects including queueMicrotask
await act(async () => {
root.render(<C />);
});
expect(container.querySelector('[data-testid="value"]')?.textContent).toBe(
"http://192.168.13.62:20128"
);
});
it("treats empty-string env as unset and falls through to origin after mount", async () => {
vi.stubEnv("NEXT_PUBLIC_BASE_URL", "");
const { useDisplayBaseUrl } = await import("../useDisplayBaseUrl");
const container = makeContainer();
const root = createRoot(container);
function C() {
const url = useDisplayBaseUrl();
return <span data-testid="value">{url}</span>;
}
// Synchronous act: render committed, useEffect fired, microtask queued but not yet run
act(() => {
root.render(<C />);
});
// Empty env treated as unset → initial state is DEFAULT_DISPLAY_BASE_URL
expect(container.querySelector('[data-testid="value"]')?.textContent).toBe(
DEFAULT_DISPLAY_BASE_URL
);
// Flush queueMicrotask + re-render
await act(async () => {});
// After mount: resolves to origin
const result = container.querySelector('[data-testid="value"]')?.textContent;
expect(result).toBe(window.location.origin.replace(/\/+$/, ""));
});
});

View File

@@ -1,2 +1,3 @@
// Shared Hooks - Export all
export { useTheme } from "./useTheme";
export { useDisplayBaseUrl, DEFAULT_DISPLAY_BASE_URL } from "./useDisplayBaseUrl";

View File

@@ -0,0 +1,49 @@
"use client";
import { useEffect, useState } from "react";
export const DEFAULT_DISPLAY_BASE_URL = "http://localhost:20128";
function normalizeUrl(value?: string): string | null {
const trimmed = value?.trim();
if (!trimmed) return null;
return trimmed.replace(/\/+$/, "");
}
/**
* Returns the public base URL to display in the dashboard.
*
* Resolution chain:
* 1. NEXT_PUBLIC_BASE_URL (env, trimmed + slash-normalized) — wins if set.
* 2. window.location.origin after client mount — when env is unset.
* 3. DEFAULT_DISPLAY_BASE_URL ("http://localhost:20128") — SSR / first render fallback.
*
* DISPLAY ONLY — do NOT use this hook for OAuth `redirect_uri`.
* OAuth callers must read `process.env.NEXT_PUBLIC_BASE_URL` directly to avoid
* host-header attack surface. For server-side resolution, use
* `src/shared/utils/resolveOmniRouteBaseUrl.ts` instead.
*/
export function useDisplayBaseUrl(): string {
const envValue = normalizeUrl(process.env.NEXT_PUBLIC_BASE_URL);
const [url, setUrl] = useState<string>(envValue ?? DEFAULT_DISPLAY_BASE_URL);
useEffect(() => {
if (envValue) return;
const origin = normalizeUrl(window.location.origin) ?? DEFAULT_DISPLAY_BASE_URL;
// Schedule via queueMicrotask so setState is called inside a callback,
// not synchronously in the effect body (react-hooks/set-state-in-effect).
// The unmounted guard prevents a stale setState on a torn-down root
// (relevant under React strict mode's double-invoke, where cleanup runs
// before the microtask fires on the first effect invocation).
let unmounted = false;
queueMicrotask(() => {
if (!unmounted) setUrl(origin);
});
return () => {
unmounted = true;
};
}, [envValue]);
return url;
}

View File

@@ -9,6 +9,7 @@ export default defineConfig({
include: [
"src/app/**/dashboard/cache/__tests__/**/*.test.tsx",
"src/app/**/dashboard/endpoint/__tests__/**/*.test.tsx",
"src/shared/hooks/__tests__/**/*.test.tsx",
"src/lib/memory/__tests__/**/*.test.ts",
"src/lib/skills/__tests__/**/*.test.ts",
"tests/unit/encryption.test.ts",