"use client"; import { useState, useEffect, useRef } from "react"; import PropTypes from "prop-types"; import { Card, Button, Input, Modal, CardSkeleton, Toggle } from "@/shared/components"; import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; const TUNNEL_BENEFITS = [ { icon: "public", title: "Access Anywhere", desc: "Use your API from any network" }, { icon: "group", title: "Share Endpoint", desc: "Share URL with team members" }, { icon: "code", title: "Use in Cursor/Cline", desc: "Connect AI tools remotely" }, { icon: "lock", title: "Encrypted", desc: "End-to-end TLS via Cloudflare" }, ]; const TUNNEL_PING_INTERVAL_MS = 2000; const TUNNEL_PING_MAX_MS = 300000; export default function APIPageClient({ machineId }) { const [keys, setKeys] = useState([]); const [loading, setLoading] = useState(true); const [showAddModal, setShowAddModal] = useState(false); const [newKeyName, setNewKeyName] = useState(""); const [createdKey, setCreatedKey] = useState(null); const [requireApiKey, setRequireApiKey] = useState(false); const [requireLogin, setRequireLogin] = useState(true); const [hasPassword, setHasPassword] = useState(true); const [tunnelDashboardAccess, setTunnelDashboardAccess] = useState(false); // Cloudflare Tunnel state const [tunnelChecking, setTunnelChecking] = useState(true); const [tunnelEnabled, setTunnelEnabled] = useState(false); const [tunnelUrl, setTunnelUrl] = useState(""); const [tunnelPublicUrl, setTunnelPublicUrl] = useState(""); const [tunnelLoading, setTunnelLoading] = useState(false); const [tunnelProgress, setTunnelProgress] = useState(""); const [tunnelStatus, setTunnelStatus] = useState(null); const [showEnableTunnelModal, setShowEnableTunnelModal] = useState(false); const [showDisableTunnelModal, setShowDisableTunnelModal] = useState(false); // Tailscale state const [tsEnabled, setTsEnabled] = useState(false); const [tsUrl, setTsUrl] = useState(""); const [tsLoading, setTsLoading] = useState(false); const [tsProgress, setTsProgress] = useState(""); const [tsStatus, setTsStatus] = useState(null); const [tsInstalled, setTsInstalled] = useState(null); // null=checking, true/false const [tsInstalling, setTsInstalling] = useState(false); const [tsInstallLog, setTsInstallLog] = useState([]); const [tsSudoPassword, setTsSudoPassword] = useState(""); const [tsConnecting, setTsConnecting] = useState(false); const [showTsModal, setShowTsModal] = useState(false); const [showDisableTsModal, setShowDisableTsModal] = useState(false); const tsLogRef = useRef(null); // API key visibility toggle state const [visibleKeys, setVisibleKeys] = useState(new Set()); const { copied, copy } = useCopyToClipboard(); // Auto-scroll install log useEffect(() => { if (tsLogRef.current) tsLogRef.current.scrollTop = tsLogRef.current.scrollHeight; }, [tsInstallLog]); useEffect(() => { fetchData(); loadSettings(); }, []); const loadSettings = async () => { setTunnelChecking(true); try { const [settingsRes, statusRes] = await Promise.all([ fetch("/api/settings"), fetch("/api/tunnel/status") ]); if (settingsRes.ok) { const data = await settingsRes.json(); setRequireApiKey(data.requireApiKey || false); setRequireLogin(data.requireLogin !== false); setHasPassword(data.hasPassword || false); setTunnelDashboardAccess(data.tunnelDashboardAccess || false); } if (statusRes.ok) { const data = await statusRes.json(); const tEnabled = data.tunnel?.enabled || false; const tUrl = data.tunnel?.tunnelUrl || ""; const tPublicUrl = data.tunnel?.publicUrl || ""; setTunnelUrl(tUrl); setTunnelPublicUrl(tPublicUrl); const tsEn = data.tailscale?.enabled || false; const tsUrlVal = data.tailscale?.tunnelUrl || ""; setTsUrl(tsUrlVal); if (tsEn && tsUrlVal) { setTsLoading(true); setTsProgress("Checking Tailscale..."); const tsHealthUrl = `${tsUrlVal}/api/health`; try { const tsPing = await fetch(tsHealthUrl, { mode: "no-cors", cache: "no-store" }); if (tsPing.ok || tsPing.type === "opaque") { setTsEnabled(true); } else { const ok = await pingTsHealth(tsUrlVal); setTsEnabled(ok); if (!ok) setTsStatus({ type: "warning", message: "Tailscale not reachable." }); } } catch { const ok = await pingTsHealth(tsUrlVal); setTsEnabled(ok); if (!ok) setTsStatus({ type: "warning", message: "Tailscale not reachable." }); } finally { setTsLoading(false); setTsProgress(""); } } else { setTsEnabled(tsEn); } if (tEnabled && (tPublicUrl || tUrl)) { // Ping once to verify reachable const healthUrl = `${tPublicUrl || tUrl}/api/health`; try { const ping = await fetch(healthUrl, { cache: "no-store" }); if (ping.ok) { setTunnelEnabled(true); } else { pingTunnelHealth(tPublicUrl || tUrl); } } catch { pingTunnelHealth(tPublicUrl || tUrl); } } else { setTunnelEnabled(tEnabled); } } } catch (error) { console.log("Error loading settings:", error); } finally { setTunnelChecking(false); } }; const handleTunnelDashboardAccess = async (value) => { try { const res = await fetch("/api/settings", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ tunnelDashboardAccess: value }), }); if (res.ok) setTunnelDashboardAccess(value); } catch (error) { console.log("Error updating tunnelDashboardAccess:", error); } }; const handleRequireApiKey = async (value) => { try { const res = await fetch("/api/settings", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ requireApiKey: value }), }); if (res.ok) setRequireApiKey(value); } catch (error) { console.log("Error updating requireApiKey:", error); } }; const fetchData = async () => { try { const keysRes = await fetch("/api/keys"); const keysData = await keysRes.json(); if (keysRes.ok) { setKeys(keysData.keys || []); } } catch (error) { console.log("Error fetching data:", error); } finally { setLoading(false); } }; // u2500u2500u2500 Cloudflare Tunnel handlers // Ping tunnel health until reachable, also check backend status to detect process die const pingTunnelHealth = async (url) => { setTunnelLoading(true); setTunnelProgress("Waiting for tunnel ready..."); const healthUrl = `${url}/api/health`; const start = Date.now(); while (Date.now() - start < TUNNEL_PING_MAX_MS) { await new Promise((r) => setTimeout(r, TUNNEL_PING_INTERVAL_MS)); try { const ping = await fetch(healthUrl, { mode: "no-cors", cache: "no-store" }); if (ping.ok || ping.type === "opaque") { setTunnelEnabled(true); setTunnelLoading(false); setTunnelProgress(""); return true; } } catch { /* not ready yet */ } // Every 5 pings (~10s), check if backend process still alive if ((Date.now() - start) % 10000 < TUNNEL_PING_INTERVAL_MS) { try { const statusRes = await fetch("/api/tunnel/status"); if (statusRes.ok) { const status = await statusRes.json(); if (!status.tunnel?.enabled) { setTunnelStatus({ type: "error", message: "Tunnel process stopped unexpectedly." }); setTunnelLoading(false); setTunnelProgress(""); return false; } } } catch { /* ignore */ } } } setTunnelStatus({ type: "error", message: "Tunnel created but not reachable. Please try again." }); setTunnelLoading(false); setTunnelProgress(""); return false; }; const handleEnableTunnel = async () => { setShowEnableTunnelModal(false); setTunnelLoading(true); setTunnelStatus(null); setTunnelProgress("Creating tunnel..."); try { const res = await fetch("/api/tunnel/enable", { method: "POST" }); const data = await res.json(); if (!res.ok) { setTunnelStatus({ type: "error", message: data.error || "Failed to enable tunnel" }); return; } const url = data.publicUrl || data.tunnelUrl; if (!url) { setTunnelStatus({ type: "error", message: "No tunnel URL returned" }); return; } setTunnelUrl(data.tunnelUrl || ""); setTunnelPublicUrl(data.publicUrl || ""); await pingTunnelHealth(url); } catch (error) { setTunnelStatus({ type: "error", message: error.message }); } finally { setTunnelLoading(false); setTunnelProgress(""); } }; const handleDisableTunnel = async () => { setTunnelLoading(true); setTunnelStatus(null); try { const res = await fetch("/api/tunnel/disable", { method: "POST" }); const data = await res.json(); if (res.ok) { setTunnelEnabled(false); setTunnelUrl(""); setTunnelPublicUrl(""); setShowDisableTunnelModal(false); setTunnelStatus({ type: "success", message: "Tunnel disabled" }); } else { setTunnelStatus({ type: "error", message: data.error || "Failed to disable tunnel" }); } } catch (error) { setTunnelStatus({ type: "error", message: error.message }); } finally { setTunnelLoading(false); } }; // u2500u2500u2500 Tailscale handlers const checkTailscaleInstalled = async () => { setTsInstalled(null); try { const res = await fetch("/api/tunnel/tailscale-check"); if (res.ok) { const data = await res.json(); setTsInstalled(data.installed); return data; } } catch { /* ignore */ } setTsInstalled(false); return { installed: false }; }; const handleInstallTailscale = async () => { setTsInstalling(true); setTsStatus(null); setTsInstallLog([]); try { const res = await fetch("/api/tunnel/tailscale-install", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ sudoPassword: tsSudoPassword }), }); setTsSudoPassword(""); const reader = res.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const parts = buffer.split("\n\n"); buffer = parts.pop() || ""; for (const part of parts) { const lines = part.split("\n"); let event = "progress"; let data = null; for (const line of lines) { if (line.startsWith("event: ")) event = line.slice(7).trim(); if (line.startsWith("data: ")) { try { data = JSON.parse(line.slice(6)); } catch { /* skip */ } } } if (!data) continue; if (event === "progress") { setTsInstallLog((prev) => [...prev.slice(-50), data.message]); } else if (event === "done") { setTsInstalled(true); setTsInstalling(false); return; } else if (event === "error") { setTsStatus({ type: "error", message: data.error || "Install failed" }); } } } } catch (e) { setTsStatus({ type: "error", message: e.message }); } finally { setTsInstalling(false); } }; // Ping Tailscale health until reachable const pingTsHealth = async (url) => { setTsProgress("Waiting for Tailscale ready..."); const healthUrl = `${url}/api/health`; const start = Date.now(); while (Date.now() - start < TUNNEL_PING_MAX_MS) { await new Promise((r) => setTimeout(r, TUNNEL_PING_INTERVAL_MS)); try { const ping = await fetch(healthUrl, { mode: "no-cors", cache: "no-store" }); if (ping.ok || ping.type === "opaque") return true; } catch { /* not ready yet */ } } return false; }; const handleConnectTailscale = async (preOpenedTab) => { const tab = preOpenedTab || null; setShowTsModal(false); setTsConnecting(true); setTsLoading(true); setTsStatus(null); setTsProgress("Connecting..."); try { const res = await fetch("/api/tunnel/tailscale-enable", { method: "POST" }); const data = await res.json(); if (res.ok && data.success) { if (tab) tab.close(); setTsUrl(data.tunnelUrl || ""); const reachable = await pingTsHealth(data.tunnelUrl); if (reachable) { setTsEnabled(true); setTsStatus(null); } else { setTsEnabled(true); setTsStatus({ type: "warning", message: "Connected but not reachable yet." }); } return; } // Needs login: redirect pre-opened tab or open new if (data.needsLogin && data.authUrl) { if (tab) tab.location.href = data.authUrl; else window.open(data.authUrl, "tailscale_auth", "width=600,height=700"); setTsProgress("Waiting for login..."); for (let i = 0; i < 40; i++) { await new Promise((r) => setTimeout(r, 3000)); try { const r2 = await fetch("/api/tunnel/tailscale-check"); if (r2.ok) { const check = await r2.json(); if (check.loggedIn) { setTsProgress("Starting funnel..."); const res2 = await fetch("/api/tunnel/tailscale-enable", { method: "POST" }); const data2 = await res2.json(); if (res2.ok && data2.success) { if (tab) tab.close(); setTsUrl(data2.tunnelUrl || ""); const ok2 = await pingTsHealth(data2.tunnelUrl); if (ok2) { setTsEnabled(true); setTsStatus(null); } else { setTsEnabled(true); setTsStatus({ type: "warning", message: "Connected but not reachable yet." }); } } else if (data2.funnelNotEnabled && data2.enableUrl) { await pollFunnelEnable(data2.enableUrl, tab); } else { setTsStatus({ type: "error", message: data2.error || "Failed to start funnel" }); } return; } } } catch { /* retry */ } } setTsStatus({ type: "error", message: "Login timed out. Please try again." }); return; } // Funnel not enabled: redirect pre-opened tab if (data.funnelNotEnabled && data.enableUrl) { await pollFunnelEnable(data.enableUrl, tab); return; } if (tab) tab.close(); setTsStatus({ type: "error", message: data.error || "Failed to connect" }); } catch (error) { if (tab) tab.close(); setTsStatus({ type: "error", message: error.message }); } finally { setTsLoading(false); setTsConnecting(false); setTsProgress(""); } }; const pollFunnelEnable = async (enableUrl, tab) => { if (tab) tab.location.href = enableUrl; else window.open(enableUrl, "tailscale_auth", "width=600,height=700"); setTsProgress("Enable Funnel in browser, waiting..."); for (let i = 0; i < 40; i++) { await new Promise((r) => setTimeout(r, 3000)); try { const res = await fetch("/api/tunnel/tailscale-enable", { method: "POST" }); const data = await res.json(); if (res.ok && data.success) { if (tab) tab.close(); setTsUrl(data.tunnelUrl || ""); const ok3 = await pingTsHealth(data.tunnelUrl); if (ok3) { setTsEnabled(true); setTsStatus(null); } else { setTsEnabled(true); setTsStatus({ type: "warning", message: "Connected but not reachable yet." }); } return; } if (data.funnelNotEnabled) continue; if (data.error) { setTsStatus({ type: "error", message: data.error }); return; } } catch { /* retry */ } } setTsStatus({ type: "error", message: "Timed out waiting for Funnel to be enabled." }); }; const handleDisableTailscale = async () => { setTsLoading(true); setTsStatus(null); try { const res = await fetch("/api/tunnel/tailscale-disable", { method: "POST" }); const data = await res.json(); if (res.ok) { setTsEnabled(false); setTsUrl(""); setShowDisableTsModal(false); setTsStatus({ type: "success", message: "Tailscale disabled" }); } else { setTsStatus({ type: "error", message: data.error || "Failed to disable Tailscale" }); } } catch (e) { setTsStatus({ type: "error", message: e.message }); } finally { setTsLoading(false); } }; const handleOpenTsModal = async () => { setTsStatus(null); setTsInstallLog([]); setShowTsModal(true); await checkTailscaleInstalled(); }; const handleCreateKey = async () => { if (!newKeyName.trim()) return; try { const res = await fetch("/api/keys", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: newKeyName }), }); const data = await res.json(); if (res.ok) { setCreatedKey(data.key); await fetchData(); setNewKeyName(""); setShowAddModal(false); } } catch (error) { console.log("Error creating key:", error); } }; const handleDeleteKey = async (id) => { if (!confirm("Delete this API key?")) return; try { const res = await fetch(`/api/keys/${id}`, { method: "DELETE" }); if (res.ok) { setKeys(keys.filter((k) => k.id !== id)); // Clean up visibility state setVisibleKeys(prev => { const next = new Set(prev); next.delete(id); return next; }); } } catch (error) { console.log("Error deleting key:", error); } }; const handleToggleKey = async (id, isActive) => { try { const res = await fetch(`/api/keys/${id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ isActive }), }); if (res.ok) { setKeys(prev => prev.map(k => k.id === id ? { ...k, isActive } : k)); } } catch (error) { console.log("Error toggling key:", error); } }; const maskKey = (fullKey) => { if (!fullKey) return ""; return fullKey.length > 8 ? fullKey.slice(0, 8) + "..." : fullKey; }; const toggleKeyVisibility = (keyId) => { setVisibleKeys(prev => { const next = new Set(prev); if (next.has(keyId)) next.delete(keyId); else next.add(keyId); return next; }); }; const [baseUrl, setBaseUrl] = useState("/v1"); // Hydration fix: Only access window on client side useEffect(() => { if (typeof window !== "undefined") { setBaseUrl(`${window.location.origin}/v1`); } }, []); if (loading) { return (
Allow dashboard access via tunnel
Require API key
Requests without a valid key will be rejected
No API keys yet
Create your first API key to get started
{key.name}
{visibleKeys.has(key.id) ? key.key : maskKey(key.key)}
Created {new Date(key.createdAt).toLocaleDateString()}
{key.isActive === false && (Paused
)}Save this key now!
This is the only time you will see this key. Store it securely.
Cloudflare Tunnel
Expose your local 9Router to the internet. No port forwarding, no static IP needed. Share endpoint URL with your team or use it in Cursor, Cline, and other AI tools from anywhere.
{benefit.title}
{benefit.desc}
Requires outbound port 7844 (TCP/UDP). Connection may take 10-30s.
The Cloudflare tunnel will be disconnected. Remote access via tunnel URL will stop working.
progress_activity Checking...
)} {/* Not installed */} {tsInstalled === false && !tsInstalling && (Tailscale is not installed. Install it to enable Funnel.
Tailscale Funnel will be stopped. Remote access via Tailscale URL will stop working.