"use client" import { useState, useEffect } from "react" import { Button } from "./ui/button" import { Input } from "./ui/input" import { Label } from "./ui/label" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card" import { Checkbox } from "./ui/checkbox" import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from "./ui/dialog" import { ShieldCheck, Globe, ExternalLink, Loader2, CheckCircle, XCircle, Play, Square, RotateCw, Trash2, FileText, ChevronRight, ChevronDown, AlertTriangle, Info, Network, Eye, EyeOff, Settings, Wifi, Key, } from "lucide-react" import { fetchApi } from "../lib/api-config" interface NetworkInfo { interface: string type?: string address?: string ip?: string subnet: string prefixlen?: number recommended?: boolean } interface AppStatus { state: "not_installed" | "running" | "stopped" | "error" health: string uptime_seconds: number last_check: string } interface ConfigSchema { [key: string]: { type: string label: string description: string placeholder?: string default?: any required?: boolean sensitive?: boolean env_var?: string help_url?: string help_text?: string options?: Array<{ value: string; label: string; description?: string }> depends_on?: { field: string; values: string[] } flag?: string warning?: string validation?: { pattern?: string; max_length?: number; message?: string } } } interface WizardStep { id: string title: string description: string fields?: string[] } export function SecureGatewaySetup() { // State const [loading, setLoading] = useState(true) const [runtimeAvailable, setRuntimeAvailable] = useState(false) const [runtimeInfo, setRuntimeInfo] = useState<{ runtime: string; version: string } | null>(null) const [appStatus, setAppStatus] = useState({ state: "not_installed", health: "unknown", uptime_seconds: 0, last_check: "" }) const [configSchema, setConfigSchema] = useState(null) const [wizardSteps, setWizardSteps] = useState([]) const [networks, setNetworks] = useState([]) // Wizard state const [showWizard, setShowWizard] = useState(false) const [currentStep, setCurrentStep] = useState(0) const [config, setConfig] = useState>({}) const [deploying, setDeploying] = useState(false) const [deployProgress, setDeployProgress] = useState("") const [deployPercent, setDeployPercent] = useState(0) const [deployError, setDeployError] = useState("") // Installed state const [actionLoading, setActionLoading] = useState(null) const [showLogs, setShowLogs] = useState(false) const [logs, setLogs] = useState("") const [logsLoading, setLogsLoading] = useState(false) const [showRemoveConfirm, setShowRemoveConfirm] = useState(false) const [showAuthKey, setShowAuthKey] = useState(false) // Post-deploy confirmation const [showPostDeployInfo, setShowPostDeployInfo] = useState(false) const [deployedConfig, setDeployedConfig] = useState>({}) // Host IP for "Host Only" mode const [hostIp, setHostIp] = useState("") // Update Auth Key const [showUpdateAuthKey, setShowUpdateAuthKey] = useState(false) const [newAuthKey, setNewAuthKey] = useState("") const [updateAuthKeyLoading, setUpdateAuthKeyLoading] = useState(false) const [updateAuthKeyError, setUpdateAuthKeyError] = useState("") // Password visibility const [visiblePasswords, setVisiblePasswords] = useState>(new Set()) useEffect(() => { loadInitialData() }, []) const loadInitialData = async () => { setLoading(true) try { // Load runtime info (checks for Proxmox 9.1+ OCI support) const runtimeRes = await fetchApi("/api/oci/runtime") if (runtimeRes.success && runtimeRes.available) { setRuntimeAvailable(true) setRuntimeInfo({ runtime: runtimeRes.runtime, version: runtimeRes.version }) } else { setRuntimeInfo({ runtime: "proxmox-lxc", version: runtimeRes.version || "unknown" }) } // Load app definition const catalogRes = await fetchApi("/api/oci/catalog/secure-gateway") if (catalogRes.success && catalogRes.app) { setConfigSchema(catalogRes.app.config_schema || {}) setWizardSteps(catalogRes.app.ui?.wizard_steps || []) // Set defaults const defaults: Record = {} for (const [key, field] of Object.entries(catalogRes.app.config_schema || {})) { if (field.default !== undefined) { defaults[key] = field.default } } setConfig(defaults) } // Load status await loadStatus() // Load networks const networksRes = await fetchApi("/api/oci/networks") if (networksRes.success) { setNetworks(networksRes.networks || []) const primaryNetwork = networksRes.networks?.find((n: NetworkInfo) => n.recommended) || networksRes.networks?.[0] const hostIpValue = primaryNetwork?.ip || primaryNetwork?.address if (hostIpValue) { const ip = hostIpValue.split("/")[0] setHostIp(ip) } } } catch (err) { console.error("Failed to load data:", err) } finally { setLoading(false) } } const loadStatus = async () => { try { const statusRes = await fetchApi("/api/oci/status/secure-gateway") if (statusRes.success) { setAppStatus(statusRes.status) } } catch (err) { // Not installed is ok } } const handleDeploy = async () => { setDeploying(true) setDeployError("") setDeployProgress("Preparing deployment...") setDeployPercent(5) try { // Prepare config based on access_mode const deployConfig = { ...config } if (config.access_mode === "host_only" && hostIp) { deployConfig.advertise_routes = [`${hostIp}/32`] } else if (config.access_mode === "proxmox_network") { if (!deployConfig.advertise_routes?.length) { const recommendedNetwork = networks.find((n) => n.recommended) || networks[0] if (recommendedNetwork) { deployConfig.advertise_routes = [recommendedNetwork.subnet] } } } setDeployProgress("Creating LXC container...") setDeployPercent(20) // Small delay to show progress await new Promise(resolve => setTimeout(resolve, 500)) setDeployProgress("Downloading Tailscale image...") setDeployPercent(35) await new Promise(resolve => setTimeout(resolve, 300)) setDeployProgress("Configuring container...") setDeployPercent(50) const result = await fetchApi("/api/oci/deploy", { method: "POST", body: JSON.stringify({ app_id: "secure-gateway", config: deployConfig }) }) setDeployProgress("Installing Tailscale...") setDeployPercent(70) await new Promise(resolve => setTimeout(resolve, 300)) setDeployProgress("Connecting to Tailscale network...") setDeployPercent(85) if (!result.success) { setDeployError(result.message || "Failed to deploy gateway") setDeploying(false) setDeployPercent(0) return } setDeployProgress("Gateway deployed successfully!") setDeployPercent(100) // Show post-deploy confirmation const needsApproval = deployConfig.access_mode && deployConfig.access_mode !== "none" if (needsApproval) { const finalConfig = { ...deployConfig } if (deployConfig.access_mode === "host_only" && hostIp) { finalConfig.advertise_routes = [`${hostIp}/32`] } setDeployedConfig(finalConfig) setShowPostDeployInfo(true) } await loadStatus() setTimeout(() => { setShowWizard(false) setDeploying(false) setDeployPercent(0) setCurrentStep(0) }, 1000) } catch (err: any) { setDeployError(err.message || "Failed to deploy gateway") setDeploying(false) setDeployPercent(0) } } const handleUpdateAuthKey = async () => { if (!newAuthKey.trim()) { setUpdateAuthKeyError("Auth Key is required") return } setUpdateAuthKeyLoading(true) setUpdateAuthKeyError("") try { const result = await fetchApi("/api/oci/installed/secure-gateway/update-auth-key", { method: "POST", body: JSON.stringify({ auth_key: newAuthKey.trim() }) }) if (!result.success) { setUpdateAuthKeyError(result.message || "Failed to update auth key") setUpdateAuthKeyLoading(false) return } setShowUpdateAuthKey(false) setNewAuthKey("") await loadStatus() } catch (err: any) { setUpdateAuthKeyError(err.message || "Failed to update auth key") } finally { setUpdateAuthKeyLoading(false) } } const handleAction = async (action: "start" | "stop" | "restart") => { setActionLoading(action) try { await fetchApi(`/api/oci/installed/secure-gateway/${action}`, { method: "POST" }) await loadStatus() } catch (err) { console.error(`Failed to ${action}:`, err) } finally { setActionLoading(null) } } const handleRemove = async () => { setActionLoading("remove") try { await fetchApi("/api/oci/installed/secure-gateway/remove", { method: "DELETE" }) setShowRemoveConfirm(false) await loadStatus() } catch (err) { console.error("Failed to remove:", err) } finally { setActionLoading(null) } } const handleViewLogs = async () => { setShowLogs(true) setLogsLoading(true) try { const result = await fetchApi("/api/oci/installed/secure-gateway/logs") setLogs(result.logs || "No logs available") } catch (err) { setLogs("Failed to load logs") } finally { setLogsLoading(false) } } const formatUptime = (seconds: number) => { if (seconds < 60) return `${seconds}s` if (seconds < 3600) return `${Math.floor(seconds / 60)}m` if (seconds < 86400) return `${Math.floor(seconds / 3600)}h` return `${Math.floor(seconds / 86400)}d` } const togglePasswordVisibility = (fieldName: string) => { setVisiblePasswords(prev => { const next = new Set(prev) if (next.has(fieldName)) { next.delete(fieldName) } else { next.add(fieldName) } return next }) } // Render field based on type const renderField = (fieldName: string, field: ConfigSchema[string]) => { // Check depends_on if (field.depends_on) { const dependsValue = config[field.depends_on.field] if (!field.depends_on.values.includes(dependsValue)) { return null } } switch (field.type) { case "password": const isVisible = visiblePasswords.has(fieldName) return (
setConfig({ ...config, [fieldName]: e.target.value })} placeholder={field.placeholder} className="pr-10 font-mono text-sm" />
{field.description && (

{field.description}

)} {field.help_url && ( {field.help_text || "Learn more"} )}
) case "text": return (
setConfig({ ...config, [fieldName]: e.target.value })} placeholder={field.placeholder} /> {field.description && (

{field.description}

)}
) case "select": const handleSelectChange = (value: string) => { const newConfig = { ...config, [fieldName]: value } if (fieldName === "access_mode" && value === "proxmox_network") { const recommendedNetwork = networks.find((n) => n.recommended) || networks[0] if (recommendedNetwork) { newConfig.advertise_routes = [recommendedNetwork.subnet] } } if (fieldName === "access_mode" && value === "host_only") { newConfig.advertise_routes = [] } if (fieldName === "access_mode" && value === "custom") { newConfig.advertise_routes = [] } setConfig(newConfig) } return (
{field.options?.map((opt) => (
handleSelectChange(opt.value)} className={`p-3 rounded-lg border cursor-pointer transition-colors ${ config[fieldName] === opt.value ? "border-cyan-500 bg-cyan-500/10" : "border-border hover:border-muted-foreground/50" }`} >
{config[fieldName] === opt.value && (
)}

{opt.label}

{opt.description && (

{opt.description}

)} {fieldName === "access_mode" && opt.value === "proxmox_network" && config[fieldName] === "proxmox_network" && (

{networks.find((n) => n.recommended)?.subnet || networks[0]?.subnet || "No network detected"}

)}
))}
) case "boolean": return (
setConfig({ ...config, [fieldName]: !config[fieldName] })} className={`p-3 rounded-lg border cursor-pointer transition-colors ${ config[fieldName] ? "border-cyan-500 bg-cyan-500/10" : "border-border hover:border-muted-foreground/50" }`} >
setConfig({ ...config, [fieldName]: checked })} className="mt-0.5" />

{field.label}

{field.description && (

{field.description}

)} {field.warning && config[fieldName] && (

{field.warning}

)}
) case "networks": return (

{field.description}

{networks.map((net) => { const isSelected = (config[fieldName] || []).includes(net.subnet) return (
{ const current = config[fieldName] || [] const updated = isSelected ? current.filter((s: string) => s !== net.subnet) : [...current, net.subnet] setConfig({ ...config, [fieldName]: updated }) }} className={`p-3 rounded-lg border cursor-pointer transition-colors ${ isSelected ? "border-cyan-500 bg-cyan-500/10" : "border-border hover:border-muted-foreground/50" }`} >

{net.subnet}

{net.interface} {net.type ? `(${net.type})` : ""}

) })}
) default: return null } } const renderWizardContent = () => { if (!wizardSteps.length || !configSchema) return null const step = wizardSteps[currentStep] if (!step) return null // Review step if (step.id === "review") { return (

{step.title}

{step.description}

Gateway Name {config.hostname || "proxmox-gateway"}
Access Scope {config.access_mode === "host_only" ? "Proxmox Only" : config.access_mode === "proxmox_network" ? "Full Local Network" : config.access_mode === "custom" ? "Custom Subnets" : config.access_mode}
{config.advertise_routes?.length > 0 && (
Networks {config.advertise_routes.join(", ")}
)} {config.exit_node && (
Exit Node Enabled
)}
{/* Approval notice */} {(config.access_mode && config.access_mode !== "none") && !deploying && (

Important: After deployment, you must approve the subnet route in Tailscale Admin for remote access to work. {config.exit_node && You{"'"}ll also need to approve the exit node.}

We{"'"}ll show you exactly what to do after the gateway is deployed.

)} {deploying && (
{deployProgress}
{/* Progress bar */}

{deployPercent}% complete

)} {deployError && (

{deployError}

)}
) } // Regular step with fields return (

{step.title}

{step.description}

{step.fields?.map((fieldName) => { const field = configSchema[fieldName] if (!field) return null return renderField(fieldName, field) })}
) } // Loading state if (loading) { return (
Loading Secure Gateway...
) } // Installed state if (appStatus.state !== "not_installed") { const isRunning = appStatus.state === "running" return ( <>

Secure Gateway

Tailscale VPN Gateway

{isRunning ? : } {isRunning ? "Connected" : "Disconnected"}
{isRunning && (

Uptime: {formatUptime(appStatus.uptime_seconds)}

)} {/* Action buttons */}
{isRunning ? ( ) : ( )}
{/* Update Auth Key button */}
Open Tailscale Admin
{/* Logs Dialog */} Gateway Logs
{logsLoading ? ( ) : (
{logs}
)}
{/* Remove Confirmation */} Remove Secure Gateway? This will remove the gateway container and disconnect it from your Tailscale network.
{/* Update Auth Key Dialog */} { setShowUpdateAuthKey(open) if (!open) { setNewAuthKey("") setUpdateAuthKeyError("") } }}> Update Auth Key Enter a new Tailscale auth key to re-authenticate the gateway. This is useful if your previous key has expired.
setNewAuthKey(e.target.value)} placeholder="tskey-auth-..." className="font-mono text-sm" />

Generate a new key at{" "} Tailscale Admin > Settings > Keys

{updateAuthKeyError && (

{updateAuthKeyError}

)}
{/* Post-Deploy Info Dialog */} Gateway Deployed Successfully!

Your gateway is connected to Tailscale. To complete setup, you need to approve the advertised routes in Tailscale Admin.

{deployedConfig.advertise_routes?.length > 0 && (

Routes to approve:

{deployedConfig.advertise_routes.map((route: string) => (

{route}

))}
)}

How to approve:

  1. Click the button below to open Tailscale Admin
  2. Find {deployedConfig.hostname || "proxmox-gateway"} in the machines list
  3. Click on it to open machine details
  4. In the Subnets section, click Edit and enable the route
  5. {deployedConfig.exit_node && (
  6. In Routing Settings, enable Exit Node
  7. )}

Once approved, you can access your Proxmox host at{" "} {deployedConfig.advertise_routes?.[0]?.replace("/32", "") || hostIp}:8006 (Proxmox UI) or{" "} {deployedConfig.advertise_routes?.[0]?.replace("/32", "") || hostIp}:8008 (ProxMenux Monitor) from any device with Tailscale.

) } // Not installed state return ( <>

Secure Gateway

VPN access without opening ports

Deploy a Tailscale VPN gateway for secure remote access to your Proxmox infrastructure. No port forwarding required.

{runtimeInfo && (

{runtimeAvailable ? : } Proxmox VE {runtimeInfo.version} - {runtimeAvailable ? "OCI support available" : "Requires Proxmox 9.1+"}

)}
{/* Wizard Dialog */} { if (!deploying) { setShowWizard(open) if (!open) { setCurrentStep(0) setDeployError("") setDeployPercent(0) } } }}> Secure Gateway Setup {/* Progress indicator - filter out "options" step if using Proxmox Only */}
{wizardSteps .filter((step) => !(config.access_mode === "host_only" && step.id === "options")) .map((step, idx) => { const adjustedCurrentStep = config.access_mode === "host_only" ? (currentStep > wizardSteps.findIndex((s) => s.id === "options") ? currentStep - 1 : currentStep) : currentStep return (
) })}
{renderWizardContent()} {/* Navigation */}
{currentStep < wizardSteps.length - 1 ? ( ) : ( )}
) }