fix(dashboard): prefer public endpoint URLs (#7547)

* fix(dashboard): prefer public endpoint URLs

* docs: add changelog fragment for #7547

* test(dashboard): cover onboarding public endpoint

* refactor(hooks): split display-URL predicates below complexity gate

Decompose isPrivateIpv4 and isPublicDisplayBaseUrl (both over the ESLint
complexity gate of 15) into small named predicates. Behavior is unchanged:

- isPrivateIpv4 now checks a PRIVATE_IPV4_RANGES table (RFC1918 +
  special-use ranges) through isInIpv4Range instead of one long chain
  of ||/&& comparisons.
- isPublicDisplayBaseUrl now delegates to isSupportedProtocol,
  isLoopbackHostname, isMulticastDnsHostname and isNonPublicIpv6 (itself
  split into isIpv6LoopbackOrUnspecified / isIpv6UniqueLocal /
  isIpv6LinkLocal), preserving the isIpv6 gate so hostnames that merely
  start with "fc"/"fd" (e.g. fdroid.example.com) are not misclassified
  as IPv6 unique-local addresses.

Adds IPv4 range-boundary and IPv6-gate regression tests; all existing
assertions are unchanged.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: nguyenha935 <208228297+nguyenha935@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
This commit is contained in:
nguyenha935
2026-07-19 01:14:18 +07:00
committed by GitHub
parent 65fbba4893
commit a7dba3bbcc
10 changed files with 429 additions and 54 deletions

View File

@@ -0,0 +1 @@
- **fix(dashboard):** Public and managed tunnel endpoints now take precedence over loopback URLs in dashboard setup and copyable API configuration ([#7547](https://github.com/diegosouzapw/OmniRoute/pull/7547)) — thanks @nguyenha935

View File

@@ -5,7 +5,7 @@ import Link from "next/link";
import { Card, Button, Input, Modal, CardSkeleton, SegmentedControl } from "@/shared/components";
import Toggle from "@/shared/components/Toggle";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
import { useDisplayBaseUrl } from "@/shared/hooks";
import { isPublicDisplayBaseUrl, useDisplayBaseUrl } from "@/shared/hooks";
import { AI_PROVIDERS, getProviderByAlias } from "@/shared/constants/providers";
import { getProviderDisplayName } from "@/lib/display/names";
import { useTranslations } from "next-intl";
@@ -19,12 +19,7 @@ const CLOUD_ACTION_TIMEOUT_MS = 15000;
type TranslationValues = Record<string, string | number | boolean | Date>;
type CloudflaredTunnelPhase =
| "unsupported"
| "not_installed"
| "stopped"
| "starting"
| "running"
| "error";
"unsupported" | "not_installed" | "stopped" | "starting" | "running" | "error";
type CloudflaredTunnelStatus = {
supported: boolean;
@@ -43,12 +38,7 @@ type CloudflaredTunnelStatus = {
};
type TailscaleTunnelPhase =
| "unsupported"
| "not_installed"
| "needs_login"
| "stopped"
| "running"
| "error";
"unsupported" | "not_installed" | "needs_login" | "stopped" | "running" | "error";
type TailscaleTunnelStatus = {
supported: boolean;
@@ -70,13 +60,7 @@ type TailscaleTunnelStatus = {
};
type NgrokTunnelPhase =
| "unsupported"
| "not_installed"
| "stopped"
| "needs_auth"
| "starting"
| "running"
| "error";
"unsupported" | "not_installed" | "stopped" | "needs_auth" | "starting" | "running" | "error";
type NgrokTunnelStatus = {
supported: boolean;
@@ -187,6 +171,7 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
const [ngrokToken, setNgrokToken] = useState("");
const [showNgrokTunnel, setShowNgrokTunnel] = useState(true);
const [expandedTunnel, setExpandedTunnel] = useState<string | null>(null);
const [localApiUrl, setLocalApiUrl] = useState("http://localhost:20128/v1");
const [lanUrls, setLanUrls] = useState<string[]>([]);
const [tailscaleIpUrl, setTailscaleIpUrl] = useState<string | null>(null);
const [activeEndpointTab, setActiveEndpointTab] = useState<EndpointTab>("apis");
@@ -335,6 +320,7 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
if (res.ok) {
const data = await res.json();
if (mounted) {
if (data.localUrl) setLocalApiUrl(data.localUrl);
setLanUrls(data.lanUrls ?? []);
if (data.tailscaleIpUrl) setTailscaleIpUrl(data.tailscaleIpUrl);
}
@@ -1080,7 +1066,8 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
}, [fetchTailscaleStatus, handleTailscaleEnable, tailscalePassword, translateOrFallback]);
const displayBaseUrl = useDisplayBaseUrl();
const baseUrl = `${displayBaseUrl}/v1`;
const displayApiUrl = `${displayBaseUrl}/v1`;
const publicDisplayApiUrl = isPublicDisplayBaseUrl(displayBaseUrl) ? displayApiUrl : null;
const normalizedCloudBaseUrl = cloudBaseUrl
? resolvedMachineId && !cloudBaseUrl.endsWith(`/${resolvedMachineId}`)
? `${cloudBaseUrl}/${resolvedMachineId}`
@@ -1097,13 +1084,9 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
);
}
// Use new format endpoint (machineId embedded in key)
const currentEndpoint = cloudEnabled && cloudEndpointNew ? cloudEndpointNew : baseUrl;
const activeUrls = [
{ label: "Local", url: baseUrl, key: "active_local" },
...(cloudEnabled && cloudEndpointNew
? [{ label: "Cloud", url: cloudEndpointNew, key: "active_cloud" }]
const activeTunnelUrls = [
...(publicDisplayApiUrl
? [{ label: t("tierPublic"), url: publicDisplayApiUrl, key: "active_public" }]
: []),
...(cloudflaredStatus?.running && cloudflaredStatus.apiUrl
? [{ label: "Cloudflare", url: cloudflaredStatus.apiUrl, key: "active_cf" }]
@@ -1121,6 +1104,21 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
? [{ label: "ngrok", url: ngrokStatus.apiUrl, key: "active_ngrok" }]
: []),
];
const preferredTunnelUrl = activeTunnelUrls[0]?.url ?? null;
const currentEndpoint =
preferredTunnelUrl ??
(cloudEnabled && cloudEndpointNew ? cloudEndpointNew : null) ??
displayApiUrl;
const activeUrls = [
...activeTunnelUrls,
...(cloudEnabled && cloudEndpointNew
? [{ label: "Cloud", url: cloudEndpointNew, key: "active_cloud" }]
: []),
{ label: "Local", url: localApiUrl, key: "active_local" },
].filter(
(candidate, index, candidates) =>
candidates.findIndex((other) => other.url === candidate.url) === index
);
const visibleTunnelCount = [showCloudflaredTunnel, showTailscaleFunnel, showNgrokTunnel].filter(
Boolean
).length;
@@ -1357,7 +1355,7 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
Running
</span>
<button
onClick={() => void copy(baseUrl, "endpoint_url")}
onClick={() => void copy(localApiUrl, "endpoint_url")}
className="shrink-0 flex items-center gap-1 text-xs px-2.5 py-1.5 rounded-md border border-border/70 text-text-muted hover:text-text hover:border-border transition-colors"
>
<span className="material-symbols-outlined text-[14px]">

View File

@@ -29,10 +29,14 @@ vi.mock("next-intl", () => ({
"endpoint.showInternal": "Show internal",
"endpoint.hideInternal": "Hide internal",
"endpoint.vscodeAliasTitle": "VS Code Token Alias",
"endpoint.vscodeAliasDescriptionReady": "Ready-to-paste compatibility URLs using the /api/v1/vscode/{token}/... endpoint.",
"endpoint.vscodeAliasDescriptionError": "Showing placeholder URLs because CLI keys could not be loaded in this session.",
"endpoint.vscodeAliasDescriptionLoading": "Loading CLI keys. Placeholder URLs are shown until a key is available.",
"endpoint.vscodeAliasDescriptionPlaceholder": "Showing placeholder URLs. Create or activate an API key in CLI Tools to replace {token}.",
"endpoint.vscodeAliasDescriptionReady":
"Ready-to-paste compatibility URLs using the /api/v1/vscode/{token}/... endpoint.",
"endpoint.vscodeAliasDescriptionError":
"Showing placeholder URLs because CLI keys could not be loaded in this session.",
"endpoint.vscodeAliasDescriptionLoading":
"Loading CLI keys. Placeholder URLs are shown until a key is available.",
"endpoint.vscodeAliasDescriptionPlaceholder":
"Showing placeholder URLs. Create or activate an API key in CLI Tools to replace {token}.",
"endpoint.vscodeAliasManage": "CLI Tools",
"endpoint.vscodeAliasBaseLabel": "VS Code base",
"endpoint.vscodeAliasModelsLabel": "VS Code models",
@@ -114,6 +118,7 @@ describe("ApiEndpointsTab", () => {
cleanupCallbacks.pop()?.();
}
document.body.innerHTML = "";
vi.unstubAllEnvs();
vi.unstubAllGlobals();
});
@@ -174,6 +179,7 @@ describe("ApiEndpointsTab", () => {
});
it("renders curl example using window.location.origin when NEXT_PUBLIC_BASE_URL is unset", async () => {
vi.stubEnv("NEXT_PUBLIC_BASE_URL", "");
fetchMock.mockImplementation(async (input) => {
if (input === "/api/cli-tools/keys") {
return jsonResponse({ keys: [] });
@@ -221,7 +227,7 @@ describe("ApiEndpointsTab", () => {
expect(
expectedOrigins.some((origin) =>
renderedText.includes(`curl -X POST ${origin}/v1/chat/completions`)
renderedText.includes(`curl -X POST ${origin}/api/v1/chat/completions`)
)
).toBe(true);
});

View File

@@ -186,9 +186,61 @@ describe("EndpointPageClient", () => {
cleanupCallbacks.pop()?.();
}
document.body.innerHTML = "";
vi.unstubAllEnvs();
vi.unstubAllGlobals();
});
it("shows the public browser endpoint before the runtime local endpoint", async () => {
vi.stubEnv("NEXT_PUBLIC_BASE_URL", "http://localhost:20128");
vi.stubGlobal("location", { origin: "https://api.example.com" });
fetchMock.mockImplementation((input: RequestInfo | URL) => {
const path = getRequestPath(input);
if (path === "/api/settings") {
return Promise.resolve(
jsonResponse({
cloudEnabled: false,
cloudConfigured: false,
hideEndpointCloudflaredTunnel: true,
hideEndpointTailscaleFunnel: true,
hideEndpointNgrokTunnel: true,
})
);
}
if (path === "/api/network/info") {
return Promise.resolve(
jsonResponse({
localUrl: "http://localhost:20131/v1",
lanUrls: [],
tailscaleIpUrl: null,
})
);
}
if (path === "/v1/models") return Promise.resolve(jsonResponse({ data: [] }));
if (path === "/api/mcp/status") return Promise.resolve(jsonResponse({ online: false }));
if (path === "/api/a2a/status") {
return Promise.resolve(jsonResponse({ status: "ok", tasks: { activeStreams: 0 } }));
}
if (path === "/api/search/providers") {
return Promise.resolve(jsonResponse({ providers: [] }));
}
if (path === "/api/cli-tools/keys") return Promise.resolve(jsonResponse({ keys: [] }));
throw new Error(`Unexpected request: ${path}`);
});
renderEndpointPage();
await waitForText("https://api.example.com/v1");
await waitForText("http://localhost:20131/v1");
const displayedUrls = Array.from(document.body.querySelectorAll("code")).map(
(element) => element.textContent
);
expect(displayedUrls.indexOf("https://api.example.com/v1")).toBeLessThan(
displayedUrls.indexOf("http://localhost:20131/v1")
);
});
it("renders the endpoint shell before models finish and skips hidden tunnel probes", async () => {
const modelsDeferred = createDeferred<Response>();

View File

@@ -25,7 +25,7 @@ export default function OnboardingWizard() {
const baseUrl = useDisplayBaseUrl();
const [step, setStep] = useState(0);
const [loading, setLoading] = useState(true);
const [apiEndpoint, setApiEndpoint] = useState(`${baseUrl}/api/v1`);
const apiEndpoint = `${baseUrl}/api/v1`;
// Security step state
const [password, setPassword] = useState("");
@@ -45,20 +45,11 @@ export default function OnboardingWizard() {
// Check if setup is already complete
useEffect(() => {
const resolveApiEndpoint = (apiPort) => {
if (typeof window === "undefined") return;
const protocol = window.location.protocol;
const hostname = window.location.hostname;
const effectiveApiPort = apiPort || 20128;
setApiEndpoint(`${protocol}//${hostname}:${effectiveApiPort}/api/v1`);
};
const checkSetup = async () => {
try {
const res = await fetch("/api/settings");
if (res.ok) {
const settings = await res.json();
resolveApiEndpoint(settings?.apiPort);
if (settings.setupComplete) {
router.replace("/dashboard");
return;

View File

@@ -12,6 +12,7 @@ export async function GET(request: Request) {
const { apiPort } = getRuntimePorts();
const interfaces = os.networkInterfaces();
const localUrl = `http://localhost:${apiPort}/v1`;
const lanUrls: string[] = [];
let tailscaleIpUrl: string | null = null;
@@ -28,5 +29,5 @@ export async function GET(request: Request) {
}
}
return NextResponse.json({ lanUrls, tailscaleIpUrl });
return NextResponse.json({ localUrl, lanUrls, tailscaleIpUrl });
}

View File

@@ -2,7 +2,11 @@
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";
import {
DEFAULT_DISPLAY_BASE_URL,
isPublicDisplayBaseUrl,
resolveDisplayBaseUrl,
} from "../useDisplayBaseUrl";
const cleanupCallbacks: Array<() => void> = [];
@@ -64,6 +68,108 @@ describe("useDisplayBaseUrl", () => {
);
});
it("classifies public domains separately from local and private addresses", () => {
expect(isPublicDisplayBaseUrl("https://api.example.com")).toBe(true);
expect(isPublicDisplayBaseUrl("http://localhost:20128")).toBe(false);
expect(isPublicDisplayBaseUrl("http://192.168.1.25:20128")).toBe(false);
expect(isPublicDisplayBaseUrl("http://100.88.4.55:20128")).toBe(false);
expect(isPublicDisplayBaseUrl("http://[::1]:20128")).toBe(false);
});
it("classifies IPv4 private ranges at their exact boundaries", () => {
const cases: Array<[host: string, expectedPublic: boolean]> = [
// 0.0.0.0/8 ("this" network) vs just above it
["0.0.0.1", false],
["1.0.0.1", true],
// RFC1918 10.0.0.0/8 vs just outside
["9.255.255.255", true],
["10.0.0.1", false],
["11.0.0.0", true],
// loopback 127.0.0.0/8 vs just outside
["126.255.255.255", true],
["127.0.0.1", false],
["128.0.0.0", true],
// multicast/reserved 224.0.0.0+ vs just below
["223.255.255.255", true],
["224.0.0.1", false],
// CGNAT RFC6598 100.64.0.0/10 vs just outside
["100.63.255.255", true],
["100.64.0.0", false],
["100.127.255.255", false],
["100.128.0.0", true],
// link-local 169.254.0.0/16 vs just outside
["169.253.255.255", true],
["169.254.0.1", false],
["169.255.0.0", true],
// RFC1918 172.16.0.0/12 vs just outside
["172.15.255.255", true],
["172.16.0.0", false],
["172.31.255.255", false],
["172.32.0.0", true],
// RFC1918 192.168.0.0/16 vs just outside
["192.167.255.255", true],
["192.168.0.1", false],
["192.169.0.0", true],
];
for (const [host, expectedPublic] of cases) {
expect(isPublicDisplayBaseUrl(`http://${host}:20128`)).toBe(expectedPublic);
}
});
it("classifies IPv6 special ranges while keeping the check scoped to actual IPv6 hosts", () => {
expect(isPublicDisplayBaseUrl("http://[::]:20128")).toBe(false);
expect(isPublicDisplayBaseUrl("http://[::1]:20128")).toBe(false);
expect(isPublicDisplayBaseUrl("http://[fc00::1]:20128")).toBe(false);
expect(isPublicDisplayBaseUrl("http://[fd12::1]:20128")).toBe(false);
expect(isPublicDisplayBaseUrl("http://[fe80::1]:20128")).toBe(false);
expect(isPublicDisplayBaseUrl("http://[2001:db8::1]:20128")).toBe(true);
// A hostname that merely STARTS WITH "fd"/"fc" must stay public — the ULA/
// link-local checks are IPv6-only and must not leak into hostname matching.
expect(isPublicDisplayBaseUrl("http://fdroid.example.com:20128")).toBe(true);
expect(isPublicDisplayBaseUrl("http://fcbar.example.com:20128")).toBe(true);
});
it("keeps a configured public URL when the browser is on a local address", () => {
expect(resolveDisplayBaseUrl("https://api.example.com/", "http://localhost:20128")).toBe(
"https://api.example.com"
);
});
it("prefers the currently reachable public origin over another configured URL", () => {
expect(resolveDisplayBaseUrl("https://old.example.com", "https://api.example.com/")).toBe(
"https://api.example.com"
);
});
it("prefers a public browser origin over a loopback build-time value", async () => {
vi.stubEnv("NEXT_PUBLIC_BASE_URL", "http://localhost:20128");
vi.stubGlobal("location", { origin: "https://api.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>;
}
act(() => {
root.render(<C />);
});
expect(container.querySelector('[data-testid="value"]')?.textContent).toBe(
DEFAULT_DISPLAY_BASE_URL
);
await act(async () => {});
expect(container.querySelector('[data-testid="value"]')?.textContent).toBe(
"https://api.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", "");

View File

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

View File

@@ -10,13 +10,122 @@ function normalizeUrl(value?: string): string | null {
return trimmed.replace(/\/+$/, "");
}
/**
* One RFC1918 / special-use IPv4 range, expressed as closed intervals on the
* first two octets. Unbounded ends use +/-Infinity so a single numeric
* comparison covers them without an extra branch.
*/
interface Ipv4Range {
readonly firstMin: number;
readonly firstMax: number;
readonly secondMin: number;
readonly secondMax: number;
}
/** RFC1918 + special-use IPv4 ranges treated as non-public for display purposes. */
const PRIVATE_IPV4_RANGES: readonly Ipv4Range[] = [
{ firstMin: 0, firstMax: 0, secondMin: -Infinity, secondMax: Infinity }, // 0.0.0.0/8 ("this" network)
{ firstMin: 10, firstMax: 10, secondMin: -Infinity, secondMax: Infinity }, // RFC1918 10.0.0.0/8
{ firstMin: 127, firstMax: 127, secondMin: -Infinity, secondMax: Infinity }, // loopback 127.0.0.0/8
{ firstMin: 224, firstMax: Infinity, secondMin: -Infinity, secondMax: Infinity }, // multicast/reserved/broadcast
{ firstMin: 100, firstMax: 100, secondMin: 64, secondMax: 127 }, // CGNAT RFC6598 100.64.0.0/10
{ firstMin: 169, firstMax: 169, secondMin: 254, secondMax: 254 }, // link-local 169.254.0.0/16
{ firstMin: 172, firstMax: 172, secondMin: 16, secondMax: 31 }, // RFC1918 172.16.0.0/12
{ firstMin: 192, firstMax: 192, secondMin: 168, secondMax: 168 }, // RFC1918 192.168.0.0/16
];
function isInIpv4Range(first: number, second: number, range: Ipv4Range): boolean {
return (
first >= range.firstMin &&
first <= range.firstMax &&
second >= range.secondMin &&
second <= range.secondMax
);
}
function isPrivateIpv4(hostname: string): boolean {
const octets = hostname.split(".").map(Number);
if (octets.length !== 4 || octets.some((octet) => !Number.isInteger(octet))) return false;
const [first, second] = octets;
return PRIVATE_IPV4_RANGES.some((range) => isInIpv4Range(first, second, range));
}
function isSupportedProtocol(protocol: string): boolean {
return protocol === "http:" || protocol === "https:";
}
function isLoopbackHostname(hostname: string): boolean {
return !hostname || hostname === "localhost" || hostname.endsWith(".localhost");
}
function isMulticastDnsHostname(hostname: string): boolean {
return hostname.endsWith(".local");
}
function isIpv6LoopbackOrUnspecified(hostname: string): boolean {
return hostname === "::" || hostname === "::1";
}
function isIpv6UniqueLocal(hostname: string): boolean {
// RFC 4193 Unique Local Addresses: fc00::/7 (prefixes "fc" and "fd").
return hostname.startsWith("fc") || hostname.startsWith("fd");
}
const IPV6_LINK_LOCAL_PATTERN = /^fe[89ab]/;
function isIpv6LinkLocal(hostname: string): boolean {
// RFC 4291 link-local: fe80::/10.
return IPV6_LINK_LOCAL_PATTERN.test(hostname);
}
/** Combines the IPv6-specific non-public checks the caller gates on `isIpv6`. */
function isNonPublicIpv6(hostname: string): boolean {
return (
isIpv6LoopbackOrUnspecified(hostname) ||
isIpv6UniqueLocal(hostname) ||
isIpv6LinkLocal(hostname)
);
}
export function isPublicDisplayBaseUrl(value?: string): boolean {
const normalized = normalizeUrl(value);
if (!normalized) return false;
try {
const parsed = new URL(normalized);
if (!isSupportedProtocol(parsed.protocol)) return false;
const hostname = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, "");
if (isLoopbackHostname(hostname)) return false;
if (isMulticastDnsHostname(hostname) || isPrivateIpv4(hostname)) return false;
// IPv6-only checks stay gated on isIpv6 — hostnames like "fdroid.example.com"
// legitimately start with "fd" and must not be misclassified as ULA addresses.
const isIpv6 = hostname.includes(":");
if (isIpv6 && isNonPublicIpv6(hostname)) return false;
return true;
} catch {
return false;
}
}
export function resolveDisplayBaseUrl(envValue?: string, browserOrigin?: string): string {
const configuredUrl = normalizeUrl(envValue);
const currentOrigin = normalizeUrl(browserOrigin);
if (currentOrigin && isPublicDisplayBaseUrl(currentOrigin)) return currentOrigin;
if (configuredUrl && isPublicDisplayBaseUrl(configuredUrl)) return configuredUrl;
return currentOrigin ?? configuredUrl ?? DEFAULT_DISPLAY_BASE_URL;
}
/**
* 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.
* Resolution chain after client mount:
* 1. Public browser origin — proves the current tunnel/domain is reachable.
* 2. Public NEXT_PUBLIC_BASE_URL — keeps a configured public URL when opened locally.
* 3. Current browser origin, configured URL, then localhost as local fallbacks.
*
* DISPLAY ONLY — do NOT use this hook for OAuth `redirect_uri`.
* OAuth callers must read `process.env.NEXT_PUBLIC_BASE_URL` directly to avoid
@@ -29,8 +138,7 @@ export function useDisplayBaseUrl(): string {
const [url, setUrl] = useState<string>(envValue ?? DEFAULT_DISPLAY_BASE_URL);
useEffect(() => {
if (envValue) return;
const origin = normalizeUrl(window.location.origin) ?? DEFAULT_DISPLAY_BASE_URL;
const resolvedUrl = resolveDisplayBaseUrl(envValue ?? undefined, window.location.origin);
// 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
@@ -38,7 +146,7 @@ export function useDisplayBaseUrl(): string {
// before the microtask fires on the first effect invocation).
let unmounted = false;
queueMicrotask(() => {
if (!unmounted) setUrl(origin);
if (!unmounted) setUrl(resolvedUrl);
});
return () => {
unmounted = true;

View File

@@ -0,0 +1,107 @@
// @vitest-environment jsdom
import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
const { pushMock, replaceMock } = vi.hoisted(() => ({
pushMock: vi.fn(),
replaceMock: vi.fn(),
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({
push: pushMock,
replace: replaceMock,
}),
}));
vi.mock("next-intl", () => ({
useTranslations: () => (key: string) => key,
}));
vi.mock("@/shared/hooks", () => ({
useDisplayBaseUrl: () => "https://api.example.com",
}));
const { default: OnboardingWizard } =
await import("../../../src/app/(dashboard)/dashboard/onboarding/page");
function jsonResponse(body: unknown): Response {
return {
ok: true,
json: async () => body,
} as Response;
}
let container: HTMLDivElement;
let root: ReturnType<typeof createRoot>;
async function waitForText(text: string): Promise<void> {
for (let attempt = 0; attempt < 20; attempt += 1) {
if (container.textContent?.includes(text)) return;
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
}
throw new Error(`Timed out waiting for text: ${text}`);
}
function findButton(label: string): HTMLButtonElement {
const button = Array.from(container.querySelectorAll("button")).find(
(candidate) => candidate.textContent?.trim() === label
);
if (!button) throw new Error(`Button not found: ${label}`);
return button;
}
async function clickButton(label: string): Promise<void> {
await act(async () => {
findButton(label).click();
});
}
beforeEach(() => {
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
pushMock.mockReset();
replaceMock.mockReset();
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
if (String(input) === "/api/settings") {
return jsonResponse({ setupComplete: false, apiPort: 20131 });
}
throw new Error(`Unexpected request: ${String(input)}`);
})
);
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
vi.unstubAllGlobals();
vi.clearAllMocks();
});
it("renders the public API URL instead of rebuilding an internal apiPort URL", async () => {
await act(async () => {
root.render(<OnboardingWizard />);
});
await waitForText("getStarted");
await clickButton("getStarted");
await clickButton("skip");
await clickButton("skip");
await clickButton("skip");
await clickButton("skip");
await waitForText("https://api.example.com/api/v1");
expect(container.textContent).toContain("https://api.example.com/api/v1");
expect(container.textContent).not.toContain(":20131/api/v1");
expect(replaceMock).not.toHaveBeenCalled();
});