From d2793dfad713f585f2f62c0c0d9ed62f2cf875db Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Thu, 4 Jun 2026 18:07:50 -0700 Subject: [PATCH] use react forms --- messages/en-US.json | 1 + src/app/rdp/RdpClient.tsx | 258 +++++++++++----------- src/app/ssh/SshClient.tsx | 437 +++++++++++++++++++++----------------- src/app/vnc/VncClient.tsx | 146 ++++++------- 4 files changed, 438 insertions(+), 404 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 4d4a41e43..409a589b1 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -3465,6 +3465,7 @@ "sshTerminalError": "Error: {error}", "sshConnectionClosedCode": "Connection closed (code {code})", "sshPrivateKeyPlaceholder": "-----BEGIN OPENSSH PRIVATE KEY-----", + "sshPrivateKeyRequired": "Private key is required", "vncTitle": "VNC", "vncSignInDescription": "Enter your VNC password to connect", "vncPasswordOptional": "Password (optional)", diff --git a/src/app/rdp/RdpClient.tsx b/src/app/rdp/RdpClient.tsx index 9b5292b1b..7be3d9360 100644 --- a/src/app/rdp/RdpClient.tsx +++ b/src/app/rdp/RdpClient.tsx @@ -1,9 +1,19 @@ "use client"; import { useEffect, useRef, useState } from "react"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; +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 type { UserInteraction, @@ -43,7 +53,7 @@ declare module "react" { } } -type FormState = { +type RdpCredentialsForm = { username: string; password: string; domain: string; @@ -52,6 +62,23 @@ type FormState = { enableClipboard: boolean; }; +function loadStoredCredentials(key: string): RdpCredentialsForm { + try { + const saved = localStorage.getItem(key); + if (saved) return JSON.parse(saved) as RdpCredentialsForm; + } catch { + // ignore + } + return { + username: "", + password: "", + domain: "", + kdcProxyUrl: "", + pcb: "", + enableClipboard: true + }; +} + const isIronError = (error: unknown): error is IronError => { return ( typeof error === "object" && @@ -73,21 +100,18 @@ export default function RdpClient({ const t = useTranslations(); const STORAGE_KEY = "pangolin_rdp_credentials"; - const [form, setForm] = useState(() => { - try { - const saved = localStorage.getItem(STORAGE_KEY); - if (saved) return JSON.parse(saved) as FormState; - } catch { - // ignore - } - return { - username: "", - password: "", - domain: "", - kdcProxyUrl: "", - pcb: "", - enableClipboard: true - }; + const formSchema = z.object({ + username: z.string().min(1, { message: t("usernameRequired") }), + password: z.string().min(1, { message: t("passwordRequired") }), + domain: z.string(), + kdcProxyUrl: z.string(), + pcb: z.string(), + enableClipboard: z.boolean() + }); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: loadStoredCredentials(STORAGE_KEY) }); const [showLogin, setShowLogin] = useState(true); @@ -167,12 +191,7 @@ export default function RdpClient({ el.addEventListener("ready", onReady); }; - const update = (key: K, value: FormState[K]) => { - setForm((prev) => ({ ...prev, [key]: value })); - }; - - const startSession = async () => { - setSubmitError(null); + const startSession = async (values: RdpCredentialsForm) => { setConnecting(true); const userInteraction = userInteractionRef.current; const exts = extensionsRef.current; @@ -182,7 +201,7 @@ export default function RdpClient({ return; } - userInteraction.setEnableClipboard(form.enableClipboard); + userInteraction.setEnableClipboard(values.enableClipboard); // Dispose any previous session's provider and create a fresh one so // there is no stale upload state from a prior connection. @@ -248,13 +267,13 @@ export default function RdpClient({ const builder = userInteraction .configBuilder() - .withUsername(form.username) - .withPassword(form.password) + .withUsername(values.username) + .withPassword(values.password) .withDestination(destination) .withProxyAddress( `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/gateway/rdp` ) - .withServerDomain(form.domain) + .withServerDomain(values.domain) .withAuthToken(target.authToken) .withDesktopSize({ width: window.innerWidth, @@ -262,18 +281,18 @@ export default function RdpClient({ }) .withExtension(exts.displayControl(true)); - if (form.pcb !== "") { - builder.withExtension(exts.preConnectionBlob(form.pcb)); + if (values.pcb !== "") { + builder.withExtension(exts.preConnectionBlob(values.pcb)); } - if (form.kdcProxyUrl !== "") { - builder.withExtension(exts.kdcProxyUrl(form.kdcProxyUrl)); + if (values.kdcProxyUrl !== "") { + builder.withExtension(exts.kdcProxyUrl(values.kdcProxyUrl)); } try { const sessionInfo = await userInteraction.connect(builder.build()); try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(form)); + localStorage.setItem(STORAGE_KEY, JSON.stringify(values)); } catch { // ignore } @@ -296,6 +315,11 @@ export default function RdpClient({ } }; + const onSubmit = (values: RdpCredentialsForm) => { + setSubmitError(null); + startSession(values); + }; + const ui = () => userInteractionRef.current; const toggleCursorKind = () => { @@ -340,87 +364,76 @@ export default function RdpClient({ -
- - - update("domain", e.target.value) - } - /> - - - - update("username", e.target.value) - } - /> - - - - update("password", e.target.value) - } - /> - - {/* - - update("pcb", e.target.value)} - /> - */} - - {/* - - update("kdcProxyUrl", e.target.value) - } - /> - */} - {/*
- - update("enableClipboard", checked === true) - } - /> - -
*/} - {submitError && ( - - - {submitError} - - - )} - - -
+ ( + + + {t("domain")} + + + + + + + )} + /> + ( + + + {t("username")} + + + + + + + )} + /> + ( + + + {t("password")} + + + + + + + )} + /> + + {submitError && ( + + + {submitError} + + + )} + +
@@ -539,20 +552,3 @@ export default function RdpClient({ ); } - -function Field({ - label, - id, - children -}: { - label: string; - id: string; - children: React.ReactNode; -}) { - return ( -
- - {children} -
- ); -} diff --git a/src/app/ssh/SshClient.tsx b/src/app/ssh/SshClient.tsx index c3123561c..4a2c3a652 100644 --- a/src/app/ssh/SshClient.tsx +++ b/src/app/ssh/SshClient.tsx @@ -2,10 +2,19 @@ import "@xterm/xterm/css/xterm.css"; import { useEffect, useRef, useState } from "react"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Textarea } from "@/components/ui/textarea"; +import { useForm } from "react-hook-form"; +import * as z from "zod"; +import { Button } from "@app/components/ui/button"; +import { Input } from "@app/components/ui/input"; +import { Textarea } from "@app/components/ui/textarea"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@app/components/ui/form"; import { GetBrowserTargetResponse } from "@server/routers/browserGatewayTarget"; import { Card, @@ -16,7 +25,7 @@ import { } from "@app/components/ui/card"; import Link from "next/link"; import { ExternalLink, Loader2 } from "lucide-react"; -import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Alert, AlertDescription } from "@app/components/ui/alert"; import { HorizontalTabs } from "@app/components/HorizontalTabs"; import type { SignSshKeyResponse } from "@server/routers/ssh/types"; import { useTranslations } from "next-intl"; @@ -25,7 +34,7 @@ import PoweredByPangolin from "@app/components/PoweredByPangolin"; type AuthTab = "password" | "privateKey"; -type FormState = { +type SshCredentialsForm = { username: string; password: string; privateKey: string; @@ -38,6 +47,16 @@ type ConnectCredentials = { certificate?: string; }; +function loadStoredCredentials(key: string): SshCredentialsForm { + try { + const saved = localStorage.getItem(key); + if (saved) return JSON.parse(saved) as SshCredentialsForm; + } catch { + // ignore + } + return { username: "", password: "", privateKey: "" }; +} + export default function SshClient({ target, error, @@ -52,18 +71,21 @@ export default function SshClient({ primaryColor?: string | null; }) { const STORAGE_KEY = "pangolin_ssh_credentials"; + const t = useTranslations(); - const [form, setForm] = useState(() => { - try { - const saved = localStorage.getItem(STORAGE_KEY); - if (saved) return JSON.parse(saved) as FormState; - } catch { - // ignore - } - return { username: "", password: "", privateKey: "" }; + const passwordTabSchema = z.object({ + username: z.string().min(1, { message: t("usernameRequired") }), + password: z.string().min(1, { message: t("passwordRequired") }) }); - const t = useTranslations(); + const privateKeyTabSchema = z.object({ + username: z.string().min(1, { message: t("usernameRequired") }), + privateKey: z.string().min(1, { message: t("sshPrivateKeyRequired") }) + }); + + const form = useForm({ + defaultValues: loadStoredCredentials(STORAGE_KEY) + }); function handleKeyFile(e: React.ChangeEvent) { const file = e.target.files?.[0]; @@ -72,11 +94,10 @@ export default function SshClient({ reader.onload = (ev) => { const text = ev.target?.result; if (typeof text === "string") { - setForm((prev) => ({ ...prev, privateKey: text })); + form.setValue("privateKey", text, { shouldDirty: true }); } }; reader.readAsText(file); - // Reset input so the same file can be re-selected if needed. e.target.value = ""; } @@ -128,14 +149,12 @@ export default function SshClient({ xtermRef.current = terminal; fitAddonRef.current = fitAddon; - // Send user keystrokes to the WebSocket. terminal.onData((data) => { if (wsRef.current?.readyState === WebSocket.OPEN) { wsRef.current.send(JSON.stringify({ type: "data", data })); } }); - // Send resize events. terminal.onResize(({ cols, rows }) => { if (wsRef.current?.readyState === WebSocket.OPEN) { wsRef.current.send( @@ -144,7 +163,6 @@ export default function SshClient({ } }); - // Send the initial size once the terminal is rendered. const { cols, rows } = terminal; if (wsRef.current?.readyState === WebSocket.OPEN) { wsRef.current.send( @@ -158,14 +176,12 @@ export default function SshClient({ }; }, [connected]); - // Refit terminal when the window resizes. useEffect(() => { const onResize = () => fitAddonRef.current?.fit(); window.addEventListener("resize", onResize); return () => window.removeEventListener("resize", onResize); }, []); - // Cleanup on unmount. useEffect(() => { return () => { wsRef.current?.close(); @@ -173,7 +189,6 @@ export default function SshClient({ }; }, []); - // Auto-connect when signed key data is provided (push PAM mode). useEffect(() => { if (signedKeyData && signedPrivateKey && target) { connect({ @@ -188,7 +203,6 @@ export default function SshClient({ override?: ConnectCredentials, authMethod: AuthTab = "password" ) { - setConnectError(null); setConnecting(true); if (!target) { @@ -197,13 +211,14 @@ export default function SshClient({ return; } - const username = override?.username ?? form.username; + const values = form.getValues(); + const username = override?.username ?? values.username; const password = override?.password ?? - (authMethod === "password" ? form.password : ""); + (authMethod === "password" ? values.password : ""); const privateKey = override?.privateKey ?? - (authMethod === "privateKey" ? form.privateKey : ""); + (authMethod === "privateKey" ? values.privateKey : ""); const certificate = override?.certificate; const proxyAddress = `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/gateway/ssh`; @@ -222,16 +237,10 @@ export default function SshClient({ const ws = new WebSocket(url.toString(), ["ssh"]); wsRef.current = ws; - // Track whether the server has confirmed auth by sending the first - // data frame. Until then, errors are shown in the login form. let authConfirmed = false; let authErrorShown = false; ws.onopen = () => { - // Send credentials as the first frame so the proxy can complete - // SSH authentication before piping pty data. Stay in "connecting" - // state until the server responds - this prevents the flash to the - // terminal page that would occur if we set connected=true here. ws.send( JSON.stringify({ type: "auth", @@ -242,7 +251,10 @@ export default function SshClient({ ); if (!override) { try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(form)); + localStorage.setItem( + STORAGE_KEY, + JSON.stringify(form.getValues()) + ); } catch { // ignore } @@ -266,7 +278,6 @@ export default function SshClient({ xtermRef.current?.write(msg.data); } else if (msg.type === "error") { if (!authConfirmed) { - // Auth-phase error - show in the login form. authErrorShown = true; setConnecting(false); setConnectError( @@ -312,8 +323,6 @@ export default function SshClient({ `\r\n\x1b[33m${t("sshConnectionClosedCode", { code: evt.code })}\x1b[0m\r\n` ); } - // If auth was never confirmed the login form is already visible; - // a generic error is shown only when no specific error was received. if (!authConfirmed && !authErrorShown) { setConnectError(t("sshErrorConnectionClosed")); } @@ -327,7 +336,40 @@ export default function SshClient({ setConnected(false); } - // In push mode, show a connecting/connected state without the login form. + function applyTabSchemaErrors( + schema: z.ZodObject, + values: SshCredentialsForm + ) { + form.clearErrors(); + const result = schema.safeParse(values); + if (result.success) return true; + for (const issue of result.error.issues) { + const field = issue.path[0]; + if (typeof field === "string") { + form.setError(field as keyof SshCredentialsForm, { + message: issue.message + }); + } + } + return false; + } + + function onPasswordSubmit(e: React.FormEvent) { + e.preventDefault(); + setConnectError(null); + const values = form.getValues(); + if (!applyTabSchemaErrors(passwordTabSchema, values)) return; + connect(undefined, "password"); + } + + function onPrivateKeySubmit(e: React.FormEvent) { + e.preventDefault(); + setConnectError(null); + const values = form.getValues(); + if (!applyTabSchemaErrors(privateKeyTabSchema, values)) return; + connect(undefined, "privateKey"); + } + if (signedKeyData && signedPrivateKey) { return ( <> @@ -352,7 +394,10 @@ export default function SshClient({ )} {connectError && ( - + {connectError} @@ -406,155 +451,164 @@ export default function SshClient({ - -
- + +
- - setForm({ - ...form, - username: e.target.value - }) - } - /> - - - - setForm({ - ...form, - password: e.target.value - }) - } - /> - -
- {connectError && ( - - - {connectError} - - - )} - - -
-
- -
-

- {t("sshPrivateKeyDisclaimer")}{" "} - - {t("sshLearnMore")} - - -

- - - setForm({ - ...form, - username: e.target.value - }) - } - /> - - -