use react forms

This commit is contained in:
miloschwartz
2026-06-04 18:07:50 -07:00
parent ff507f1275
commit d2793dfad7
4 changed files with 438 additions and 404 deletions
+1
View File
@@ -3465,6 +3465,7 @@
"sshTerminalError": "Error: {error}", "sshTerminalError": "Error: {error}",
"sshConnectionClosedCode": "Connection closed (code {code})", "sshConnectionClosedCode": "Connection closed (code {code})",
"sshPrivateKeyPlaceholder": "-----BEGIN OPENSSH PRIVATE KEY-----", "sshPrivateKeyPlaceholder": "-----BEGIN OPENSSH PRIVATE KEY-----",
"sshPrivateKeyRequired": "Private key is required",
"vncTitle": "VNC", "vncTitle": "VNC",
"vncSignInDescription": "Enter your VNC password to connect", "vncSignInDescription": "Enter your VNC password to connect",
"vncPasswordOptional": "Password (optional)", "vncPasswordOptional": "Password (optional)",
+127 -131
View File
@@ -1,9 +1,19 @@
"use client"; "use client";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button"; import { useForm } from "react-hook-form";
import { Input } from "@/components/ui/input"; import { zodResolver } from "@hookform/resolvers/zod";
import { Label } from "@/components/ui/label"; 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 { toast } from "@app/hooks/useToast";
import type { import type {
UserInteraction, UserInteraction,
@@ -43,7 +53,7 @@ declare module "react" {
} }
} }
type FormState = { type RdpCredentialsForm = {
username: string; username: string;
password: string; password: string;
domain: string; domain: string;
@@ -52,6 +62,23 @@ type FormState = {
enableClipboard: boolean; 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 => { const isIronError = (error: unknown): error is IronError => {
return ( return (
typeof error === "object" && typeof error === "object" &&
@@ -73,21 +100,18 @@ export default function RdpClient({
const t = useTranslations(); const t = useTranslations();
const STORAGE_KEY = "pangolin_rdp_credentials"; const STORAGE_KEY = "pangolin_rdp_credentials";
const [form, setForm] = useState<FormState>(() => { const formSchema = z.object({
try { username: z.string().min(1, { message: t("usernameRequired") }),
const saved = localStorage.getItem(STORAGE_KEY); password: z.string().min(1, { message: t("passwordRequired") }),
if (saved) return JSON.parse(saved) as FormState; domain: z.string(),
} catch { kdcProxyUrl: z.string(),
// ignore pcb: z.string(),
} enableClipboard: z.boolean()
return { });
username: "",
password: "", const form = useForm<RdpCredentialsForm>({
domain: "", resolver: zodResolver(formSchema),
kdcProxyUrl: "", defaultValues: loadStoredCredentials(STORAGE_KEY)
pcb: "",
enableClipboard: true
};
}); });
const [showLogin, setShowLogin] = useState(true); const [showLogin, setShowLogin] = useState(true);
@@ -167,12 +191,7 @@ export default function RdpClient({
el.addEventListener("ready", onReady); el.addEventListener("ready", onReady);
}; };
const update = <K extends keyof FormState>(key: K, value: FormState[K]) => { const startSession = async (values: RdpCredentialsForm) => {
setForm((prev) => ({ ...prev, [key]: value }));
};
const startSession = async () => {
setSubmitError(null);
setConnecting(true); setConnecting(true);
const userInteraction = userInteractionRef.current; const userInteraction = userInteractionRef.current;
const exts = extensionsRef.current; const exts = extensionsRef.current;
@@ -182,7 +201,7 @@ export default function RdpClient({
return; return;
} }
userInteraction.setEnableClipboard(form.enableClipboard); userInteraction.setEnableClipboard(values.enableClipboard);
// Dispose any previous session's provider and create a fresh one so // Dispose any previous session's provider and create a fresh one so
// there is no stale upload state from a prior connection. // there is no stale upload state from a prior connection.
@@ -248,13 +267,13 @@ export default function RdpClient({
const builder = userInteraction const builder = userInteraction
.configBuilder() .configBuilder()
.withUsername(form.username) .withUsername(values.username)
.withPassword(form.password) .withPassword(values.password)
.withDestination(destination) .withDestination(destination)
.withProxyAddress( .withProxyAddress(
`${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/gateway/rdp` `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/gateway/rdp`
) )
.withServerDomain(form.domain) .withServerDomain(values.domain)
.withAuthToken(target.authToken) .withAuthToken(target.authToken)
.withDesktopSize({ .withDesktopSize({
width: window.innerWidth, width: window.innerWidth,
@@ -262,18 +281,18 @@ export default function RdpClient({
}) })
.withExtension(exts.displayControl(true)); .withExtension(exts.displayControl(true));
if (form.pcb !== "") { if (values.pcb !== "") {
builder.withExtension(exts.preConnectionBlob(form.pcb)); builder.withExtension(exts.preConnectionBlob(values.pcb));
} }
if (form.kdcProxyUrl !== "") { if (values.kdcProxyUrl !== "") {
builder.withExtension(exts.kdcProxyUrl(form.kdcProxyUrl)); builder.withExtension(exts.kdcProxyUrl(values.kdcProxyUrl));
} }
try { try {
const sessionInfo = await userInteraction.connect(builder.build()); const sessionInfo = await userInteraction.connect(builder.build());
try { try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(form)); localStorage.setItem(STORAGE_KEY, JSON.stringify(values));
} catch { } catch {
// ignore // ignore
} }
@@ -296,6 +315,11 @@ export default function RdpClient({
} }
}; };
const onSubmit = (values: RdpCredentialsForm) => {
setSubmitError(null);
startSession(values);
};
const ui = () => userInteractionRef.current; const ui = () => userInteractionRef.current;
const toggleCursorKind = () => { const toggleCursorKind = () => {
@@ -340,87 +364,76 @@ export default function RdpClient({
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="space-y-4"> <Form {...form}>
<Field label={t("domain")} id="domain"> <form
<Input onSubmit={form.handleSubmit(onSubmit)}
id="domain" className="space-y-4"
value={form.domain}
onChange={(e) =>
update("domain", e.target.value)
}
/>
</Field>
<Field label={t("username")} id="username">
<Input
id="username"
value={form.username}
onChange={(e) =>
update("username", e.target.value)
}
/>
</Field>
<Field label={t("password")} id="password">
<Input
id="password"
type="password"
value={form.password}
onChange={(e) =>
update("password", e.target.value)
}
/>
</Field>
{/*
<Field label="Pre Connection Blob (optional)" id="pcb">
<Input
id="pcb"
value={form.pcb}
onChange={(e) => update("pcb", e.target.value)}
/>
</Field> */}
{/* <Field
label="KDC Proxy URL (optional)"
id="kdcProxyUrl"
>
<Input
id="kdcProxyUrl"
value={form.kdcProxyUrl}
onChange={(e) =>
update("kdcProxyUrl", e.target.value)
}
/>
</Field> */}
{/* <div className="flex items-center gap-2">
<Checkbox
id="enable_clipboard"
checked={form.enableClipboard}
onCheckedChange={(checked) =>
update("enableClipboard", checked === true)
}
/>
<Label htmlFor="enable_clipboard">
Enable Clipboard
</Label>
</div> */}
{submitError && (
<Alert variant="destructive">
<AlertDescription>
{submitError}
</AlertDescription>
</Alert>
)}
<Button
onClick={startSession}
disabled={!moduleReady}
loading={connecting}
className="w-full"
> >
{moduleReady <FormField
? t("browserGatewayConnect") control={form.control}
: t("rdpLoadingModule")} name="domain"
</Button> render={({ field }) => (
</div> <FormItem>
<FormLabel>
{t("domain")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("username")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("password")}
</FormLabel>
<FormControl>
<Input
type="password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
disabled={!moduleReady || connecting}
loading={connecting}
className="w-full"
>
{t("browserGatewayConnect")}
</Button>
{submitError && (
<Alert variant="destructive">
<AlertDescription>
{submitError}
</AlertDescription>
</Alert>
)}
</form>
</Form>
</CardContent> </CardContent>
</Card> </Card>
</BrandedAuthSurface> </BrandedAuthSurface>
@@ -539,20 +552,3 @@ export default function RdpClient({
</> </>
); );
} }
function Field({
label,
id,
children
}: {
label: string;
id: string;
children: React.ReactNode;
}) {
return (
<div className="space-y-1.5">
<Label htmlFor={id}>{label}</Label>
{children}
</div>
);
}
+237 -200
View File
@@ -2,10 +2,19 @@
import "@xterm/xterm/css/xterm.css"; import "@xterm/xterm/css/xterm.css";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button"; import { useForm } from "react-hook-form";
import { Input } from "@/components/ui/input"; import * as z from "zod";
import { Label } from "@/components/ui/label"; import { Button } from "@app/components/ui/button";
import { Textarea } from "@/components/ui/textarea"; 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 { GetBrowserTargetResponse } from "@server/routers/browserGatewayTarget";
import { import {
Card, Card,
@@ -16,7 +25,7 @@ import {
} from "@app/components/ui/card"; } from "@app/components/ui/card";
import Link from "next/link"; import Link from "next/link";
import { ExternalLink, Loader2 } from "lucide-react"; 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 { HorizontalTabs } from "@app/components/HorizontalTabs";
import type { SignSshKeyResponse } from "@server/routers/ssh/types"; import type { SignSshKeyResponse } from "@server/routers/ssh/types";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
@@ -25,7 +34,7 @@ import PoweredByPangolin from "@app/components/PoweredByPangolin";
type AuthTab = "password" | "privateKey"; type AuthTab = "password" | "privateKey";
type FormState = { type SshCredentialsForm = {
username: string; username: string;
password: string; password: string;
privateKey: string; privateKey: string;
@@ -38,6 +47,16 @@ type ConnectCredentials = {
certificate?: string; 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({ export default function SshClient({
target, target,
error, error,
@@ -52,18 +71,21 @@ export default function SshClient({
primaryColor?: string | null; primaryColor?: string | null;
}) { }) {
const STORAGE_KEY = "pangolin_ssh_credentials"; const STORAGE_KEY = "pangolin_ssh_credentials";
const t = useTranslations();
const [form, setForm] = useState<FormState>(() => { const passwordTabSchema = z.object({
try { username: z.string().min(1, { message: t("usernameRequired") }),
const saved = localStorage.getItem(STORAGE_KEY); password: z.string().min(1, { message: t("passwordRequired") })
if (saved) return JSON.parse(saved) as FormState;
} catch {
// ignore
}
return { username: "", password: "", privateKey: "" };
}); });
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<SshCredentialsForm>({
defaultValues: loadStoredCredentials(STORAGE_KEY)
});
function handleKeyFile(e: React.ChangeEvent<HTMLInputElement>) { function handleKeyFile(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
@@ -72,11 +94,10 @@ export default function SshClient({
reader.onload = (ev) => { reader.onload = (ev) => {
const text = ev.target?.result; const text = ev.target?.result;
if (typeof text === "string") { if (typeof text === "string") {
setForm((prev) => ({ ...prev, privateKey: text })); form.setValue("privateKey", text, { shouldDirty: true });
} }
}; };
reader.readAsText(file); reader.readAsText(file);
// Reset input so the same file can be re-selected if needed.
e.target.value = ""; e.target.value = "";
} }
@@ -128,14 +149,12 @@ export default function SshClient({
xtermRef.current = terminal; xtermRef.current = terminal;
fitAddonRef.current = fitAddon; fitAddonRef.current = fitAddon;
// Send user keystrokes to the WebSocket.
terminal.onData((data) => { terminal.onData((data) => {
if (wsRef.current?.readyState === WebSocket.OPEN) { if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: "data", data })); wsRef.current.send(JSON.stringify({ type: "data", data }));
} }
}); });
// Send resize events.
terminal.onResize(({ cols, rows }) => { terminal.onResize(({ cols, rows }) => {
if (wsRef.current?.readyState === WebSocket.OPEN) { if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send( wsRef.current.send(
@@ -144,7 +163,6 @@ export default function SshClient({
} }
}); });
// Send the initial size once the terminal is rendered.
const { cols, rows } = terminal; const { cols, rows } = terminal;
if (wsRef.current?.readyState === WebSocket.OPEN) { if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send( wsRef.current.send(
@@ -158,14 +176,12 @@ export default function SshClient({
}; };
}, [connected]); }, [connected]);
// Refit terminal when the window resizes.
useEffect(() => { useEffect(() => {
const onResize = () => fitAddonRef.current?.fit(); const onResize = () => fitAddonRef.current?.fit();
window.addEventListener("resize", onResize); window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize); return () => window.removeEventListener("resize", onResize);
}, []); }, []);
// Cleanup on unmount.
useEffect(() => { useEffect(() => {
return () => { return () => {
wsRef.current?.close(); wsRef.current?.close();
@@ -173,7 +189,6 @@ export default function SshClient({
}; };
}, []); }, []);
// Auto-connect when signed key data is provided (push PAM mode).
useEffect(() => { useEffect(() => {
if (signedKeyData && signedPrivateKey && target) { if (signedKeyData && signedPrivateKey && target) {
connect({ connect({
@@ -188,7 +203,6 @@ export default function SshClient({
override?: ConnectCredentials, override?: ConnectCredentials,
authMethod: AuthTab = "password" authMethod: AuthTab = "password"
) { ) {
setConnectError(null);
setConnecting(true); setConnecting(true);
if (!target) { if (!target) {
@@ -197,13 +211,14 @@ export default function SshClient({
return; return;
} }
const username = override?.username ?? form.username; const values = form.getValues();
const username = override?.username ?? values.username;
const password = const password =
override?.password ?? override?.password ??
(authMethod === "password" ? form.password : ""); (authMethod === "password" ? values.password : "");
const privateKey = const privateKey =
override?.privateKey ?? override?.privateKey ??
(authMethod === "privateKey" ? form.privateKey : ""); (authMethod === "privateKey" ? values.privateKey : "");
const certificate = override?.certificate; const certificate = override?.certificate;
const proxyAddress = `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/gateway/ssh`; 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"]); const ws = new WebSocket(url.toString(), ["ssh"]);
wsRef.current = ws; 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 authConfirmed = false;
let authErrorShown = false; let authErrorShown = false;
ws.onopen = () => { 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( ws.send(
JSON.stringify({ JSON.stringify({
type: "auth", type: "auth",
@@ -242,7 +251,10 @@ export default function SshClient({
); );
if (!override) { if (!override) {
try { try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(form)); localStorage.setItem(
STORAGE_KEY,
JSON.stringify(form.getValues())
);
} catch { } catch {
// ignore // ignore
} }
@@ -266,7 +278,6 @@ export default function SshClient({
xtermRef.current?.write(msg.data); xtermRef.current?.write(msg.data);
} else if (msg.type === "error") { } else if (msg.type === "error") {
if (!authConfirmed) { if (!authConfirmed) {
// Auth-phase error - show in the login form.
authErrorShown = true; authErrorShown = true;
setConnecting(false); setConnecting(false);
setConnectError( setConnectError(
@@ -312,8 +323,6 @@ export default function SshClient({
`\r\n\x1b[33m${t("sshConnectionClosedCode", { code: evt.code })}\x1b[0m\r\n` `\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) { if (!authConfirmed && !authErrorShown) {
setConnectError(t("sshErrorConnectionClosed")); setConnectError(t("sshErrorConnectionClosed"));
} }
@@ -327,7 +336,40 @@ export default function SshClient({
setConnected(false); setConnected(false);
} }
// In push mode, show a connecting/connected state without the login form. function applyTabSchemaErrors(
schema: z.ZodObject<z.ZodRawShape>,
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) { if (signedKeyData && signedPrivateKey) {
return ( return (
<> <>
@@ -352,7 +394,10 @@ export default function SshClient({
</div> </div>
)} )}
{connectError && ( {connectError && (
<Alert variant="destructive" className="w-full"> <Alert
variant="destructive"
className="w-full"
>
<AlertDescription> <AlertDescription>
{connectError} {connectError}
</AlertDescription> </AlertDescription>
@@ -406,155 +451,164 @@ export default function SshClient({
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<HorizontalTabs <Form {...form}>
clientSide <HorizontalTabs
defaultTab={0} clientSide
items={[ defaultTab={0}
{ title: t("sshPasswordTab"), href: "#" }, items={[
{ title: t("sshPrivateKeyTab"), href: "#" } {
]} title: t("sshPasswordTab"),
> href: "#"
<div className="space-y-4 mt-4 p-1"> },
<Field {
label={t("username")} title: t("sshPrivateKeyTab"),
id="username-pw" href: "#"
}
]}
>
<form
onSubmit={onPasswordSubmit}
className="space-y-4 mt-4 p-1"
> >
<Input <FormField
id="username-pw" control={form.control}
value={form.username} name="username"
onChange={(e) => render={({ field }) => (
setForm({ <FormItem>
...form, <FormLabel>
username: e.target.value {t("username")}
}) </FormLabel>
} <FormControl>
/> <Input {...field} />
</Field> </FormControl>
<Field label={t("password")} id="password"> <FormMessage />
<Input </FormItem>
id="password"
type="password"
value={form.password}
onChange={(e) =>
setForm({
...form,
password: e.target.value
})
}
/>
</Field>
<div className="mt-4 space-y-3">
{connectError && (
<Alert variant="destructive">
<AlertDescription>
{connectError}
</AlertDescription>
</Alert>
)}
<Button
onClick={() =>
connect(undefined, "password")
}
loading={connecting}
disabled={
!form.username || !form.password
}
className="w-full"
>
{connecting
? t("sshConnecting")
: t("sshAuthenticate")}
</Button>
</div>
</div>
<div className="space-y-4 mt-4 p-1">
<p className="text-sm text-muted-foreground">
{t("sshPrivateKeyDisclaimer")}{" "}
<Link
href="https://docs.pangolin.net/"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-1"
>
{t("sshLearnMore")}
<ExternalLink className="size-3.5 shrink-0" />
</Link>
</p>
<Field
label={t("username")}
id="username-key"
>
<Input
id="username-key"
value={form.username}
onChange={(e) =>
setForm({
...form,
username: e.target.value
})
}
/>
</Field>
<Field
label={t("sshPrivateKeyField")}
id="privateKey"
>
<Textarea
id="privateKey"
value={form.privateKey}
onChange={(e) =>
setForm({
...form,
privateKey: e.target.value
})
}
placeholder={t(
"sshPrivateKeyPlaceholder"
)} )}
rows={5}
className="font-mono text-xs"
/> />
</Field> <FormField
<Field control={form.control}
label={t("sshPrivateKeyFile")} name="password"
id="privateKeyFile" render={({ field }) => (
> <FormItem>
<Input <FormLabel>
id="privateKeyFile" {t("password")}
type="file" </FormLabel>
accept=".pem,.key,.pub,*" <FormControl>
onChange={handleKeyFile} <Input
type="password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/> />
</Field> <div className="mt-4 space-y-3">
<div className="mt-4 space-y-3"> <Button
{connectError && ( type="submit"
<Alert variant="destructive"> loading={connecting}
<AlertDescription> disabled={connecting}
{connectError} className="w-full"
</AlertDescription> >
</Alert> {t("sshAuthenticate")}
)} </Button>
{connectError && (
<Alert variant="destructive">
<AlertDescription>
{connectError}
</AlertDescription>
</Alert>
)}
</div>
</form>
<Button <form
onClick={() => onSubmit={onPrivateKeySubmit}
connect(undefined, "privateKey") className="space-y-4 mt-4 p-1"
} >
loading={connecting} <p className="text-sm text-muted-foreground">
disabled={ {t("sshPrivateKeyDisclaimer")}{" "}
!form.username || <Link
!form.privateKey href="https://docs.pangolin.net/"
} target="_blank"
className="w-full" rel="noopener noreferrer"
> className="text-primary hover:underline inline-flex items-center gap-1"
{connecting >
? t("sshConnecting") {t("sshLearnMore")}
: t("sshAuthenticate")} <ExternalLink className="size-3.5 shrink-0" />
</Button> </Link>
</div> </p>
</div> <FormField
</HorizontalTabs> control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("username")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="privateKey"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"sshPrivateKeyField"
)}
</FormLabel>
<FormControl>
<Textarea
{...field}
placeholder={t(
"sshPrivateKeyPlaceholder"
)}
rows={5}
className="font-mono text-xs"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormItem>
<FormLabel>
{t("sshPrivateKeyFile")}
</FormLabel>
<FormControl>
<Input
type="file"
accept=".pem,.key,.pub,*"
onChange={handleKeyFile}
/>
</FormControl>
</FormItem>
<div className="mt-4 space-y-3">
<Button
type="submit"
loading={connecting}
disabled={connecting}
className="w-full"
>
{t("sshAuthenticate")}
</Button>
{connectError && (
<Alert variant="destructive">
<AlertDescription>
{connectError}
</AlertDescription>
</Alert>
)}
</div>
</form>
</HorizontalTabs>
</Form>
</CardContent> </CardContent>
</Card> </Card>
</BrandedAuthSurface> </BrandedAuthSurface>
@@ -581,20 +635,3 @@ export default function SshClient({
</> </>
); );
} }
function Field({
label,
id,
children
}: {
label: string;
id: string;
children: React.ReactNode;
}) {
return (
<div className="space-y-1.5">
<Label htmlFor={id}>{label}</Label>
{children}
</div>
);
}
+73 -73
View File
@@ -1,9 +1,19 @@
"use client"; "use client";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button"; import { useForm } from "react-hook-form";
import { Input } from "@/components/ui/input"; import { zodResolver } from "@hookform/resolvers/zod";
import { Label } from "@/components/ui/label"; 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 { toast } from "@app/hooks/useToast";
import { GetBrowserTargetResponse } from "@server/routers/browserGatewayTarget"; import { GetBrowserTargetResponse } from "@server/routers/browserGatewayTarget";
import { import {
@@ -18,10 +28,20 @@ import BrandedAuthSurface from "@app/components/BrandedAuthSurface";
import PoweredByPangolin from "@app/components/PoweredByPangolin"; import PoweredByPangolin from "@app/components/PoweredByPangolin";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
type FormState = { type VncCredentialsForm = {
password: string; 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({ export default function VncClient({
target, target,
error, error,
@@ -34,14 +54,13 @@ export default function VncClient({
const t = useTranslations(); const t = useTranslations();
const STORAGE_KEY = "pangolin_vnc_credentials"; const STORAGE_KEY = "pangolin_vnc_credentials";
const [form, setForm] = useState<FormState>(() => { const formSchema = z.object({
try { password: z.string()
const saved = localStorage.getItem(STORAGE_KEY); });
if (saved) return JSON.parse(saved) as FormState;
} catch { const form = useForm<VncCredentialsForm>({
// ignore resolver: zodResolver(formSchema),
} defaultValues: loadStoredCredentials(STORAGE_KEY)
return { password: "" };
}); });
const [connected, setConnected] = useState(false); const [connected, setConnected] = useState(false);
@@ -49,11 +68,6 @@ export default function VncClient({
const rfbRef = useRef<any>(null); const rfbRef = useRef<any>(null);
const screenRef = useRef<HTMLDivElement>(null); const screenRef = useRef<HTMLDivElement>(null);
const update = <K extends keyof FormState>(key: K, value: FormState[K]) => {
setForm((prev) => ({ ...prev, [key]: value }));
};
// Disconnect and clean up the RFB instance.
const disconnect = () => { const disconnect = () => {
if (rfbRef.current) { if (rfbRef.current) {
rfbRef.current.disconnect(); rfbRef.current.disconnect();
@@ -62,14 +76,11 @@ export default function VncClient({
setConnected(false); setConnected(false);
}; };
// Clean up on unmount.
useEffect(() => { useEffect(() => {
return () => disconnect(); return () => disconnect();
}, []); }, []);
const connect = async () => { const connect = async (values: VncCredentialsForm) => {
setConnectError(null);
if (!target) { if (!target) {
setConnectError(t("vncNoResourceTarget")); setConnectError(t("vncNoResourceTarget"));
return; return;
@@ -77,11 +88,8 @@ export default function VncClient({
if (!screenRef.current) return; if (!screenRef.current) return;
// Disconnect any existing session first.
disconnect(); disconnect();
// noVNC has no ESM default export — import the module dynamically to
// keep it out of the server bundle, then grab the default export.
let RFB: new ( let RFB: new (
target: HTMLElement, target: HTMLElement,
url: string, url: string,
@@ -100,8 +108,6 @@ export default function VncClient({
return; return;
} }
// Build the proxy WebSocket URL:
// ws://<proxyAddress>?authToken=<token>&host=<ip>&port=<port>
const proxyAddress = `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/gateway/vnc`; const proxyAddress = `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/gateway/vnc`;
const base = proxyAddress.replace(/\/$/, ""); const base = proxyAddress.replace(/\/$/, "");
const params = new URLSearchParams({ const params = new URLSearchParams({
@@ -111,12 +117,11 @@ export default function VncClient({
}); });
const wsUrl = `${base}?${params.toString()}`; const wsUrl = `${base}?${params.toString()}`;
// Clear the container so noVNC gets a clean mount point.
screenRef.current.innerHTML = ""; screenRef.current.innerHTML = "";
const options: Record<string, unknown> = {}; const options: Record<string, unknown> = {};
if (form.password) { if (values.password) {
options.credentials = { password: form.password }; options.credentials = { password: values.password };
} }
const rfb: any = new RFB(screenRef.current, wsUrl, options); const rfb: any = new RFB(screenRef.current, wsUrl, options);
@@ -126,7 +131,7 @@ export default function VncClient({
rfb.addEventListener("connect", () => { rfb.addEventListener("connect", () => {
try { try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(form)); localStorage.setItem(STORAGE_KEY, JSON.stringify(values));
} catch { } catch {
// ignore // ignore
} }
@@ -157,6 +162,11 @@ export default function VncClient({
rfbRef.current = rfb; rfbRef.current = rfb;
}; };
const onSubmit = (values: VncCredentialsForm) => {
setConnectError(null);
connect(values);
};
if (error) { if (error) {
return ( return (
<BrandedAuthSurface primaryColor={primaryColor}> <BrandedAuthSurface primaryColor={primaryColor}>
@@ -188,33 +198,41 @@ export default function VncClient({
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="space-y-4"> <Form {...form}>
<Field <form
label={t("vncPasswordOptional")} onSubmit={form.handleSubmit(onSubmit)}
id="password" className="space-y-4"
> >
<Input <FormField
id="password" control={form.control}
type="password" name="password"
value={form.password} render={({ field }) => (
onChange={(e) => <FormItem>
update("password", e.target.value) <FormLabel>
} {t("vncPasswordOptional")}
</FormLabel>
<FormControl>
<Input
type="password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/> />
</Field> <Button type="submit" className="w-full">
{t("browserGatewayConnect")}
{connectError && ( </Button>
<Alert variant="destructive"> {connectError && (
<AlertDescription> <Alert variant="destructive">
{connectError} <AlertDescription>
</AlertDescription> {connectError}
</Alert> </AlertDescription>
)} </Alert>
)}
<Button onClick={connect} className="w-full"> </form>
{t("browserGatewayConnect")} </Form>
</Button>
</div>
</CardContent> </CardContent>
</Card> </Card>
</BrandedAuthSurface> </BrandedAuthSurface>
@@ -259,7 +277,6 @@ export default function VncClient({
</Button> </Button>
</div> </div>
{/* noVNC mounts a <canvas> inside this div */}
<div <div
ref={screenRef} ref={screenRef}
className="flex-1 overflow-hidden" className="flex-1 overflow-hidden"
@@ -269,20 +286,3 @@ export default function VncClient({
</> </>
); );
} }
function Field({
label,
id,
children
}: {
label: string;
id: string;
children: React.ReactNode;
}) {
return (
<div className="space-y-1.5">
<Label htmlFor={id}>{label}</Label>
{children}
</div>
);
}