import type { ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { Globe, CheckCircle, AlertTriangle } from "lucide-react"; import { updateRemoteSettings, startRemoteTunnel, stopRemoteTunnel, killExternalTunnel, regenerateRemotePersistentToken, generateShortLivedRemoteToken, fetchRemoteUrl, fetchRemoteQr, type RemoteSettings, type RemoteStatus, } from "../../../api"; import type { ToastType } from "../../../hooks/useToast"; import type { SectionBaseProps, SettingsFormState } from "./context"; export interface RemoteSectionData { projectId?: string; addToast: (message: string, type?: ToastType) => void; remoteStatus: RemoteStatus | null; externalTunnel: { provider: string; url: string | null; } | null; tunnelShareLink: { url: string; qrSvg: string | null; } | null; remoteBusyAction: string | null; cloudflaredInstalling: boolean; cloudflaredInstallError: string | null; cloudflaredManualInstallCommand: () => string; cloudflaredMacFallbackCommand: () => string | null; handleInstallCloudflared: () => Promise; runRemoteAction: (label: string, action: () => Promise) => Promise; remoteShortLivedToken: { token: string; expiresAt: string; ttlMs: number; } | null; setRemoteShortLivedToken: (value: { token: string; expiresAt: string; ttlMs: number; } | null) => void; remoteAuthLinkTokenType: "persistent" | "short-lived"; setRemoteAuthLinkTokenType: (value: "persistent" | "short-lived") => void; remoteUrlPreview: { url: string; expiresAt: string | null; tokenType: "persistent" | "short-lived"; } | null; setRemoteUrlPreview: (value: { url: string; expiresAt: string | null; tokenType: "persistent" | "short-lived"; } | null) => void; remoteQrSvg: string | null; setRemoteQrSvg: (value: string | null) => void; } export interface RemoteSectionProps extends SectionBaseProps { scopeBanner: ReactNode; remote: RemoteSectionData; } export function RemoteSection({ scopeBanner, form, setForm, remote }: RemoteSectionProps) { const { t } = useTranslation("app"); const { projectId, addToast, remoteStatus, externalTunnel, tunnelShareLink, remoteBusyAction, cloudflaredInstalling, cloudflaredInstallError, cloudflaredManualInstallCommand, cloudflaredMacFallbackCommand, handleInstallCloudflared, runRemoteAction, remoteShortLivedToken, setRemoteShortLivedToken, remoteAuthLinkTokenType, setRemoteAuthLinkTokenType, remoteUrlPreview, setRemoteUrlPreview, remoteQrSvg, setRemoteQrSvg, } = remote; const remoteForm = form as Record; const activeProvider = (remoteForm.remoteActiveProvider as "tailscale" | "cloudflare" | null) ?? null; const tunnelState = (remoteStatus?.state as RemoteStatus["state"] | "error" | undefined) ?? "stopped"; const statusColor = tunnelState === "running" ? "running" : tunnelState === "starting" ? "starting" : tunnelState === "failed" || tunnelState === "error" ? "error" : "stopped"; const buildSavePayload = (provider: "tailscale" | "cloudflare"): Partial => { const formState = form as Record; return { remoteActiveProvider: provider, remoteTailscaleEnabled: provider === "tailscale", remoteTailscaleHostname: String(formState.remoteTailscaleHostname ?? ""), remoteTailscaleTargetPort: Number(formState.remoteTailscaleTargetPort ?? 4040), remoteTailscaleAcceptRoutes: Boolean(formState.remoteTailscaleAcceptRoutes), remoteCloudflareEnabled: provider === "cloudflare", remoteCloudflareQuickTunnel: Boolean(formState.remoteCloudflareQuickTunnel ?? true), remoteCloudflareTunnelName: String(formState.remoteCloudflareTunnelName ?? ""), remoteCloudflareTunnelToken: (formState.remoteCloudflareTunnelToken as string | null) || null, remoteCloudflareIngressUrl: String(formState.remoteCloudflareIngressUrl ?? ""), remoteShortLivedEnabled: Boolean(formState.remoteShortLivedEnabled), remoteShortLivedTtlMs: Number(formState.remoteShortLivedTtlMs ?? 900000), remoteRememberLastRunning: Boolean(formState.remoteRememberLastRunning), }; }; return (<> {scopeBanner}

{t("settings.remote.remoteAccess", "Remote Access")}

{tunnelState} {remoteStatus?.provider && · {remoteStatus.provider}} {remoteStatus?.url && {remoteStatus.url}} {remoteStatus?.lastError && {remoteStatus.lastError}}
{tunnelState === "stopped" && externalTunnel && (
{externalTunnel.url && {externalTunnel.url}} {tunnelShareLink?.qrSvg && (
{t("settings.remote.scanToOpen", "Scan to open:")} {t("settings.remote.externalTunnelQRCode",
)}
)} {tunnelState === "running" && (remoteStatus?.url || tunnelShareLink) && (() => { let accessCode: string | null = null; let tailnetUrl: string | null = remoteStatus?.url ?? null; if (tunnelShareLink?.url) { try { const parsed = new URL(tunnelShareLink.url); accessCode = parsed.searchParams.get("rt"); if (!tailnetUrl) tailnetUrl = `${parsed.origin}/`; } catch { // fall through } } return (
{tailnetUrl && (
{t("settings.remote.tailnetURL", "Tailnet URL:")} {tailnetUrl}
)} {accessCode && (
{t("settings.remote.remoteAccessCode", "Remote access code:")} {accessCode}
)} {tunnelShareLink?.qrSvg && (
{t("settings.remote.scanToConnect", "Scan to connect:")} {t("settings.remote.remoteAccessQRCode",
)}
); })()}
{!activeProvider && {t("settings.remote.selectAProviderAboveToConfigureRemoteAccess", "Select a provider above to configure remote access.")}}
{activeProvider === "cloudflare" && remoteStatus?.cloudflaredAvailable === true && (
)} {activeProvider === "cloudflare" && remoteStatus?.cloudflaredAvailable === false && (
)} {activeProvider && (
{activeProvider === "tailscale" ? (<> {t("settings.remote.tailscaleFunnelWillExposeThisDashboardOnYour", "Tailscale Funnel will expose this dashboard on your tailnet's public ")}{`https://..ts.net/`}{t("settings.remote.uRLNoHostnameOrPortConfigurationNeeded", " URL \u2014 no hostname or port configuration needed.")} ) : (<> {(remoteForm.remoteCloudflareQuickTunnel ?? true) ? t("settings.remote.usingQuickTunnel", "Using Quick Tunnel — automatically creates a random trycloudflare.com URL, no account needed.") : t("settings.remote.namedTunnelModeEnabled", "Named Tunnel mode enabled — configure tunnel name, token, and ingress URL below.")}
{ const detailsOpen = event.currentTarget.open; setForm((f) => { const currentQuickTunnel = Boolean((f as Record).remoteCloudflareQuickTunnel ?? true); const nextQuickTunnel = !detailsOpen; if (currentQuickTunnel === nextQuickTunnel) { return f; } return { ...f, remoteCloudflareQuickTunnel: nextQuickTunnel } as SettingsFormState; }); }}> {t("settings.remote.advancedNamedTunnel", "Advanced (Named Tunnel)")} {!(remoteForm.remoteCloudflareQuickTunnel ?? true) ? (
setForm((f) => ({ ...f, remoteCloudflareTunnelName: e.target.value } as SettingsFormState))}/> setForm((f) => ({ ...f, remoteCloudflareTunnelToken: e.target.value } as SettingsFormState))}/> setForm((f) => ({ ...f, remoteCloudflareIngressUrl: e.target.value } as SettingsFormState))}/>
) : null}
)}
)}
{tunnelState === "running" || tunnelState === "starting" ? () : (<> {externalTunnel ? (
) : ()} {activeProvider === "cloudflare" && remoteStatus?.cloudflaredAvailable === false ? ({t("settings.remote.cloudflaredMustBeInstalledToStartTheTunnel", "cloudflared must be installed to start the tunnel")}) : null} )}
{t("settings.remote.advancedSettings", "Advanced Settings")}
setForm((f) => ({ ...f, remoteShortLivedTtlMs: Number(e.target.value || 900000) } as SettingsFormState))}/> {remoteShortLivedToken && {t("settings.remote.lastShortLivedTokenExpiresAt", "Last short-lived token expires at ")}{new Date(remoteShortLivedToken.expiresAt).toLocaleString()} ({remoteShortLivedToken.ttlMs}{t("settings.remote.ms", "ms)")}}
{t("settings.remote.automaticallyRestoreTunnelOnStartupIfItWas", "Automatically restore tunnel on startup if it was running when last stopped.")}
{t("settings.remote.uRLAndQRGenerationUseTheSelectedToken", " URL and QR generation use the selected token type. ")}{remoteAuthLinkTokenType === "short-lived" ? ` TTL: ${Number(remoteForm.remoteShortLivedTtlMs ?? 900000)}ms.` : ""} {remoteUrlPreview?.url && (<> {t("settings.remote.authenticatedURL", "Authenticated URL:")}{remoteUrlPreview.url} {t("settings.remote.tokenType", " Token type: ")}{remoteUrlPreview.tokenType} {remoteUrlPreview.expiresAt ? t("settings.remote.expiresAt", " · Expires at {{expiresAt}}", { expiresAt: new Date(remoteUrlPreview.expiresAt).toLocaleString() }) : t("settings.remote.noExpiry", " · No expiry")} )} {remoteQrSvg && (

{t("settings.remote.scanThisQRCodeOnYourPhone", "Scan this QR code on your phone")}

{t("settings.remote.remoteAccessQRCode",
{t("settings.remote.qRSVGMarkup", "QR SVG markup")}
{remoteQrSvg}
)}
); } export default RemoteSection;