diff --git a/.env.example b/.env.example index 599f1ab2d3..089b87379c 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.tsx b/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.tsx index 2b2c8d27c3..126d75b215 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.tsx +++ b/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.tsx @@ -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) { diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/ClineToolCard.tsx b/src/app/(dashboard)/dashboard/cli-tools/components/ClineToolCard.tsx index 8e61118c7c..e079a4e77d 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/ClineToolCard.tsx +++ b/src/app/(dashboard)/dashboard/cli-tools/components/ClineToolCard.tsx @@ -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 () => { diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/CustomCliCard.tsx b/src/app/(dashboard)/dashboard/cli-tools/components/CustomCliCard.tsx index 1726de6025..e0119498ba 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/CustomCliCard.tsx +++ b/src/app/(dashboard)/dashboard/cli-tools/components/CustomCliCard.tsx @@ -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( diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/DefaultToolCard.tsx b/src/app/(dashboard)/dashboard/cli-tools/components/DefaultToolCard.tsx index 89e976fa65..95f7074efa 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/DefaultToolCard.tsx +++ b/src/app/(dashboard)/dashboard/cli-tools/components/DefaultToolCard.tsx @@ -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`; diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/KiloToolCard.tsx b/src/app/(dashboard)/dashboard/cli-tools/components/KiloToolCard.tsx index 52cfd85391..e99243968b 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/KiloToolCard.tsx +++ b/src/app/(dashboard)/dashboard/cli-tools/components/KiloToolCard.tsx @@ -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 () => { diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/customCliConfig.ts b/src/app/(dashboard)/dashboard/cli-tools/components/customCliConfig.ts index 82488084a2..b3c205f768 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/customCliConfig.ts +++ b/src/app/(dashboard)/dashboard/cli-tools/components/customCliConfig.ts @@ -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`; } diff --git a/src/app/(dashboard)/dashboard/endpoint/ApiEndpointsTab.tsx b/src/app/(dashboard)/dashboard/endpoint/ApiEndpointsTab.tsx index 1bfaee3f81..4f9b8c9b83 100644 --- a/src/app/(dashboard)/dashboard/endpoint/ApiEndpointsTab.tsx +++ b/src/app/(dashboard)/dashboard/endpoint/ApiEndpointsTab.tsx @@ -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(null); const [catalogError, setCatalogError] = useState(null); const [loading, setLoading] = useState(true); @@ -536,7 +538,7 @@ export default function ApiEndpointsTab() { Example

- 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 diff --git a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx index 10f3e25412..3be6a00ca1 100644 --- a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx +++ b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx @@ -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 (
diff --git a/src/app/(dashboard)/dashboard/endpoint/__tests__/ApiEndpointsTab.test.tsx b/src/app/(dashboard)/dashboard/endpoint/__tests__/ApiEndpointsTab.test.tsx index ce993e3663..8b7aa4e33f 100644 --- a/src/app/(dashboard)/dashboard/endpoint/__tests__/ApiEndpointsTab.test.tsx +++ b/src/app/(dashboard)/dashboard/endpoint/__tests__/ApiEndpointsTab.test.tsx @@ -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` + ); + }); }); diff --git a/src/app/(dashboard)/dashboard/onboarding/page.tsx b/src/app/(dashboard)/dashboard/onboarding/page.tsx index 71995da7f7..df80edcaa5 100644 --- a/src/app/(dashboard)/dashboard/onboarding/page.tsx +++ b/src/app/(dashboard)/dashboard/onboarding/page.tsx @@ -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(""); diff --git a/src/app/landing/components/GetStarted.tsx b/src/app/landing/components/GetStarted.tsx index 2fa01f4195..9eb847d475 100644 --- a/src/app/landing/components/GetStarted.tsx +++ b/src/app/landing/components/GetStarted.tsx @@ -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"; diff --git a/src/shared/hooks/__tests__/useDisplayBaseUrl.test.tsx b/src/shared/hooks/__tests__/useDisplayBaseUrl.test.tsx new file mode 100644 index 0000000000..c6dd92b137 --- /dev/null +++ b/src/shared/hooks/__tests__/useDisplayBaseUrl.test.tsx @@ -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 {url}; + } + + // Synchronous act: commits render and flushes synchronous effects. + // The queueMicrotask in useEffect has not yet fired. + act(() => { + root.render(); + }); + + // 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 {url}; + } + + // Synchronous act commits render. useEffect fires but queueMicrotask + // schedules setState for after this act() call returns. + act(() => { + root.render(); + }); + + // 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 {url}; + } + + await act(async () => { + root.render(); + }); + + 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 {url}; + } + + // Render and flush all effects including queueMicrotask + await act(async () => { + root.render(); + }); + + 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 {url}; + } + + // Synchronous act: render committed, useEffect fired, microtask queued but not yet run + act(() => { + root.render(); + }); + + // 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(/\/+$/, "")); + }); +}); diff --git a/src/shared/hooks/index.ts b/src/shared/hooks/index.ts index 68c81c2d54..896a06fc7e 100644 --- a/src/shared/hooks/index.ts +++ b/src/shared/hooks/index.ts @@ -1,2 +1,3 @@ // Shared Hooks - Export all export { useTheme } from "./useTheme"; +export { useDisplayBaseUrl, DEFAULT_DISPLAY_BASE_URL } from "./useDisplayBaseUrl"; diff --git a/src/shared/hooks/useDisplayBaseUrl.ts b/src/shared/hooks/useDisplayBaseUrl.ts new file mode 100644 index 0000000000..96081ba583 --- /dev/null +++ b/src/shared/hooks/useDisplayBaseUrl.ts @@ -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(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; +} diff --git a/vitest.config.ts b/vitest.config.ts index 34b035d509..8fb8eb8c6e 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -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",