"use client"; import { useEffect, useRef, useState } from "react"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import * as z from "zod"; import { Button } from "@app/components/ui/button"; import { Input } from "@app/components/ui/input"; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@app/components/ui/form"; import { toast } from "@app/hooks/useToast"; import { GetBrowserTargetResponse } from "@server/routers/browserGatewayTarget"; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@app/components/ui/card"; import { Alert, AlertDescription } from "@app/components/ui/alert"; import BrandedAuthSurface from "@app/components/BrandedAuthSurface"; import PoweredByPangolin from "@app/components/PoweredByPangolin"; import AuthPageFooterNotices from "@app/components/AuthPageFooterNotices"; import CollapsibleSessionToolbar from "@app/components/CollapsibleSessionToolbar"; import { useTranslations } from "next-intl"; import { loadEncryptedLocalStorage, saveEncryptedLocalStorage } from "@app/lib/secureLocalStorage"; type VncCredentialsForm = { password: string; }; const DEFAULT_VNC_CREDENTIALS: VncCredentialsForm = { password: "" }; export default function VncClient({ target, error, primaryColor }: { target: GetBrowserTargetResponse | null; error: string | null; primaryColor?: string | null; }) { const t = useTranslations(); const STORAGE_KEY = "pangolin_vnc_credentials"; const resourceName = target?.name?.trim() || null; const formSchema = z.object({ password: z.string() }); const form = useForm({ resolver: zodResolver(formSchema), defaultValues: DEFAULT_VNC_CREDENTIALS }); useEffect(() => { let cancelled = false; void loadEncryptedLocalStorage( STORAGE_KEY, target?.authToken ).then((saved) => { if (cancelled || !saved) return; form.reset({ ...DEFAULT_VNC_CREDENTIALS, ...saved }); }); return () => { cancelled = true; }; }, [form, target?.authToken]); const [connected, setConnected] = useState(false); const [connecting, setConnecting] = useState(false); const [connectError, setConnectError] = useState(null); const rfbRef = useRef(null); const screenRef = useRef(null); const disconnect = () => { if (rfbRef.current) { rfbRef.current.disconnect(); rfbRef.current = null; } setConnecting(false); setConnected(false); }; useEffect(() => { return () => disconnect(); }, []); const connect = async (values: VncCredentialsForm) => { setConnecting(true); if (!target) { setConnectError(t("vncNoResourceTarget")); setConnecting(false); return; } if (!screenRef.current) { setConnectError(t("sshErrorConnectionClosed")); setConnecting(false); return; } disconnect(); const proxyAddress = `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/gateway/vnc`; const base = proxyAddress.replace(/\/$/, ""); const params = new URLSearchParams({ host: target.ip, port: String(target.port), authToken: target.authToken }); try { const checkParams = new URLSearchParams(params); checkParams.set("checkOnly", "1"); const response = await fetch(`${base}?${checkParams.toString()}`); if (!response.ok) { const detail = (await response.text()).trim(); setConnectError(detail || t("sshErrorConnectionClosed")); setConnecting(false); return; } } catch { setConnectError(t("sshErrorWebSocket")); setConnecting(false); return; } let RFB: new ( target: HTMLElement, url: string, options?: Record ) => unknown; try { // @ts-expect-error — @novnc/novnc ships plain JS with no bundled types const mod = await import("@novnc/novnc"); RFB = mod.default ?? mod; } catch (err) { setConnecting(false); setConnectError(t("sshErrorWebSocket")); toast({ variant: "destructive", title: t("vncFailedToLoadNovnc"), description: `${err}` }); return; } const wsUrl = `${base}?${params.toString()}`; screenRef.current.innerHTML = ""; const options: Record = {}; if (values.password) { options.credentials = { password: values.password }; } let rfb: any; try { rfb = new RFB(screenRef.current, wsUrl, options); } catch { setConnecting(false); setConnectError(t("sshErrorWebSocket")); return; } let authConfirmed = false; rfb.scaleViewport = true; rfb.resizeSession = true; rfb.addEventListener("connect", () => { void saveEncryptedLocalStorage( STORAGE_KEY, values, target.authToken ); authConfirmed = true; setConnecting(false); setConnected(true); }); rfb.addEventListener( "disconnect", (e: { detail: { clean: boolean } }) => { rfbRef.current = null; setConnecting(false); setConnected(false); if (!authConfirmed && !e.detail.clean) { setConnectError(t("sshErrorConnectionClosed")); } } ); rfb.addEventListener( "securityfailure", (e: { detail: { status: number; reason?: string } }) => { disconnect(); setConnectError( e.detail.reason ?? t("vncAuthFailedStatus", { status: e.detail.status }) ); } ); rfbRef.current = rfb; }; const onSubmit = (values: VncCredentialsForm) => { setConnectError(null); connect(values); }; if (error) { return ( {t("vncTitle")} {error} ); } return ( <> {!connected && ( {t("vncTitle")} {resourceName ? `${t("vncSignInDescription")} (${resourceName})` : t("vncSignInDescription")}
( {t("vncPasswordOptional")} )} /> {connectError && ( {connectError} )}
)}
); }