"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, ArrowUpCircle, } 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 StorageInfo { name: string type: string total: number used: number avail: number active: boolean enabled: boolean 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) // Surface initial-data load failures. Wizard rendering depends on // wizardSteps being populated; if loadInitialData throws, we previously // ended up with `loading=false` and an empty wizard, which read as a // broken UI. Keep the error message so we can show a retry button. const [loadError, setLoadError] = useState(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([]) const [storages, setStorages] = 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 [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("") // Sprint 14.6: Tailscale / Alpine package update flow. // `updateInfo`: result of GET /api/oci/installed//update-check. // `null` until the first probe lands. // `updateApplying`: true while POST /update is running. Long op // (apk upgrade can take 1-3 min on slow links). // `updateError` / `updateResultMsg`: surfaced as a small banner // so the user gets explicit feedback. const [updateInfo, setUpdateInfo] = useState<{ available: boolean current_version?: string | null latest_version?: string | null packages?: Array<{ name: string; current: string; latest: string }> last_checked_iso?: string error?: string | null } | null>(null) const [updateApplying, setUpdateApplying] = useState(false) const [updateError, setUpdateError] = useState(null) const [updateResultMsg, setUpdateResultMsg] = useState(null) // Password visibility const [visiblePasswords, setVisiblePasswords] = useState>(new Set()) useEffect(() => { loadInitialData() }, []) const loadInitialData = async () => { setLoading(true) setLoadError(null) try { // Secure Gateway uses standard LXC, not OCI containers // So we don't require PVE 9.1+ - it works on any Proxmox version setRuntimeAvailable(true) // Still load runtime info for reference const runtimeRes = await fetchApi("/api/oci/runtime") if (runtimeRes.success) { setRuntimeInfo({ runtime: runtimeRes.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 || []) // Get host IP for "Host Only" mode const primaryNetwork = networksRes.networks?.find((n: NetworkInfo) => n.recommended) || networksRes.networks?.[0] // Backend returns "ip" field with the host IP address const hostIpValue = primaryNetwork?.ip || primaryNetwork?.address if (hostIpValue) { // Remove CIDR notation if present (e.g., "192.168.0.55/24" -> "192.168.0.55") const ip = hostIpValue.split("/")[0] setHostIp(ip) } } // Load available storages const storagesRes = await fetchApi("/api/oci/storages") if (storagesRes.success && storagesRes.storages?.length > 0) { setStorages(storagesRes.storages) // Set default storage (first recommended one) const recommended = storagesRes.storages.find((s: StorageInfo) => s.recommended) || storagesRes.storages[0] if (recommended) { setConfig(prev => ({ ...prev, storage: recommended.name })) } } } catch (err) { console.error("Failed to load data:", err) setLoadError(err instanceof Error ? err.message : "Failed to load wizard data") } finally { setLoading(false) } } const loadStatus = async () => { try { const statusRes = await fetchApi("/api/oci/status/secure-gateway") if (statusRes.success) { setAppStatus(statusRes.status) // Once we know the gateway is installed, kick off the update // probe in the background. It hits the 24h-cached endpoint, so // repeating this on every status reload is essentially free. if (statusRes.status?.state && statusRes.status.state !== "not_installed") { loadUpdateInfo() } } } catch (err) { // Not installed is ok } } // Pull the cached update-check from the backend. The server-side // cache is 24h, so this is cheap to call on mount. After applying // an update we pass `force=true` so the panel doesn't keep // rendering the pre-update "available" state from a stale cache // entry. const loadUpdateInfo = async (force = false) => { try { const url = force ? "/api/oci/installed/secure-gateway/update-check?force=1" : "/api/oci/installed/secure-gateway/update-check" const res: any = await fetchApi(url) if (res?.success) { setUpdateInfo({ available: !!res.available, current_version: res.current_version, latest_version: res.latest_version, packages: res.packages, last_checked_iso: res.last_checked_iso, error: res.error || null, }) } } catch { // Silent — the panel just won't show the update line. } } const handleApplyUpdate = async () => { setUpdateApplying(true) setUpdateError(null) setUpdateResultMsg(null) try { const res: any = await fetchApi("/api/oci/installed/secure-gateway/update", { method: "POST", }) if (res?.success) { setUpdateResultMsg(res.message || "Update applied") // Re-probe with force=true so the panel flips back to "No // updates available" immediately, bypassing the 24h server // cache which may still hold the pre-apply "available" entry. await loadUpdateInfo(true) // Status may briefly show "stopped" if tailscale was restarted — // refresh that too so the action buttons render the right state. await loadStatus() } else { setUpdateError(res?.message || "Update failed") } } catch (err) { setUpdateError(err instanceof Error ? err.message : "Network error during update") } finally { setUpdateApplying(false) } } const handleDeploy = async () => { // Concurrency guard. The button is also `disabled={deploying}`, but // a screen reader, a fast double-tap on a high-latency link, or an // automated test can fire two clicks before React re-renders the // disabled state. The handler-level guard makes it impossible to // submit a second deploy while one is still in flight. Audit Tier 6 // — `secure-gateway-setup.tsx` action buttons sin guard. if (deploying) return setDeploying(true) setDeployError("") setDeployProgress("Preparing deployment...") try { // Validate required fields const step = wizardSteps[currentStep] if (step?.fields) { for (const fieldName of step.fields) { const field = configSchema?.[fieldName] if (field?.required && !config[fieldName]) { setDeployError(`${field.label} is required`) setDeploying(false) return } } } // Prepare config based on access_mode const deployConfig = { ...config } if (config.access_mode === "host_only" && hostIp) { // Host only: just the host IP deployConfig.advertise_routes = [`${hostIp}/32`] } else if (config.access_mode === "proxmox_network") { // Proxmox network: use the recommended network (should already be set) if (!deployConfig.advertise_routes?.length) { const recommendedNetwork = networks.find((n) => n.recommended) || networks[0] if (recommendedNetwork) { deployConfig.advertise_routes = [recommendedNetwork.subnet] } } } // For "custom", the user has already selected networks manually setDeployProgress("Creating LXC container...") const result = await fetchApi("/api/oci/deploy", { method: "POST", body: JSON.stringify({ app_id: "secure-gateway", config: deployConfig }) }) if (!result.success) { // Make runtime errors more user-friendly let errorMsg = result.message || "Deployment failed" if (errorMsg.includes("9.1") || errorMsg.includes("OCI") || errorMsg.includes("not supported")) { errorMsg = "OCI containers require Proxmox VE 9.1 or later. Please upgrade your Proxmox installation to use this feature." } setDeployError(errorMsg) setDeploying(false) return } setDeployProgress("Gateway deployed successfully!") // Wipe the Tailscale auth_key from React state so it's no longer // reachable from a future XSS / state-inspection. The key only needs // to live in memory for the duration of the deploy POST. Audit // residual #11 — secure-gateway auth_key persistence. setConfig((prev) => ({ ...prev, auth_key: "" })) // Wait and reload status, then show post-deploy info setTimeout(async () => { await loadStatus() setShowWizard(false) setDeploying(false) setCurrentStep(0) // Show post-deploy confirmation - always show when access mode is set (routes need approval) const needsApproval = deployConfig.access_mode && deployConfig.access_mode !== "none" if (needsApproval) { // Ensure advertise_routes is set for the dialog const finalConfig = { ...deployConfig } if (deployConfig.access_mode === "host_only" && hostIp) { finalConfig.advertise_routes = [`${hostIp}/32`] } setDeployedConfig(finalConfig) setShowPostDeployInfo(true) } }, 2000) } catch (err: any) { setDeployError(err.message || "Deployment failed") setDeploying(false) } } const handleAction = async (action: "start" | "stop" | "restart") => { if (actionLoading) return setActionLoading(action) try { const result = await fetchApi(`/api/oci/installed/secure-gateway/${action}`, { method: "POST" }) if (result.success) { await loadStatus() } } catch (err) { console.error(`Failed to ${action}:`, err) } finally { setActionLoading(null) } } const handleUpdateAuthKey = async () => { if (!newAuthKey.trim()) { setUpdateAuthKeyError("Auth Key is required") return } if (updateAuthKeyLoading) 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 } // Success - close dialog and reload status setShowUpdateAuthKey(false) setNewAuthKey("") await loadStatus() } catch (err: any) { setUpdateAuthKeyError(err.message || "Failed to update auth key") } finally { setUpdateAuthKeyLoading(false) } } const handleRemove = async () => { if (actionLoading) return setActionLoading("remove") try { const result = await fetchApi("/api/oci/installed/secure-gateway?remove_data=false", { method: "DELETE" }) if (result.success) { setAppStatus({ state: "not_installed", health: "unknown", uptime_seconds: 0, last_check: "" }) setShowRemoveConfirm(false) } } catch (err) { console.error("Failed to remove:", err) } finally { setActionLoading(null) } } const loadLogs = async () => { setLogsLoading(true) try { const result = await fetchApi("/api/oci/installed/secure-gateway/logs?lines=100") if (result.success) { setLogs(result.logs || "No logs available") } } catch (err) { setLogs("Failed to load logs") } finally { setLogsLoading(false) } } const formatUptime = (seconds: number): string => { if (seconds < 60) return `${seconds}s` if (seconds < 3600) return `${Math.floor(seconds / 60)}m` if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m` return `${Math.floor(seconds / 86400)}d ${Math.floor((seconds % 86400) / 3600)}h` } // Format an ISO timestamp as a friendly "HH:MM" / "yesterday HH:MM" / // date-only string. Used in the Updates panel — the user wants to know // "how stale is this number" without seeing the raw 2026-05-09T10:23Z. const formatLastChecked = (iso?: string): string => { if (!iso) return "never" const d = new Date(iso) if (isNaN(d.getTime())) return "unknown" const now = Date.now() const ageMs = now - d.getTime() const sameDay = new Date(now).toDateString() === d.toDateString() const yesterday = new Date(now - 86_400_000).toDateString() === d.toDateString() const time = d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) if (sameDay) return time if (yesterday) return `yesterday ${time}` if (ageMs < 7 * 86_400_000) { return d.toLocaleDateString([], { weekday: "short" }) + " " + time } return d.toLocaleDateString([], { month: "short", day: "numeric" }) } const renderField = (fieldName: string) => { const field = configSchema?.[fieldName] if (!field) return null // Check depends_on if (field.depends_on) { const depValue = config[field.depends_on.field] if (!field.depends_on.values.includes(depValue)) { return null } } const isVisible = visiblePasswords.has(fieldName) switch (field.type) { case "password": return (
setConfig({ ...config, [fieldName]: e.target.value })} placeholder={field.placeholder} className="pr-10 bg-background border-border" />

{field.description}

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

{field.description}

) case "select": // Special handling for access_mode to auto-select networks const handleSelectChange = (value: string) => { const newConfig = { ...config, [fieldName]: value } // When access_mode changes to proxmox_network, auto-select the recommended network if (fieldName === "access_mode" && value === "proxmox_network") { const recommendedNetwork = networks.find((n) => n.recommended) || networks[0] if (recommendedNetwork) { newConfig.advertise_routes = [recommendedNetwork.subnet] } } // Clear routes when switching to host_only if (fieldName === "access_mode" && value === "host_only") { newConfig.advertise_routes = [] } // Clear routes when switching to custom (user will select manually) 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}

)} {/* Show selected network for proxmox_network */} {fieldName === "access_mode" && opt.value === "proxmox_network" && config[fieldName] === "proxmox_network" && (

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

)}
))}
) case "networks": return (

{field.description}

{networks.length === 0 ? (

No networks detected

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

{net.interface} ({net.type})

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

{field.label}

{field.description}

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

{field.warning}

)}
) default: return null } } const renderWizardContent = () => { const step = wizardSteps[currentStep] if (!step) return null if (step.id === "intro") { return (

Secure Remote Access

Deploy a VPN gateway using Tailscale for secure, zero-trust access to your Proxmox infrastructure without opening ports.

What you{"'"}ll get:

  • Access ProxMenux Monitor from anywhere
  • Secure access to Proxmox web UI
  • Optionally expose VMs and LXC containers
  • End-to-end encryption
  • No port forwarding required

You{"'"}ll need a free Tailscale account. If you don{"'"}t have one, you can create it at{" "} tailscale.com

) } if (step.id === "deploy") { return (

Review & Deploy

Review your configuration before deploying the gateway.

{/* Storage selector */} {storages.length > 1 && (

Select where to create the container disk.

{storages.filter(s => s.active && s.enabled).map((storage) => (
setConfig({ ...config, storage: storage.name })} className={`p-3 rounded-lg border cursor-pointer transition-colors ${ config.storage === storage.name ? "border-cyan-500 bg-cyan-500/10" : "border-border hover:border-muted-foreground/50" }`} >
{config.storage === storage.name && (
)}
{storage.name} ({storage.type}) {storage.recommended && ( Recommended )}

{(storage.avail / 1024 / 1024 / 1024).toFixed(1)} GB available

))}
)}

Configuration Summary

Hostname: {config.hostname || "proxmox-gateway"}
{storages.length > 1 && (
Storage: {config.storage || storages[0]?.name}
)}
Access Mode: {config.access_mode === "host_only" ? "Host Only" : config.access_mode === "proxmox_network" ? "Proxmox Network" : "Custom Networks"}
{config.access_mode === "host_only" && hostIp && (
Host Access: {hostIp}/32
)} {(config.access_mode === "proxmox_network" || config.access_mode === "custom") && config.advertise_routes?.length > 0 && (
Networks: {config.advertise_routes.join(", ")}
)}
Exit Node: {config.exit_node ? "Yes" : "No"}
Accept Routes: {config.accept_routes ? "Yes" : "No"}
{/* 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}
)} {deployError && (

{deployError}

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

{step.title}

{step.description}

{step.fields?.map((fieldName) => renderField(fieldName))}
) } // Loading state if (loading) { return (
Secure Gateway
) } // Initial data load failed — show the error and a retry button instead // of an empty wizard. Without this, a transient network error or 401 // dropped the user into a wizard with zero steps and no signal. if (loadError) { return (
Secure Gateway

Could not load setup data: {loadError}

) } // Installed state if (appStatus.state !== "not_installed") { const isRunning = appStatus.state === "running" const isStopped = appStatus.state === "stopped" const isError = appStatus.state === "error" return ( <>
Secure Gateway
{isRunning ? : isStopped ? : } {isRunning ? "Connected" : isStopped ? "Stopped" : "Error"}
Tailscale VPN Gateway
{/* Status info */} {isRunning && appStatus.uptime_seconds > 0 && (
Uptime: {formatUptime(appStatus.uptime_seconds)}
)} {/* Actions */}
{isStopped && ( )} {isRunning && ( <> )}
{/* Updates panel — only when we have a probe result. The cached 24h backend means this stays cheap; the user doesn't see anything during the very first load. */} {updateInfo && !updateInfo.error && (
{updateInfo.available ? ( <>
Last checked: {formatLastChecked(updateInfo.last_checked_iso)} ·{" "} Tailscale v{updateInfo.latest_version} available
{updateInfo.packages && updateInfo.packages.length > 1 && (
+{updateInfo.packages.length - 1} other package {updateInfo.packages.length > 2 ? "s" : ""} pending in the container
)} ) : (
Last checked: {formatLastChecked(updateInfo.last_checked_iso)} {updateInfo.current_version ? ` · Tailscale v${updateInfo.current_version}` : ""} {" · "} No updates available
)} {updateError && (
{updateError}
)} {updateResultMsg && !updateError && (
{updateResultMsg}
)}
)} {/* Update Auth Key button */}
Open Tailscale Admin
{/* Logs Dialog */} Secure Gateway Logs Recent container logs
{logsLoading ? (
) : (
                  {logs || "No logs available"}
                
)}
{/* Remove Confirm Dialog */} Remove Secure Gateway? This will stop and remove the gateway container. Your Tailscale state will be preserved for re-deployment.
{/* 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 One more step to complete the setup

Next Step: Approve in Tailscale Admin

You need to approve the following settings in your Tailscale admin console for them to take effect:

    {deployedConfig.advertise_routes?.length > 0 && (
  • Subnet Routes: {deployedConfig.advertise_routes.join(", ")}
  • )} {deployedConfig.exit_node && (
  • Exit Node: Route all internet traffic
  • )}

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.

{/* Wizard Dialog */} { if (!deploying) { setShowWizard(open) if (!open) { setCurrentStep(0) setDeployError("") } } }}> {/* Fixed Header */}
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) => { // Recalculate the actual step index accounting for skipped steps const actualIdx = wizardSteps.findIndex((s) => s.id === step.id) const adjustedCurrentStep = config.access_mode === "host_only" ? (currentStep > wizardSteps.findIndex((s) => s.id === "options") ? currentStep - 1 : currentStep) : currentStep return (
) })}
{/* Scrollable Content */}
{renderWizardContent()}
{/* Fixed Footer with Navigation */}
{currentStep < wizardSteps.length - 1 ? ( ) : ( )}
) }