"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 { useTranslations } from "next-intl"; type VncCredentialsForm = { password: string; }; function loadStoredCredentials(key: string): VncCredentialsForm { try { const saved = localStorage.getItem(key); if (saved) return JSON.parse(saved) as VncCredentialsForm; } catch { // ignore } return { 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: loadStoredCredentials(STORAGE_KEY) }); const [connected, setConnected] = 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; } setConnected(false); }; useEffect(() => { return () => disconnect(); }, []); const connect = async (values: VncCredentialsForm) => { if (!target) { setConnectError(t("vncNoResourceTarget")); return; } if (!screenRef.current) return; disconnect(); 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) { toast({ variant: "destructive", title: t("vncFailedToLoadNovnc"), description: `${err}` }); return; } 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 }); const wsUrl = `${base}?${params.toString()}`; screenRef.current.innerHTML = ""; const options: Record = {}; if (values.password) { options.credentials = { password: values.password }; } const rfb: any = new RFB(screenRef.current, wsUrl, options); rfb.scaleViewport = true; rfb.resizeSession = true; rfb.addEventListener("connect", () => { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(values)); } catch { // ignore } setConnected(true); }); rfb.addEventListener( "disconnect", (e: { detail: { clean: boolean } }) => { rfbRef.current = null; setConnected(false); } ); 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 && ( {resourceName ? `${t("vncTitle")} - ${resourceName}` : t("vncTitle")} {resourceName ? `${t("vncSignInDescription")} (${resourceName})` : t("vncSignInDescription")}
( {t("vncPasswordOptional")} )} /> {connectError && ( {connectError} )}
)}
); }