2026-01-05 09:58:59 +07:00
"use client" ;
2026-05-09 17:48:20 +07:00
import { useState , useEffect , useRef , useCallback } from "react" ;
2026-01-20 13:16:34 +07:00
import PropTypes from "prop-types" ;
2026-05-13 15:32:17 +07:00
import { Card , Button , Input , Modal , CardSkeleton , Toggle , ConfirmModal } from "@/shared/components" ;
2026-01-05 09:58:59 +07:00
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard" ;
2026-06-14 19:31:09 +07:00
import {
TUNNEL_BENEFITS ,
TUNNEL_PING_INTERVAL_MS ,
TUNNEL_PING_MAX_MS ,
STATUS_POLL_FAST_MS ,
REACHABLE_MISS_THRESHOLD ,
CLIENT_PING_FAST_MS ,
} from "./endpointConstants" ;
import { clientPingUrl , clientPingAny } from "./endpointPing" ;
import EndpointRow from "./components/EndpointRow" ;
import StatusAlert from "./components/StatusAlert" ;
import Tooltip from "./components/Tooltip" ;
import SecurityWarning from "./components/SecurityWarning" ;
2026-01-05 09:58:59 +07:00
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 );
2026-05-13 15:32:17 +07:00
const [ confirmState , setConfirmState ] = useState ( null );
2026-01-05 09:58:59 +07:00
2026-02-21 16:42:46 +07:00
const [ requireApiKey , setRequireApiKey ] = useState ( false );
2026-04-11 14:56:35 +07:00
const [ requireLogin , setRequireLogin ] = useState ( true );
const [ hasPassword , setHasPassword ] = useState ( true );
2026-06-26 10:12:25 +07:00
const [ tunnelDashboardAccess , setTunnelDashboardAccess ] = useState ( false );
2026-04-10 18:16:14 +07:00
2026-06-26 10:12:25 +07:00
// Cloudflare Tunnel state
2026-04-10 18:16:14 +07:00
const [ tunnelChecking , setTunnelChecking ] = useState ( true );
2026-02-21 16:42:46 +07:00
const [ tunnelEnabled , setTunnelEnabled ] = useState ( false );
2026-05-09 17:48:20 +07:00
const [ tunnelReachable , setTunnelReachable ] = useState ( false );
2026-02-21 16:42:46 +07:00
const [ tunnelUrl , setTunnelUrl ] = useState ( "" );
2026-03-19 23:47:13 +07:00
const [ tunnelPublicUrl , setTunnelPublicUrl ] = useState ( "" );
2026-02-21 16:42:46 +07:00
const [ tunnelLoading , setTunnelLoading ] = useState ( false );
const [ tunnelProgress , setTunnelProgress ] = useState ( "" );
const [ tunnelStatus , setTunnelStatus ] = useState ( null );
2026-04-10 18:16:14 +07:00
const [ showEnableTunnelModal , setShowEnableTunnelModal ] = useState ( false );
const [ showDisableTunnelModal , setShowDisableTunnelModal ] = useState ( false );
// Tailscale state
const [ tsEnabled , setTsEnabled ] = useState ( false );
2026-05-09 17:48:20 +07:00
const [ tsReachable , setTsReachable ] = useState ( false );
2026-04-10 18:16:14 +07:00
const [ tsUrl , setTsUrl ] = useState ( "" );
const [ tsLoading , setTsLoading ] = useState ( false );
const [ tsProgress , setTsProgress ] = useState ( "" );
const [ tsStatus , setTsStatus ] = useState ( null );
2026-05-10 08:44:14 +07:00
const [ tsAuthUrl , setTsAuthUrl ] = useState ( "" );
const [ tsAuthLabel , setTsAuthLabel ] = useState ( "" );
2026-04-10 18:16:14 +07:00
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 );
2026-05-09 17:48:20 +07:00
// Debounce reachable=false: server may briefly return false during background refresh.
// Only flip UI to "reconnecting" after N consecutive misses to avoid spinner flicker.
const tunnelMissRef = useRef ( 0 );
const tsMissRef = useRef ( 0 );
2026-05-09 22:48:07 +07:00
// Browser-side reachable cache (independent of backend DNS quirks)
const tunnelClientReachableRef = useRef ( false );
const tsClientReachableRef = useRef ( false );
2026-05-09 17:48:20 +07:00
// Track whether reachable=true was ever observed in this session.
// Distinguishes "Checking..." (initial cold cache) from "Reconnecting..." (lost connection).
const tunnelEverReachableRef = useRef ( false );
const tsEverReachableRef = useRef ( false );
const [ tunnelEverReachable , setTunnelEverReachable ] = useState ( false );
const [ tsEverReachable , setTsEverReachable ] = useState ( false );
2026-02-28 11:07:51 +07:00
// API key visibility toggle state
const [ visibleKeys , setVisibleKeys ] = useState ( new Set ());
2026-01-05 09:58:59 +07:00
2026-06-06 12:35:08 +07:00
// Client-side local/remote detection (UI hint only, not a security gate)
const [ isRemoteHost , setIsRemoteHost ] = useState ( false );
useEffect (() => {
if ( typeof window !== "undefined" )
setIsRemoteHost ( ! [ "localhost" , "127.0.0.1" , "::1" ]. includes ( window . location . hostname ));
}, []);
2026-01-05 09:58:59 +07:00
const { copied , copy } = useCopyToClipboard ();
2026-05-20 10:05:22 +07:00
// Security gate: block remote exposure while dashboard uses default password or login is off.
const isLoginUnsafe = ! requireLogin || ! hasPassword ;
const unsafeReason = ! requireLogin
? "Enable \"Require login\" and set a custom password before activating the tunnel."
: "Change the default dashboard password before activating the tunnel." ;
2026-04-10 18:16:14 +07:00
// Auto-scroll install log
useEffect (() => {
if ( tsLogRef . current ) tsLogRef . current . scrollTop = tsLogRef . current . scrollHeight ;
}, [ tsInstallLog ]);
2026-01-05 09:58:59 +07:00
useEffect (() => {
fetchData ();
2026-02-21 16:42:46 +07:00
loadSettings ();
2026-05-16 12:38:06 +07:00
}, []);
2026-05-21 11:28:49 +07:00
// Status poll: only while degraded (not yet reachable). Stop once healthy to avoid spam.
// Visibility re-check: refresh once when tab becomes visible.
2026-05-16 12:38:06 +07:00
useEffect (() => {
const anyEnabled = tunnelEnabled || tsEnabled ;
if ( ! anyEnabled ) return ;
const tunnelHealthy = ! tunnelEnabled || tunnelReachable ;
const tsHealthy = ! tsEnabled || tsReachable ;
const allHealthy = tunnelHealthy && tsHealthy ;
2026-05-03 22:10:03 +07:00
const onVisible = () => { if ( ! document . hidden ) syncTunnelStatus (); };
document . addEventListener ( "visibilitychange" , onVisible );
2026-05-21 11:28:49 +07:00
if ( allHealthy ) return () => document . removeEventListener ( "visibilitychange" , onVisible );
const timer = setInterval (() => { if ( ! document . hidden ) syncTunnelStatus (); }, STATUS_POLL_FAST_MS );
2026-05-03 22:10:03 +07:00
return () => {
2026-05-21 11:28:49 +07:00
clearInterval ( timer );
2026-05-03 22:10:03 +07:00
document . removeEventListener ( "visibilitychange" , onVisible );
};
2026-05-16 12:38:06 +07:00
}, [ tunnelEnabled , tsEnabled , tunnelReachable , tsReachable ]);
2026-01-05 09:58:59 +07:00
2026-05-09 22:48:07 +07:00
// Browser-side periodic ping: probes tunnel/tailscale URLs directly so UI stays
// "reachable" even when backend DNS (1.1.1.1) hiccups on *.ts.net or *.trycloudflare.com.
2026-05-16 12:38:06 +07:00
// Adaptive: slow when healthy, fast when degraded; pause when tab hidden.
2026-05-09 22:48:07 +07:00
useEffect (() => {
const probeBoth = async () => {
2026-05-16 12:38:06 +07:00
if ( document . hidden ) return ;
2026-05-21 11:28:49 +07:00
if ( tunnelEnabled && ( tunnelUrl || tunnelPublicUrl )) {
const ok = await clientPingAny ( tunnelPublicUrl , tunnelUrl );
2026-05-09 22:48:07 +07:00
tunnelClientReachableRef . current = ok ;
if ( ok ) { tunnelMissRef . current = 0 ; setTunnelReachable ( true ); if ( ! tunnelEverReachableRef . current ) { tunnelEverReachableRef . current = true ; setTunnelEverReachable ( true ); } }
2026-05-21 14:30:59 +07:00
else { tunnelMissRef . current += 1 ; if ( tunnelMissRef . current >= REACHABLE_MISS_THRESHOLD ) setTunnelReachable ( false ); }
2026-05-09 22:48:07 +07:00
} else {
tunnelClientReachableRef . current = false ;
}
if ( tsEnabled && tsUrl ) {
const ok = await clientPingUrl ( tsUrl );
tsClientReachableRef . current = ok ;
if ( ok ) { tsMissRef . current = 0 ; setTsReachable ( true ); if ( ! tsEverReachableRef . current ) { tsEverReachableRef . current = true ; setTsEverReachable ( true ); } }
2026-05-21 14:30:59 +07:00
else { tsMissRef . current += 1 ; if ( tsMissRef . current >= REACHABLE_MISS_THRESHOLD ) setTsReachable ( false ); }
2026-05-09 22:48:07 +07:00
} else {
tsClientReachableRef . current = false ;
}
};
2026-05-21 11:28:49 +07:00
const anyEnabled = ( tunnelEnabled && ( tunnelUrl || tunnelPublicUrl )) || ( tsEnabled && tsUrl );
2026-05-16 12:38:06 +07:00
if ( ! anyEnabled ) return ;
2026-05-09 22:48:07 +07:00
probeBoth ();
2026-05-16 12:38:06 +07:00
const tunnelHealthy = ! tunnelEnabled || tunnelReachable ;
const tsHealthy = ! tsEnabled || tsReachable ;
2026-05-21 11:28:49 +07:00
if ( tunnelHealthy && tsHealthy ) return ;
const id = setInterval ( probeBoth , CLIENT_PING_FAST_MS );
2026-05-09 22:48:07 +07:00
return () => clearInterval ( id );
2026-05-21 11:28:49 +07:00
}, [ tunnelEnabled , tunnelUrl , tunnelPublicUrl , tsEnabled , tsUrl , tunnelReachable , tsReachable ]);
2026-05-09 22:48:07 +07:00
2026-05-21 11:28:49 +07:00
// Client-side reachable only (server no longer probes; watchdog handles backend health).
// Miss-debounce: only flip to false after N consecutive misses.
const updateReachable = useCallback (( _unused , clientRef , missRef , setter , everRef , everSetter ) => {
const reachable = clientRef . current ;
2026-05-09 17:48:20 +07:00
if ( reachable ) {
missRef . current = 0 ;
setter ( true );
if ( ! everRef . current ) {
everRef . current = true ;
everSetter ( true );
}
} else {
missRef . current += 1 ;
if ( missRef . current >= REACHABLE_MISS_THRESHOLD ) setter ( false );
}
}, []);
2026-05-03 22:10:03 +07:00
// Trust user intent (settingsEnabled): UI stays "enabled" while watchdog restarts process
const syncTunnelStatus = async () => {
try {
const statusRes = await fetch ( "/api/tunnel/status" , { cache : "no-store" });
if ( ! statusRes . ok ) return ;
const data = await statusRes . json ();
const tEnabled = data . tunnel ? . settingsEnabled ?? data . tunnel ? . enabled ?? false ;
const tUrl = data . tunnel ? . tunnelUrl || "" ;
setTunnelUrl ( tUrl );
2026-05-15 18:22:10 +07:00
setTunnelPublicUrl ( data . tunnel ? . publicUrl || "" );
2026-05-03 22:10:03 +07:00
setTunnelEnabled ( tEnabled );
2026-05-21 11:28:49 +07:00
updateReachable ( null , tunnelClientReachableRef , tunnelMissRef , setTunnelReachable , tunnelEverReachableRef , setTunnelEverReachable );
2026-05-03 22:10:03 +07:00
const tsEn = data . tailscale ? . settingsEnabled ?? data . tailscale ? . enabled ?? false ;
const tsUrlVal = data . tailscale ? . tunnelUrl || "" ;
setTsUrl ( tsUrlVal );
setTsEnabled ( tsEn );
2026-05-21 11:28:49 +07:00
updateReachable ( null , tsClientReachableRef , tsMissRef , setTsReachable , tsEverReachableRef , setTsEverReachable );
2026-05-03 22:10:03 +07:00
} catch { /* ignore poll errors */ }
};
2026-02-21 16:42:46 +07:00
const loadSettings = async () => {
2026-04-10 18:16:14 +07:00
setTunnelChecking ( true );
2026-02-21 16:42:46 +07:00
try {
2026-04-10 18:16:14 +07:00
const [ settingsRes , statusRes ] = await Promise . all ([
2026-02-21 16:42:46 +07:00
fetch ( "/api/settings" ),
2026-05-03 22:10:03 +07:00
fetch ( "/api/tunnel/status" , { cache : "no-store" })
2026-02-21 16:42:46 +07:00
]);
if ( settingsRes . ok ) {
const data = await settingsRes . json ();
setRequireApiKey ( data . requireApiKey || false );
2026-04-11 14:56:35 +07:00
setRequireLogin ( data . requireLogin !== false );
setHasPassword ( data . hasPassword || false );
setTunnelDashboardAccess ( data . tunnelDashboardAccess || false );
2026-02-21 16:42:46 +07:00
}
2026-04-10 18:16:14 +07:00
if ( statusRes . ok ) {
const data = await statusRes . json ();
2026-05-03 22:10:03 +07:00
const tEnabled = data . tunnel ? . settingsEnabled ?? data . tunnel ? . enabled ?? false ;
2026-04-10 18:16:14 +07:00
const tUrl = data . tunnel ? . tunnelUrl || "" ;
setTunnelUrl ( tUrl );
2026-05-15 18:22:10 +07:00
setTunnelPublicUrl ( data . tunnel ? . publicUrl || "" );
2026-05-03 22:10:03 +07:00
setTunnelEnabled ( tEnabled );
2026-05-21 11:28:49 +07:00
updateReachable ( null , tunnelClientReachableRef , tunnelMissRef , setTunnelReachable , tunnelEverReachableRef , setTunnelEverReachable );
2026-05-03 22:10:03 +07:00
const tsEn = data . tailscale ? . settingsEnabled ?? data . tailscale ? . enabled ?? false ;
2026-04-10 18:16:14 +07:00
const tsUrlVal = data . tailscale ? . tunnelUrl || "" ;
setTsUrl ( tsUrlVal );
2026-05-03 22:10:03 +07:00
setTsEnabled ( tsEn );
2026-05-21 11:28:49 +07:00
updateReachable ( null , tsClientReachableRef , tsMissRef , setTsReachable , tsEverReachableRef , setTsEverReachable );
2026-02-21 16:42:46 +07:00
}
} catch ( error ) {
console . log ( "Error loading settings:" , error );
2026-04-10 18:16:14 +07:00
} finally {
setTunnelChecking ( false );
2026-02-21 16:42:46 +07:00
}
};
2026-04-11 14:56:35 +07:00
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 );
}
};
2026-02-21 16:42:46 +07:00
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 {
2026-08-05 16:22:06 +07:00
const fetchKeys = async () => {
const res = await fetch ( "/api/keys" );
if ( ! res . ok ) return [];
const data = await res . json ();
return data . keys || [];
};
let existing = await fetchKeys ();
// Auto-provision a default key for first-time users so the endpoint works out of the box.
if ( existing . length === 0 ) {
try {
const createRes = await fetch ( "/api/keys" , {
method : "POST" ,
headers : { "Content-Type" : "application/json" },
body : JSON . stringify ({ name : "Default Key" }),
});
if ( createRes . ok ) existing = await fetchKeys ();
} catch { /* fall through to empty render */ }
2026-02-21 16:42:46 +07:00
}
2026-08-05 16:22:06 +07:00
setKeys ( existing );
2026-02-21 16:42:46 +07:00
} catch ( error ) {
console . log ( "Error fetching data:" , error );
} finally {
setLoading ( false );
}
};
2026-04-10 18:16:14 +07:00
// u2500u2500u2500 Cloudflare Tunnel handlers
2026-05-21 11:28:49 +07:00
// Ping tunnel health until reachable. Race multiple URLs (shortlink + direct) — 1 OK is enough.
const pingTunnelHealth = async (... urls ) => {
2026-04-10 18:16:14 +07:00
setTunnelLoading ( true );
setTunnelProgress ( "Waiting for tunnel ready..." );
2026-05-21 11:28:49 +07:00
const targets = urls . filter ( Boolean ). map (( u ) => ` ${ u } /api/health` );
2026-04-10 18:16:14 +07:00
const start = Date . now ();
while ( Date . now () - start < TUNNEL_PING_MAX_MS ) {
await new Promise (( r ) => setTimeout ( r , TUNNEL_PING_INTERVAL_MS ));
2026-05-21 11:28:49 +07:00
const ok = await Promise . any ( targets . map ( async ( h ) => {
2026-05-21 14:30:59 +07:00
const p = await fetch ( h , { mode : "cors" , cache : "no-store" });
if ( p . ok ) return true ;
2026-05-21 11:28:49 +07:00
throw new Error ( "not ready" );
})). catch (() => false );
if ( ok ) {
setTunnelEnabled ( true );
setTunnelLoading ( false );
setTunnelProgress ( "" );
return true ;
}
2026-04-10 18:16:14 +07:00
// 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 ;
};
2026-02-21 16:42:46 +07:00
const handleEnableTunnel = async () => {
2026-04-10 18:16:14 +07:00
setShowEnableTunnelModal ( false );
2026-02-21 16:42:46 +07:00
setTunnelLoading ( true );
setTunnelStatus ( null );
2026-04-10 18:16:14 +07:00
setTunnelProgress ( "Creating tunnel..." );
2026-04-16 10:58:35 +07:00
// Poll download progress while enable request is pending
let polling = true ;
const pollProgress = async () => {
while ( polling ) {
try {
const r = await fetch ( "/api/tunnel/status" );
if ( r . ok ) {
const s = await r . json ();
if ( s . download ? . downloading ) {
setTunnelProgress ( `Downloading cloudflared... ${ s . download . progress } %` );
} else if ( polling ) {
setTunnelProgress ( "Creating tunnel..." );
}
}
} catch { /* ignore */ }
await new Promise (( r ) => setTimeout ( r , 1000 ));
}
};
pollProgress ();
2026-02-21 16:42:46 +07:00
try {
2026-04-10 18:16:14 +07:00
const res = await fetch ( "/api/tunnel/enable" , { method : "POST" });
2026-04-16 10:58:35 +07:00
polling = false ;
2026-02-21 16:42:46 +07:00
const data = await res . json ();
2026-04-10 18:16:14 +07:00
if ( ! res . ok ) {
2026-02-21 16:42:46 +07:00
setTunnelStatus ({ type : "error" , message : data . error || "Failed to enable tunnel" });
2026-04-10 18:16:14 +07:00
return ;
2026-02-21 16:42:46 +07:00
}
2026-04-10 18:16:14 +07:00
2026-05-15 18:22:10 +07:00
const url = data . tunnelUrl ;
2026-04-10 18:16:14 +07:00
if ( ! url ) {
setTunnelStatus ({ type : "error" , message : "No tunnel URL returned" });
return ;
}
2026-05-15 18:22:10 +07:00
setTunnelUrl ( url );
2026-04-10 18:16:14 +07:00
setTunnelPublicUrl ( data . publicUrl || "" );
2026-05-21 11:28:49 +07:00
await pingTunnelHealth ( data . publicUrl , url );
2026-02-21 16:42:46 +07:00
} catch ( error ) {
2026-04-10 18:16:14 +07:00
setTunnelStatus ({ type : "error" , message : error . message });
2026-02-21 16:42:46 +07:00
} finally {
2026-04-16 10:58:35 +07:00
polling = false ;
2026-02-21 16:42:46 +07:00
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 ( "" );
2026-04-10 18:16:14 +07:00
setShowDisableTunnelModal ( false );
2026-02-21 16:42:46 +07:00
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 );
}
};
2026-02-09 11:27:41 +07:00
2026-04-10 18:16:14 +07:00
// 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 );
2026-05-10 21:54:54 +07:00
setShowTsModal ( false );
handleConnectTailscale ();
2026-04-10 18:16:14 +07:00
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 ;
};
2026-05-10 08:44:14 +07:00
// Show inline login button instead of auto-opening popup (browsers block popups
// opened after async work because the user gesture is lost).
const requestUserAuth = ( url , label ) => {
setTsAuthUrl ( url );
setTsAuthLabel ( label );
};
const clearUserAuth = () => {
setTsAuthUrl ( "" );
setTsAuthLabel ( "" );
2026-05-09 17:48:20 +07:00
};
const handleConnectTailscale = async () => {
2026-04-10 18:16:14 +07:00
setShowTsModal ( false );
setTsConnecting ( true );
setTsLoading ( true );
setTsStatus ( null );
setTsProgress ( "Connecting..." );
2026-05-10 08:44:14 +07:00
clearUserAuth ();
2026-04-10 18:16:14 +07:00
try {
const res = await fetch ( "/api/tunnel/tailscale-enable" , { method : "POST" });
const data = await res . json ();
if ( res . ok && data . success ) {
setTsUrl ( data . tunnelUrl || "" );
const reachable = await pingTsHealth ( data . tunnelUrl );
2026-05-09 17:48:20 +07:00
setTsEnabled ( true );
setTsStatus ( reachable ? null : { type : "warning" , message : "Connected but not reachable yet." });
2026-04-10 18:16:14 +07:00
return ;
}
if ( data . needsLogin && data . authUrl ) {
2026-05-10 08:44:14 +07:00
requestUserAuth ( data . authUrl , "Open Login Page" );
setTsProgress ( "Login required — click \"Open Login Page\" to continue" );
2026-04-10 18:16:14 +07:00
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 ) {
2026-05-10 08:44:14 +07:00
clearUserAuth ();
2026-04-10 18:16:14 +07:00
setTsProgress ( "Starting funnel..." );
const res2 = await fetch ( "/api/tunnel/tailscale-enable" , { method : "POST" });
const data2 = await res2 . json ();
if ( res2 . ok && data2 . success ) {
setTsUrl ( data2 . tunnelUrl || "" );
const ok2 = await pingTsHealth ( data2 . tunnelUrl );
2026-05-09 17:48:20 +07:00
setTsEnabled ( true );
setTsStatus ( ok2 ? null : { type : "warning" , message : "Connected but not reachable yet." });
2026-04-10 18:16:14 +07:00
} else if ( data2 . funnelNotEnabled && data2 . enableUrl ) {
2026-05-09 17:48:20 +07:00
await pollFunnelEnable ( data2 . enableUrl );
2026-04-10 18:16:14 +07:00
} else {
setTsStatus ({ type : "error" , message : data2 . error || "Failed to start funnel" });
}
return ;
}
}
} catch { /* retry */ }
}
2026-05-10 08:44:14 +07:00
clearUserAuth ();
2026-04-10 18:16:14 +07:00
setTsStatus ({ type : "error" , message : "Login timed out. Please try again." });
return ;
}
if ( data . funnelNotEnabled && data . enableUrl ) {
2026-05-09 17:48:20 +07:00
await pollFunnelEnable ( data . enableUrl );
2026-04-10 18:16:14 +07:00
return ;
}
setTsStatus ({ type : "error" , message : data . error || "Failed to connect" });
} catch ( error ) {
setTsStatus ({ type : "error" , message : error . message });
} finally {
setTsLoading ( false );
setTsConnecting ( false );
setTsProgress ( "" );
2026-05-10 08:44:14 +07:00
clearUserAuth ();
2026-04-10 18:16:14 +07:00
}
};
2026-05-09 17:48:20 +07:00
const pollFunnelEnable = async ( enableUrl ) => {
2026-05-10 08:44:14 +07:00
requestUserAuth ( enableUrl , "Open Funnel Settings" );
setTsProgress ( "Click \"Open Funnel Settings\" to enable Funnel..." );
2026-04-10 18:16:14 +07:00
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 ) {
2026-05-10 08:44:14 +07:00
clearUserAuth ();
2026-04-10 18:16:14 +07:00
setTsUrl ( data . tunnelUrl || "" );
const ok3 = await pingTsHealth ( data . tunnelUrl );
2026-05-09 17:48:20 +07:00
setTsEnabled ( true );
setTsStatus ( ok3 ? null : { type : "warning" , message : "Connected but not reachable yet." });
2026-04-10 18:16:14 +07:00
return ;
}
if ( data . funnelNotEnabled ) continue ;
if ( data . error ) {
2026-05-10 08:44:14 +07:00
clearUserAuth ();
2026-04-10 18:16:14 +07:00
setTsStatus ({ type : "error" , message : data . error });
return ;
}
} catch { /* retry */ }
}
2026-05-10 08:44:14 +07:00
clearUserAuth ();
2026-04-10 18:16:14 +07:00
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 ([]);
2026-05-10 08:44:14 +07:00
const data = await checkTailscaleInstalled ();
2026-05-10 21:54:54 +07:00
if ( data ? . installed && data ? . hasCachedPassword ) {
2026-05-10 08:44:14 +07:00
handleConnectTailscale ();
} else {
setShowTsModal ( true );
}
2026-04-10 18:16:14 +07:00
};
2026-01-05 09:58:59 +07:00
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 ) => {
2026-05-13 15:32:17 +07:00
setConfirmState ({
title : "Delete API Key" ,
message : "Delete this API key?" ,
onConfirm : async () => {
setConfirmState ( null );
try {
const res = await fetch ( `/api/keys/ ${ id } ` , { method : "DELETE" });
if ( res . ok ) {
setKeys ( keys . filter (( k ) => k . id !== id ));
setVisibleKeys ( prev => {
const next = new Set ( prev );
next . delete ( id );
return next ;
});
}
} catch ( error ) {
console . log ( "Error deleting key:" , error );
}
2026-01-05 09:58:59 +07:00
}
2026-05-13 15:32:17 +07:00
});
2026-01-05 09:58:59 +07:00
};
2026-02-20 15:07:12 +07:00
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 );
}
};
2026-02-28 11:07:51 +07:00
const maskKey = ( fullKey ) => {
2026-06-17 11:03:24 +07:00
if ( ! fullKey || fullKey . length <= 10 ) return fullKey || "" ;
return fullKey . slice ( 0 , 6 ) + "•" . repeat ( fullKey . length - 10 ) + fullKey . slice ( - 4 );
2026-02-28 11:07:51 +07:00
};
const toggleKeyVisibility = ( keyId ) => {
setVisibleKeys ( prev => {
const next = new Set ( prev );
if ( next . has ( keyId )) next . delete ( keyId );
else next . add ( keyId );
return next ;
});
};
2026-01-20 13:16:34 +07:00
const [ baseUrl , setBaseUrl ] = useState ( "/v1" );
2026-01-05 09:58:59 +07:00
2026-01-20 13:16:34 +07:00
// Hydration fix: Only access window on client side
useEffect (() => {
if ( typeof window !== "undefined" ) {
setBaseUrl ( ` ${ window . location . origin } /v1` );
}
}, []);
2026-01-05 09:58:59 +07:00
if ( loading ) {
return (
< div className = "flex flex-col gap-8" >
< CardSkeleton />
< CardSkeleton />
< /div>
);
}
2026-04-10 18:16:14 +07:00
const currentEndpoint = baseUrl ;
2026-01-05 09:58:59 +07:00
return (
< div className = "flex flex-col gap-8" >
{ /* Endpoint Card */ }
2026-02-09 11:27:41 +07:00
< Card >
2026-05-04 11:29:02 +07:00
< h2 className = "text-lg font-semibold mb-4 flex items-center gap-2" >
< span className = "material-symbols-outlined text-primary" > api < /span>
API Endpoint
< /h2>
2026-04-10 18:16:14 +07:00
{ /* Endpoint rows */ }
< div className = "flex flex-col gap-2" >
{ /* Local */ }
< EndpointRow
label = "Local"
url = { currentEndpoint }
copyId = "local_url"
copied = { copied }
onCopy = { copy }
/>
{ /* Cloudflare Tunnel */ }
2026-01-05 09:58:59 +07:00
< div className = "flex items-center gap-2" >
2026-05-03 18:00:35 +07:00
< span className = { `text-xs font-mono px-1.5 py-0.5 rounded shrink-0 min-w-[88px] text-center ${
tunnelEnabled ? "bg-primary/10 text-primary" : "bg-surface-2 text-text-muted"
2026-04-10 18:16:14 +07:00
} ` } > Tunnel < /span>
2026-05-09 17:48:20 +07:00
{ tunnelEnabled && ! tunnelLoading && tunnelReachable ? (
2026-04-10 18:16:14 +07:00
<>
< Input value = { ` ${ tunnelPublicUrl || tunnelUrl } /v1` } readOnly className = "flex-1 font-mono text-sm" />
< button
onClick = {() => copy ( ` ${ tunnelPublicUrl || tunnelUrl } /v1` , "tunnel_url" )}
className = "p-2 hover:bg-black/5 dark:hover:bg-white/5 rounded text-text-muted hover:text-primary transition-colors shrink-0"
>
< span className = "material-symbols-outlined text-[18px]" > { copied === "tunnel_url" ? "check" : "content_copy" } < /span>
< /button>
< button
onClick = {() => setShowDisableTunnelModal ( true )}
className = "p-2 hover:bg-red-500/10 rounded text-red-500 transition-colors shrink-0"
title = "Disable Tunnel"
>
< span className = "material-symbols-outlined text-[18px]" > power_settings_new < /span>
< /button>
< />
2026-05-09 17:48:20 +07:00
) : tunnelEnabled && ! tunnelLoading && ! tunnelReachable ? (
<>
< div className = "flex-1 flex items-center gap-2 px-3 py-1.5 rounded border border-amber-300 dark:border-amber-800 bg-amber-500/5 text-sm text-amber-600 dark:text-amber-400" >
< span className = "material-symbols-outlined animate-spin text-sm" > progress_activity < /span>
{ tunnelEverReachable ? "Tunnel reconnecting..." : "Tunnel checking..." }
< /div>
< button
onClick = {() => setShowDisableTunnelModal ( true )}
className = "p-2 hover:bg-red-500/10 rounded text-red-500 transition-colors shrink-0"
title = "Disable Tunnel"
>
< span className = "material-symbols-outlined text-[18px]" > power_settings_new < /span>
< /button>
< />
2026-04-10 18:16:14 +07:00
) : tunnelLoading ? (
2026-04-13 15:52:01 +07:00
<>
< div className = "flex-1 flex items-center gap-2 px-3 py-1.5 rounded border border-border bg-input text-sm text-text-muted" >
< span className = "material-symbols-outlined animate-spin text-sm" > progress_activity < /span>
{ tunnelProgress || "Creating tunnel..." }
< /div>
< button
2026-04-13 16:49:34 +07:00
onClick = {() => { setTunnelLoading ( false ); setTunnelProgress ( "" ); }}
2026-04-13 15:52:01 +07:00
className = "p-2 hover:bg-red-500/10 rounded text-red-500 transition-colors shrink-0"
2026-04-13 16:49:34 +07:00
title = "Stop"
2026-04-13 15:52:01 +07:00
>
< span className = "material-symbols-outlined text-[18px]" > power_settings_new < /span>
< /button>
< />
2026-04-10 18:16:14 +07:00
) : tunnelStatus ? . type === "error" ? (
<>
< div className = "flex-1 flex items-center gap-2 px-3 py-1.5 rounded border border-red-300 dark:border-red-800 bg-red-500/5 text-sm text-red-600 dark:text-red-400" >
< span className = "material-symbols-outlined text-sm" > error < /span>
{ tunnelStatus . message }
< /div>
< Button size = "sm" icon = "cloud_upload" onClick = {() => setShowEnableTunnelModal ( true )} > Enable < /Button>
< />
) : tunnelChecking ? (
2026-04-13 15:52:01 +07:00
<>
< div className = "flex-1 flex items-center gap-2 px-3 py-1.5 rounded border border-border bg-input text-sm text-text-muted" >
< span className = "material-symbols-outlined animate-spin text-sm" > progress_activity < /span>
Checking ...
< /div>
< button
2026-04-13 16:49:34 +07:00
onClick = {() => setTunnelChecking ( false )}
2026-04-13 15:52:01 +07:00
className = "p-2 hover:bg-red-500/10 rounded text-red-500 transition-colors shrink-0"
2026-04-13 16:49:34 +07:00
title = "Stop"
2026-04-13 15:52:01 +07:00
>
< span className = "material-symbols-outlined text-[18px]" > power_settings_new < /span>
< /button>
< />
2026-01-05 09:58:59 +07:00
) : (
< Button
2026-04-10 18:16:14 +07:00
size = "sm"
2026-01-05 09:58:59 +07:00
icon = "cloud_upload"
2026-04-04 23:24:24 +07:00
onClick = {() => {
2026-05-20 10:05:22 +07:00
if ( isLoginUnsafe ) {
setTunnelStatus ({ type : "error" , message : `Security required: ${ unsafeReason } ` });
return ;
}
2026-04-04 23:24:24 +07:00
if ( ! requireApiKey ) {
setTunnelStatus ({ type : "error" , message : "Security required: Enable \"Require API key\" before activating the tunnel." });
return ;
}
2026-04-10 18:16:14 +07:00
setShowEnableTunnelModal ( true );
2026-04-04 23:24:24 +07:00
}}
2026-01-05 09:58:59 +07:00
>
2026-04-10 18:16:14 +07:00
Enable
< /Button>
)}
< /div>
{ /* Tailscale */ }
< div className = "flex items-center gap-2" >
2026-05-03 18:00:35 +07:00
< span className = { `text-xs font-mono px-1.5 py-0.5 rounded shrink-0 min-w-[88px] text-center ${
tsEnabled ? "bg-primary/10 text-primary" : "bg-surface-2 text-text-muted"
2026-04-10 18:16:14 +07:00
} ` } > Tailscale < /span>
2026-05-09 17:48:20 +07:00
{ tsEnabled && ! tsLoading && tsReachable ? (
2026-04-10 18:16:14 +07:00
<>
< Input value = { ` ${ tsUrl } /v1` } readOnly className = "flex-1 font-mono text-sm" />
< button
onClick = {() => copy ( ` ${ tsUrl } /v1` , "ts_url" )}
className = "p-2 hover:bg-black/5 dark:hover:bg-white/5 rounded text-text-muted hover:text-primary transition-colors shrink-0"
>
< span className = "material-symbols-outlined text-[18px]" > { copied === "ts_url" ? "check" : "content_copy" } < /span>
< /button>
< button
onClick = {() => setShowDisableTsModal ( true )}
className = "p-2 hover:bg-red-500/10 rounded text-red-500 transition-colors shrink-0"
title = "Disable Tailscale"
>
< span className = "material-symbols-outlined text-[18px]" > power_settings_new < /span>
< /button>
< />
2026-05-09 17:48:20 +07:00
) : tsEnabled && ! tsLoading && ! tsReachable ? (
<>
< div className = "flex-1 flex items-center gap-2 px-3 py-1.5 rounded border border-amber-300 dark:border-amber-800 bg-amber-500/5 text-sm text-amber-600 dark:text-amber-400" >
< span className = "material-symbols-outlined animate-spin text-sm" > progress_activity < /span>
{ tsEverReachable ? "Tailscale reconnecting..." : "Tailscale checking..." }
< /div>
< button
onClick = {() => setShowDisableTsModal ( true )}
className = "p-2 hover:bg-red-500/10 rounded text-red-500 transition-colors shrink-0"
title = "Disable Tailscale"
>
< span className = "material-symbols-outlined text-[18px]" > power_settings_new < /span>
< /button>
< />
2026-04-10 18:16:14 +07:00
) : ( tsLoading || tsConnecting ) ? (
2026-04-13 15:52:01 +07:00
<>
< div className = "flex-1 flex items-center gap-2 px-3 py-1.5 rounded border border-border bg-input text-sm text-text-muted" >
< span className = "material-symbols-outlined animate-spin text-sm" > progress_activity < /span>
{ tsProgress || "Connecting..." }
< /div>
2026-05-10 08:44:14 +07:00
{ tsAuthUrl && (
< Button
size = "sm"
icon = "open_in_new"
onClick = {() => window . open ( tsAuthUrl , "tailscale_auth" , "width=600,height=700,noopener,noreferrer" )}
>
{ tsAuthLabel || "Open" }
< /Button>
)}
2026-04-13 15:52:01 +07:00
< button
2026-05-10 08:44:14 +07:00
onClick = {() => { setTsLoading ( false ); setTsConnecting ( false ); setTsProgress ( "" ); clearUserAuth (); }}
2026-04-13 15:52:01 +07:00
className = "p-2 hover:bg-red-500/10 rounded text-red-500 transition-colors shrink-0"
2026-04-13 16:49:34 +07:00
title = "Stop"
2026-04-13 15:52:01 +07:00
>
< span className = "material-symbols-outlined text-[18px]" > power_settings_new < /span>
< /button>
< />
2026-04-10 18:16:14 +07:00
) : tsStatus ? . type === "error" ? (
<>
< div className = "flex-1 flex items-center gap-2 px-3 py-1.5 rounded border border-red-300 dark:border-red-800 bg-red-500/5 text-sm text-red-600 dark:text-red-400" >
< span className = "material-symbols-outlined text-sm" > error < /span>
{ tsStatus . message }
< /div>
< Button size = "sm" icon = "vpn_lock" onClick = { handleOpenTsModal } > Enable < /Button>
< />
) : (
< Button
size = "sm"
icon = "vpn_lock"
2026-05-20 10:05:22 +07:00
onClick = {() => {
if ( isLoginUnsafe ) {
setTsStatus ({ type : "error" , message : `Security required: ${ unsafeReason } ` });
return ;
}
handleOpenTsModal ();
}}
2026-04-10 18:16:14 +07:00
className = "bg-linear-to-r from-indigo-500 to-purple-500 hover:from-indigo-600 hover:to-purple-600 text-white!"
>
Enable
2026-01-05 09:58:59 +07:00
< /Button>
)}
< /div>
< /div>
2026-04-11 14:56:35 +07:00
2026-05-20 10:05:22 +07:00
{ /* Pre-enable security gate banner */ }
{ isLoginUnsafe && ! tunnelEnabled && ! tsEnabled && (
< div className = "mt-4" >
< SecurityWarning
message = { unsafeReason }
action = {{ label : "Open settings" , href : "/dashboard/profile" }}
/>
< /div>
)}
2026-04-11 14:56:35 +07:00
{ /* Security warnings when tunnel or tailscale is active */ }
{( tunnelEnabled || tsEnabled ) && (
< div className = "mt-4 flex flex-col gap-2" >
{ ! requireApiKey && (
< SecurityWarning
message = "Require API key is disabled — your endpoint is publicly accessible without authentication."
action = {{ label : "Enable" , href : "#require-api-key" }}
/>
)}
{( ! requireLogin || ! hasPassword ) && (
< SecurityWarning
message = {
! requireLogin
? "Require login is disabled — anyone can access your dashboard via tunnel."
: "Dashboard uses the default password — change it in Profile settings."
}
action = {{
label : ! requireLogin ? "Enable" : "Change password" ,
href : "/dashboard/profile" ,
}}
/>
)}
< /div>
)}
{ /* Tunnel dashboard access option */ }
{( tunnelEnabled || tsEnabled ) && (
< div className = "mt-4 pt-4 border-t border-border flex items-center gap-3" >
< Toggle
checked = { tunnelDashboardAccess }
onChange = {() => handleTunnelDashboardAccess ( ! tunnelDashboardAccess )}
/>
< div className = "flex items-center gap-1.5" >
< p className = "font-medium text-sm" > Allow dashboard access via tunnel < /p>
2026-04-13 17:38:09 +07:00
< Tooltip text = "When enabled, the dashboard can be accessed through your tunnel or Tailscale URL (login still required). When disabled, dashboard access via tunnel/Tailscale is completely blocked." />
2026-04-11 14:56:35 +07:00
< /div>
< /div>
)}
2026-01-05 09:58:59 +07:00
< /Card>
2026-06-20 11:19:03 +07:00
{ /* API Keys */ }
< Card id = "require-api-key" >
< div className = "flex items-center justify-between mb-4" >
< h2 className = "text-lg font-semibold flex items-center gap-2" >
< span className = "material-symbols-outlined text-primary" > vpn_key < /span>
API Keys
< /h2>
< Button icon = "add" onClick = {() => setShowAddModal ( true )} >
Create Key
< /Button>
< /div>
< div className = "flex items-center justify-between pb-4 mb-4 border-b border-border" >
< div >
< p className = "font-medium" > Require API key < /p>
< p className = "text-sm text-text-muted" >
Requests without a valid key will be rejected
< /p>
< /div>
< Toggle
checked = { requireApiKey }
onChange = {() => handleRequireApiKey ( ! requireApiKey )}
/>
< /div>
{ isRemoteHost && ! requireApiKey && (
< div className = "mb-4 -mt-2" >
< SecurityWarning message = "Endpoint is exposed without an API key." />
< /div>
)}
{ keys . length === 0 ? (
< div className = "text-center py-12" >
< div className = "inline-flex items-center justify-center w-16 h-16 rounded-full bg-primary/10 text-primary mb-4" >
< span className = "material-symbols-outlined text-[32px]" > vpn_key < /span>
< /div>
< p className = "text-text-main font-medium mb-1" > No API keys yet < /p>
< p className = "text-sm text-text-muted mb-4" > Create your first API key to get started < /p>
< Button icon = "add" onClick = {() => setShowAddModal ( true )} >
Create Key
< /Button>
< /div>
) : (
< div className = "flex flex-col" >
{ keys . map (( key ) => (
< div
key = { key . id }
className = { `group flex items-center justify-between py-3 border-b border-black/[0.03] dark:border-white/[0.03] last:border-b-0 ${ key . isActive === false ? "opacity-60" : "" } ` }
>
< div className = "flex-1 min-w-0" >
< p className = "text-sm font-medium" > { key . name } < /p>
< div className = "flex items-center gap-2 mt-1" >
< code className = "text-xs text-text-muted font-mono" >
{ visibleKeys . has ( key . id ) ? key . key : maskKey ( key . key )}
< /code>
< button
onClick = {() => toggleKeyVisibility ( key . id )}
2026-08-05 16:22:06 +07:00
className = "p-1 hover:bg-black/5 dark:hover:bg-white/5 rounded text-text-muted hover:text-primary transition-all"
2026-06-20 11:19:03 +07:00
title = { visibleKeys . has ( key . id ) ? "Hide key" : "Show key" }
>
< span className = "material-symbols-outlined text-[14px]" >
{ visibleKeys . has ( key . id ) ? "visibility_off" : "visibility" }
< /span>
< /button>
< button
onClick = {() => copy ( key . key , key . id )}
2026-08-05 16:22:06 +07:00
className = "p-1 hover:bg-black/5 dark:hover:bg-white/5 rounded text-text-muted hover:text-primary transition-all"
2026-06-20 11:19:03 +07:00
>
< span className = "material-symbols-outlined text-[14px]" >
{ copied === key . id ? "check" : "content_copy" }
< /span>
< /button>
< /div>
< p className = "text-xs text-text-muted mt-1" >
Created { new Date ( key . createdAt ). toLocaleDateString ()}
< /p>
{ key . isActive === false && (
< p className = "text-xs text-orange-500 mt-1" > Paused < /p>
)}
< /div>
< div className = "flex items-center gap-2" >
< Toggle
size = "sm"
checked = { key . isActive ?? true }
onChange = {( checked ) => {
if ( key . isActive && ! checked ) {
setConfirmState ({
title : "Pause API Key" ,
message : `Pause API key " ${ key . name } "?\n\nThis key will stop working immediately but can be resumed later.` ,
onConfirm : async () => {
setConfirmState ( null );
handleToggleKey ( key . id , checked );
}
});
} else {
handleToggleKey ( key . id , checked );
}
}}
title = { key . isActive ? "Pause key" : "Resume key" }
/>
< button
onClick = {() => handleDeleteKey ( key . id )}
className = "p-2 hover:bg-red-500/10 rounded text-red-500 opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-all"
>
< span className = "material-symbols-outlined text-[18px]" > delete < /span>
< /button>
< /div>
< /div>
))}
< /div>
)}
< /Card>
2026-01-05 09:58:59 +07:00
{ /* Add Key Modal */ }
< Modal
isOpen = { showAddModal }
title = "Create API Key"
onClose = {() => {
setShowAddModal ( false );
setNewKeyName ( "" );
}}
>
< div className = "flex flex-col gap-4" >
< Input
label = "Key Name"
value = { newKeyName }
onChange = {( e ) => setNewKeyName ( e . target . value )}
placeholder = "Production Key"
/>
< div className = "flex gap-2" >
< Button onClick = { handleCreateKey } fullWidth disabled = { ! newKeyName . trim ()} >
Create
< /Button>
< Button
onClick = {() => {
setShowAddModal ( false );
setNewKeyName ( "" );
}}
variant = "ghost"
fullWidth
>
Cancel
< /Button>
< /div>
< /div>
< /Modal>
{ /* Created Key Modal */ }
< Modal
isOpen = { !! createdKey }
title = "API Key Created"
onClose = {() => setCreatedKey ( null )}
>
< div className = "flex flex-col gap-4" >
< div className = "bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4" >
< p className = "text-sm text-yellow-800 dark:text-yellow-200 mb-2 font-medium" >
Save this key now !
< /p>
< p className = "text-sm text-yellow-700 dark:text-yellow-300" >
This is the only time you will see this key . Store it securely .
< /p>
< /div>
< div className = "flex gap-2" >
< Input
value = { createdKey || "" }
readOnly
className = "flex-1 font-mono text-sm"
/>
< Button
variant = "secondary"
icon = { copied === "created_key" ? "check" : "content_copy" }
onClick = {() => copy ( createdKey , "created_key" )}
>
{ copied === "created_key" ? "Copied!" : "Copy" }
< /Button>
< /div>
< Button onClick = {() => setCreatedKey ( null )} fullWidth >
Done
< /Button>
< /div>
< /Modal>
2026-02-21 16:42:46 +07:00
{ /* Enable Tunnel Modal */ }
< Modal
2026-04-10 18:16:14 +07:00
isOpen = { showEnableTunnelModal }
2026-02-21 16:42:46 +07:00
title = "Enable Tunnel"
2026-04-10 18:16:14 +07:00
onClose = {() => setShowEnableTunnelModal ( false )}
2026-02-21 16:42:46 +07:00
>
< div className = "flex flex-col gap-4" >
2026-05-03 18:00:35 +07:00
< div className = "bg-surface-2 border border-border-subtle rounded-lg p-4" >
2026-02-21 16:42:46 +07:00
< div className = "flex items-start gap-3" >
2026-05-03 18:00:35 +07:00
< span className = "material-symbols-outlined text-primary" > cloud_upload < /span>
2026-02-21 16:42:46 +07:00
< div >
2026-05-03 18:00:35 +07:00
< p className = "text-sm text-text-main font-medium mb-1" >
2026-02-21 16:42:46 +07:00
Cloudflare Tunnel
< /p>
2026-05-03 18:00:35 +07:00
< p className = "text-sm text-text-muted" >
2026-02-21 16:42:46 +07:00
Expose your local 9 Router 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 .
< /p>
< /div>
< /div>
< /div>
< div className = "grid grid-cols-2 gap-3" >
{ TUNNEL_BENEFITS . map (( benefit ) => (
< div key = { benefit . title } className = "flex flex-col items-center text-center p-3 rounded-lg bg-sidebar/50" >
< span className = "material-symbols-outlined text-xl text-primary mb-1" > { benefit . icon } < /span>
< p className = "text-xs font-semibold" > { benefit . title } < /p>
< p className = "text-xs text-text-muted" > { benefit . desc } < /p>
< /div>
))}
< /div>
< p className = "text-xs text-text-muted" >
Requires outbound port 7844 ( TCP / UDP ). Connection may take 10 - 30 s .
< /p>
< div className = "flex gap-2" >
2026-05-03 18:00:35 +07:00
< Button onClick = { handleEnableTunnel } fullWidth >
2026-02-21 16:42:46 +07:00
Start Tunnel
< /Button>
2026-04-10 18:16:14 +07:00
< Button onClick = {() => setShowEnableTunnelModal ( false )} variant = "ghost" fullWidth > Cancel < /Button>
2026-02-21 16:42:46 +07:00
< /div>
< /div>
< /Modal>
2026-04-10 18:16:14 +07:00
{ /* Disable Cloudflare Tunnel Modal */ }
2026-01-05 09:58:59 +07:00
< Modal
2026-04-10 18:16:14 +07:00
isOpen = { showDisableTunnelModal }
2026-02-21 16:42:46 +07:00
title = "Disable Tunnel"
2026-04-10 18:16:14 +07:00
onClose = {() => ! tunnelLoading && setShowDisableTunnelModal ( false )}
2026-01-05 09:58:59 +07:00
>
< div className = "flex flex-col gap-4" >
2026-04-10 18:16:14 +07:00
< p className = "text-sm text-text-muted" > The Cloudflare tunnel will be disconnected . Remote access via tunnel URL will stop working . < /p>
< div className = "flex gap-2" >
2026-05-03 18:00:35 +07:00
< Button onClick = { handleDisableTunnel } fullWidth disabled = { tunnelLoading } variant = "danger" >
2026-04-10 18:16:14 +07:00
{ tunnelLoading ? "Disabling..." : "Disable" }
< /Button>
< Button onClick = {() => setShowDisableTunnelModal ( false )} variant = "ghost" fullWidth disabled = { tunnelLoading } > Cancel < /Button>
< /div>
< /div>
< /Modal>
{ /* Tailscale Modal */ }
< Modal
isOpen = { showTsModal }
title = "Tailscale Funnel"
onClose = {() => { if ( ! tsInstalling ) { setShowTsModal ( false ); setTsSudoPassword ( "" ); setTsStatus ( null ); } }}
>
< div className = "flex flex-col gap-4" >
{ /* Checking state */ }
{ tsInstalled === null && (
< p className = "text-sm text-text-muted flex items-center gap-2" >
< span className = "material-symbols-outlined animate-spin text-sm" > progress_activity < /span>
Checking ...
< /p>
)}
{ /* Not installed */ }
{ tsInstalled === false && ! tsInstalling && (
< div className = "flex flex-col gap-3" >
< p className = "text-sm text-text-muted" > Tailscale is not installed . Install it to enable Funnel . < /p>
< div className = "flex gap-2" >
2026-05-03 18:00:35 +07:00
< Button onClick = { handleInstallTailscale } fullWidth >
2026-04-10 18:16:14 +07:00
Install Tailscale
< /Button>
< Button onClick = {() => setShowTsModal ( false )} variant = "ghost" fullWidth > Cancel < /Button>
2026-01-05 09:58:59 +07:00
< /div>
< /div>
2026-04-10 18:16:14 +07:00
)}
2026-01-05 09:58:59 +07:00
2026-04-10 18:16:14 +07:00
{ /* Installing with progress log */ }
{ tsInstalling && (
< div className = "flex flex-col gap-2" >
< div className = "flex items-center gap-2 text-sm text-text-muted" >
< span className = "material-symbols-outlined animate-spin text-sm" > progress_activity < /span>
Installing Tailscale ...
< /div>
{ tsInstallLog . length > 0 && (
< div ref = { tsLogRef } className = "bg-black/5 dark:bg-white/5 rounded p-2 max-h-40 overflow-y-auto font-mono text-xs text-text-muted" >
{ tsInstallLog . map (( line , i ) => (
< div key = { i } > { line } < /div>
))}
< /div>
)}
< /div>
)}
2026-01-05 09:58:59 +07:00
2026-04-10 18:16:14 +07:00
{ /* Installed: show Connect button */ }
{ tsInstalled === true && ! tsInstalling && (
< div className = "flex flex-col gap-3" >
< div className = "flex items-center gap-2 text-sm text-green-600 dark:text-green-400" >
< span className = "material-symbols-outlined text-[16px]" > check_circle < /span>
Tailscale installed
< /div>
< div className = "flex gap-2" >
< Button
2026-05-09 17:48:20 +07:00
onClick = {() => handleConnectTailscale ()}
2026-04-10 18:16:14 +07:00
fullWidth
>
Connect
< /Button>
< Button onClick = {() => setShowTsModal ( false )} variant = "ghost" fullWidth > Cancel < /Button>
< /div>
< /div>
)}
{ tsStatus && < StatusAlert status = { tsStatus } /> }
< /div>
< /Modal>
{ /* Disable Tailscale Modal */ }
< Modal
isOpen = { showDisableTsModal }
title = "Disable Tailscale"
onClose = {() => ! tsLoading && setShowDisableTsModal ( false )}
>
< div className = "flex flex-col gap-4" >
< p className = "text-sm text-text-muted" > Tailscale Funnel will be stopped . Remote access via Tailscale URL will stop working . < /p>
2026-01-05 09:58:59 +07:00
< div className = "flex gap-2" >
2026-05-03 18:00:35 +07:00
< Button onClick = { handleDisableTailscale } fullWidth disabled = { tsLoading } variant = "danger" >
2026-04-10 18:16:14 +07:00
{ tsLoading ? "Disabling..." : "Disable" }
2026-01-05 09:58:59 +07:00
< /Button>
2026-04-10 18:16:14 +07:00
< Button onClick = {() => setShowDisableTsModal ( false )} variant = "ghost" fullWidth disabled = { tsLoading } > Cancel < /Button>
2026-01-05 09:58:59 +07:00
< /div>
< /div>
< /Modal>
2026-05-13 15:32:17 +07:00
{ /* Confirm Modal */ }
< ConfirmModal
isOpen = { !! confirmState }
onClose = {() => setConfirmState ( null )}
onConfirm = { confirmState ? . onConfirm }
title = { confirmState ? . title || "Confirm" }
message = { confirmState ? . message }
variant = "danger"
/>
2026-01-05 09:58:59 +07:00
< /div>
);
2026-01-19 02:15:11 -08:00
}
2026-04-11 14:56:35 +07:00
2026-01-19 02:15:11 -08:00
APIPageClient . propTypes = {
2026-01-20 13:16:34 +07:00
machineId : PropTypes . string . isRequired ,
2026-02-08 16:45:31 +07:00
};