Merge branch 'dev' into refactor/standardize-clear-buttons

This commit is contained in:
Fred KISSIE
2026-06-05 20:21:34 +02:00
80 changed files with 4385 additions and 1973 deletions

View File

@@ -103,7 +103,7 @@ export function ProxyResourceTargetsForm({
// Notify parent of changes (create mode)
useEffect(() => {
onChange?.(targets);
}, [targets]); // eslint-disable-line react-hooks/exhaustive-deps
}, [targets]);
// Poll health status only in edit mode
const { data: polledTargets } = useQuery({
@@ -334,19 +334,15 @@ export function ProxyResourceTargetsForm({
{row.original.siteType === "newt" ? (
<Button
variant="outline"
className="flex items-center gap-2 w-full text-left cursor-pointer"
className="flex items-center space-x-2 w-full text-left cursor-pointer"
onClick={() =>
openHealthCheckDialog(row.original)
}
>
<div
className={`flex items-center gap-2 ${status === "healthy" ? "text-green-500" : status === "unhealthy" ? "text-destructive" : "text-neutral-500"}`}
>
<div
className={`w-2 h-2 rounded-full ${status === "healthy" ? "bg-green-500" : status === "unhealthy" ? "bg-destructive" : "bg-neutral-500"}`}
></div>
{getStatusText(status)}
</div>
className={`w-2 h-2 rounded-full ${status === "healthy" ? "bg-green-500" : status === "unhealthy" ? "bg-destructive" : "bg-neutral-500"}`}
></div>
<span>{getStatusText(status)}</span>
</Button>
) : (
<span>-</span>
@@ -535,7 +531,7 @@ export function ProxyResourceTargetsForm({
accessorKey: "enabled",
header: () => <span className="p-3">{t("enabled")}</span>,
cell: ({ row }) => (
<div className="flex items-center justify-center w-full">
<div className="flex items-center w-full">
<Switch
defaultChecked={row.original.enabled}
onCheckedChange={(val) =>
@@ -554,9 +550,8 @@ export function ProxyResourceTargetsForm({
const actionsColumn: ColumnDef<LocalTarget> = {
id: "actions",
header: () => <span className="p-3">{t("actions")}</span>,
cell: ({ row }) => (
<div className="flex items-center w-full">
<div className="flex items-center justify-end w-full">
<Button
variant="outline"
onClick={() => removeTarget(row.original.targetId)}

View File

@@ -7,7 +7,10 @@ import {
SettingsSectionDescription,
SettingsSectionForm,
SettingsSectionHeader,
SettingsSectionTitle
SettingsSectionTitle,
SettingsSubsectionDescription,
SettingsSubsectionHeader,
SettingsSubsectionTitle
} from "@app/components/Settings";
import { StrategySelect, StrategyOption } from "@app/components/StrategySelect";
import { BrowserGatewayTargetForm } from "@app/components/BrowserGatewayTargetForm";
@@ -386,9 +389,9 @@ function SshServerForm({
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<div className="space-y-3">
<p className="text-sm font-semibold">
<SettingsSubsectionTitle>
{t("sshServerMode")}
</p>
</SettingsSubsectionTitle>
<Badge variant="secondary">
{sshServerMode == "standard"
? t("sshServerModeStandard")
@@ -397,9 +400,9 @@ function SshServerForm({
</div>
<div className="space-y-3">
<p className="text-sm font-semibold">
<SettingsSubsectionTitle>
{t("sshAuthenticationMethod")}
</p>
</SettingsSubsectionTitle>
<StrategySelect<"passthrough" | "push">
value={pamMode}
options={authMethodOptions}
@@ -410,9 +413,9 @@ function SshServerForm({
{showDaemonLocation && (
<div className="space-y-3">
<p className="text-sm font-semibold">
<SettingsSubsectionTitle>
{t("sshAuthDaemonLocation")}
</p>
</SettingsSubsectionTitle>
<StrategySelect<"site" | "remote">
value={standardDaemonLocation}
options={daemonLocationOptions}
@@ -460,14 +463,14 @@ function SshServerForm({
)}
<div className="space-y-3">
<div>
<h2 className="text-1xl font-semibold tracking-tight flex items-center gap-2">
<SettingsSubsectionHeader>
<SettingsSubsectionTitle>
{t("sshServerDestination")}
</h2>
<p className="text-sm text-muted-foreground">
</SettingsSubsectionTitle>
<SettingsSubsectionDescription>
{t("sshServerDestinationDescription")}
</p>
</div>
</SettingsSubsectionDescription>
</SettingsSubsectionHeader>
{isNative ? (
<Popover
open={nativeSiteOpen}

View File

@@ -2,13 +2,6 @@
import CopyTextBox from "@app/components/CopyTextBox";
import DomainPicker from "@app/components/DomainPicker";
import HealthCheckCredenza from "@app/components/HealthCheckCredenza";
import {
PathMatchDisplay,
PathMatchModal,
PathRewriteDisplay,
PathRewriteModal
} from "@app/components/PathMatchRenameModal";
import {
SettingsContainer,
SettingsSection,
@@ -16,7 +9,10 @@ import {
SettingsSectionDescription,
SettingsSectionForm,
SettingsSectionHeader,
SettingsSectionTitle
SettingsSectionTitle,
SettingsSubsectionDescription,
SettingsSubsectionHeader,
SettingsSubsectionTitle
} from "@app/components/Settings";
import HeaderTitle from "@app/components/SettingsSectionTitle";
import {
@@ -48,29 +44,6 @@ import {
PopoverContent,
PopoverTrigger
} from "@app/components/ui/popover";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@app/components/ui/select";
import { Switch } from "@app/components/ui/switch";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from "@app/components/ui/table";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from "@app/components/ui/tooltip";
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { toast } from "@app/hooks/useToast";
@@ -84,32 +57,16 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { build } from "@server/build";
import { Resource } from "@server/db";
import { isTargetValid } from "@server/lib/validators";
import { ListTargetsResponse } from "@server/routers/target";
import { ListRemoteExitNodesResponse } from "@server/routers/remoteExitNode/types";
import { ArrayElement } from "@server/types/ArrayElement";
import { useQuery } from "@tanstack/react-query";
import {
LocalTarget,
ProxyResourceTargetsForm
} from "@app/app/[orgId]/settings/resources/public/ProxyResourceTargetsForm";
import {
ColumnDef,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable
} from "@tanstack/react-table";
import { AxiosResponse } from "axios";
import {
ChevronsUpDown,
CircleCheck,
CircleX,
ExternalLink,
Info,
Plus,
Settings,
SquareArrowOutUpRight
} from "lucide-react";
import { useTranslations } from "next-intl";
@@ -119,13 +76,11 @@ import { toASCII } from "punycode";
import {
useMemo,
useState,
useCallback,
useTransition,
useEffect
} from "react";
import { Controller, useForm } from "react-hook-form";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { cn } from "@app/lib/cn";
const baseResourceFormSchema = z.object({
name: z.string().min(1).max(255),
@@ -327,14 +282,25 @@ export default function Page() {
const rawResourcesAllowed =
env.flags.allowRawResources &&
(build !== "saas" || remoteExitNodes.length > 0);
const enterpriseModesAllowed =
!env.flags.disableEnterpriseFeatures;
const availableTypes = useMemo((): NewResourceType[] => {
const base: NewResourceType[] = ["http", "ssh", "rdp", "vnc"];
const base: NewResourceType[] = ["http"];
if (enterpriseModesAllowed) {
base.push("ssh", "rdp", "vnc");
}
if (rawResourcesAllowed) {
base.push("tcp", "udp");
}
return base;
}, [rawResourcesAllowed]);
}, [enterpriseModesAllowed, rawResourcesAllowed]);
useEffect(() => {
if (!availableTypes.includes(resourceType)) {
setResourceType("http");
}
}, [availableTypes, resourceType]);
const baseForm = useForm({
resolver: zodResolver(baseResourceFormSchema),
@@ -686,19 +652,25 @@ export default function Page() {
}
];
const typeLabels: Record<NewResourceType, string> = {
let typeLabels: Partial<Record<NewResourceType, string>> = {
http: "HTTP",
ssh: "SSH",
rdp: "RDP",
vnc: "VNC",
tcp: "TCP",
udp: "UDP"
};
if (enterpriseModesAllowed) {
typeLabels = {
...typeLabels,
ssh: "SSH",
rdp: "RDP",
vnc: "VNC",
}
}
const typeOptions: OptionSelectOption<NewResourceType>[] =
availableTypes.map((type) => ({
value: type,
label: typeLabels[type]
label: typeLabels[type] ?? type.toUpperCase()
}));
return (
@@ -742,7 +714,7 @@ export default function Page() {
e.preventDefault();
}
}}
className="space-y-4"
className="grid gap-4 grid-cols-1 md:grid-cols-2 items-start"
id="base-resource-form"
>
<FormField
@@ -825,7 +797,7 @@ export default function Page() {
e.preventDefault();
}
}}
className="space-y-4"
className="grid gap-4 grid-cols-1 md:grid-cols-2 items-start"
id="tcp-udp-settings-form"
>
<FormField
@@ -910,10 +882,10 @@ export default function Page() {
<SettingsSectionBody>
<SettingsSectionForm variant="half">
{/* Mode */}
<div className="space-y-3">
<p className="text-sm font-semibold">
<div className="space-y-2">
<SettingsSubsectionTitle>
{t("sshServerMode")}
</p>
</SettingsSubsectionTitle>
<StrategySelect<
"standard" | "native"
>
@@ -924,12 +896,12 @@ export default function Page() {
/>
</div>
<div className="space-y-3">
<p className="text-sm font-semibold">
<div className="space-y-2">
<SettingsSubsectionTitle>
{t(
"sshAuthenticationMethod"
)}
</p>
</SettingsSubsectionTitle>
<StrategySelect<
"passthrough" | "push"
>
@@ -944,12 +916,12 @@ export default function Page() {
{/* Daemon Location (standard + push) */}
{showDaemonLocation && (
<div className="space-y-3">
<p className="text-sm font-semibold">
<div className="space-y-2">
<SettingsSubsectionTitle>
{t(
"sshAuthDaemonLocation"
)}
</p>
</SettingsSubsectionTitle>
<StrategySelect<
"site" | "remote"
>
@@ -984,49 +956,55 @@ export default function Page() {
{/* Daemon Port (standard + push + remote) */}
{showDaemonPort && (
<Form {...sshDaemonPortForm}>
<FormField
control={
sshDaemonPortForm.control
}
name="authDaemonPort"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"sshDaemonPort"
)}
</FormLabel>
<FormControl>
<Input
type="number"
min={1}
max={
65535
}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="w-full md:w-1/2">
<FormField
control={
sshDaemonPortForm.control
}
name="authDaemonPort"
render={({
field
}) => (
<FormItem>
<FormLabel>
{t(
"sshDaemonPort"
)}
</FormLabel>
<FormControl>
<Input
type="number"
min={
1
}
max={
65535
}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</Form>
)}
{/* Server Destination */}
<div className="space-y-3">
<div>
<h2 className="text-sm font-semibold">
<SettingsSubsectionHeader>
<SettingsSubsectionTitle>
{t(
"sshServerDestination"
)}
</h2>
<p className="text-sm text-muted-foreground">
</SettingsSubsectionTitle>
<SettingsSubsectionDescription>
{t(
"sshServerDestinationDescription"
)}
</p>
</div>
</SettingsSubsectionDescription>
</SettingsSubsectionHeader>
{isNative ? (
<Popover
open={nativeSiteOpen}
@@ -1038,7 +1016,7 @@ export default function Page() {
<Button
variant="outline"
role="combobox"
className="w-full max-w-xs justify-between font-normal"
className="w-full md:w-1/2 justify-between font-normal"
>
<span className="truncate">
{nativeSelectedSite?.name ??

View File

@@ -256,7 +256,7 @@ export default function GeneralPage() {
return (
<FormItem>
<FormControl>
<div className="flex items-center gap-3">
<div className="">
<SwitchInput
id="auto-update-enabled"
label={t(
@@ -285,7 +285,7 @@ export default function GeneralPage() {
type="button"
variant="link"
size="sm"
className="h-auto p-0 pb-2 text-xs"
className="text-sm text-muted-foreground underline px-0"
onClick={() => {
form.setValue(
"autoUpdateOverrideOrg",

View File

@@ -243,10 +243,8 @@ export default function Page() {
onCheckedChange={(checked) => {
form.setValue("autoProvision", checked);
}}
description={t("idpAutoProvisionConfigureAfterCreate")}
/>
<p className="text-sm text-muted-foreground">
{t("idpAutoProvisionConfigureAfterCreate")}
</p>
</div>
</SettingsSectionBody>
</SettingsSection>

View File

@@ -22,7 +22,7 @@
--accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.985 0 0);
--border: oklch(0.88 0.004 286.32);
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.88 0.004 286.32);
--ring: oklch(0.705 0.213 47.604);
--chart-1: oklch(0.646 0.222 41.116);
@@ -57,7 +57,7 @@
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.5382 0.1949 22.216);
--destructive-foreground: oklch(0.985 0 0);
--border: oklch(1 0 0 / 15%);
--border: oklch(1 0 0 / 8%);
--input: oklch(1 0 0 / 18%);
--ring: oklch(0.646 0.222 41.116);
--chart-1: oklch(0.488 0.243 264.376);

View File

@@ -137,7 +137,7 @@ export const orgNavSections = (
}
]
},
...(build !== "oss"
...(!env?.flags.disableEnterpriseFeatures
? [
{
title: "sidebarPolicies",

View File

@@ -86,7 +86,7 @@ export default async function Page(props: {
targetOrgId = lastOrgCookie;
} else {
let ownedOrg = orgs.find((org) => org.isOwner);
let primaryOrg = orgs.find((org) => org.isPrimaryOrg);
const primaryOrg = orgs.find((org) => org.isPrimaryOrg);
if (!ownedOrg) {
if (primaryOrg) {
ownedOrg = primaryOrg;

View File

@@ -16,9 +16,9 @@ export const metadata: Metadata = {
export default async function MaintenanceScreen() {
const t = await getTranslations();
let title = t("privateMaintenanceScreenTitle");
let message = t("privateMaintenanceScreenMessage");
let steps = t("privateMaintenanceScreenSteps");
const title = t("privateMaintenanceScreenTitle");
const message = t("privateMaintenanceScreenMessage");
const steps = t("privateMaintenanceScreenSteps");
return (
<div className="min-h-screen flex items-center justify-center p-4">

View File

@@ -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,
@@ -22,7 +32,10 @@ import {
CardTitle,
CardDescription
} from "@app/components/ui/card";
import Link from "next/link";
import { Alert, AlertDescription } from "@app/components/ui/alert";
import BrandedAuthSurface from "@app/components/BrandedAuthSurface";
import PoweredByPangolin from "@app/components/PoweredByPangolin";
import { useTranslations } from "next-intl";
declare module "react" {
namespace JSX {
@@ -40,7 +53,7 @@ declare module "react" {
}
}
type FormState = {
type RdpCredentialsForm = {
username: string;
password: string;
domain: string;
@@ -49,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" &&
@@ -60,33 +90,35 @@ const isIronError = (error: unknown): error is IronError => {
export default function RdpClient({
target,
error
error,
primaryColor
}: {
target: GetBrowserTargetResponse | null;
error: string | null;
primaryColor?: string | null;
}) {
const t = useTranslations();
const STORAGE_KEY = "pangolin_rdp_credentials";
const resourceName = target?.name?.trim() || null;
const [form, setForm] = useState<FormState>(() => {
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<RdpCredentialsForm>({
resolver: zodResolver(formSchema),
defaultValues: loadStoredCredentials(STORAGE_KEY)
});
const [showLogin, setShowLogin] = useState(true);
const [moduleReady, setModuleReady] = useState(false);
const [connecting, setConnecting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const [unicodeMode, setUnicodeMode] = useState(false);
const [cursorOverrideActive, setCursorOverrideActive] = useState(false);
@@ -138,7 +170,7 @@ export default function RdpClient({
console.error("Failed to load iron-remote-desktop modules", err);
toast({
variant: "destructive",
title: "Failed to load RDP module",
title: t("rdpFailedToLoadModule"),
description: `${err}`
});
});
@@ -160,25 +192,17 @@ export default function RdpClient({
el.addEventListener("ready", onReady);
};
const update = <K extends keyof FormState>(key: K, value: FormState[K]) => {
setForm((prev) => ({ ...prev, [key]: value }));
};
const startSession = async () => {
const startSession = async (values: RdpCredentialsForm) => {
setConnecting(true);
const userInteraction = userInteractionRef.current;
const exts = extensionsRef.current;
if (!userInteraction || !exts) {
setConnecting(false);
toast({
variant: "destructive",
title: "Not ready",
description: "RDP module is still initializing"
});
setSubmitError(t("rdpModuleInitializing"));
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.
@@ -193,7 +217,9 @@ export default function RdpClient({
const downloadable = files.filter((f) => !f.isDirectory);
if (downloadable.length === 0) return;
toast({
title: `Downloading ${downloadable.length} file(s) from remote…`
title: t("rdpDownloadingFiles", {
count: downloadable.length
})
});
for (let i = 0; i < files.length; i++) {
const file = files[i];
@@ -211,7 +237,9 @@ export default function RdpClient({
.catch((err) => {
toast({
variant: "destructive",
title: `Download failed: ${file.name}`,
title: t("rdpDownloadFailed", {
fileName: file.name
}),
description: `${err}`
});
});
@@ -220,7 +248,7 @@ export default function RdpClient({
// Notify when individual uploads complete (remote pasted a file).
fileTransfer.on("upload-complete", (file: File) => {
toast({ title: `Uploaded: ${file.name}` });
toast({ title: t("rdpUploaded", { fileName: file.name }) });
});
// Register with the web component so CLIPRDR extensions are
@@ -232,11 +260,7 @@ export default function RdpClient({
if (!target) {
setConnecting(false);
toast({
variant: "destructive",
title: "No target",
description: "No connection target available"
});
setSubmitError(t("rdpNoConnectionTarget"));
return;
}
@@ -244,13 +268,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,
@@ -258,18 +282,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
}
@@ -285,21 +309,18 @@ export default function RdpClient({
setConnecting(false);
setShowLogin(true);
if (isIronError(err)) {
toast({
variant: "destructive",
title: "Connection failed",
description: err.backtrace()
});
setSubmitError(err.backtrace());
} else {
toast({
variant: "destructive",
title: "Connection failed",
description: `${err}`
});
setSubmitError(`${err}`);
}
}
};
const onSubmit = (values: RdpCredentialsForm) => {
setSubmitError(null);
startSession(values);
};
const ui = () => userInteractionRef.current;
const toggleCursorKind = () => {
@@ -315,133 +336,114 @@ export default function RdpClient({
if (error) {
return (
<div>
<div className="text-center mb-2">
<span className="text-sm text-muted-foreground">
Powered by{" "}
<Link
href="https://pangolin.net/"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
Pangolin
</Link>
</span>
</div>
<BrandedAuthSurface primaryColor={primaryColor}>
<PoweredByPangolin />
<Card className="w-full">
<CardHeader>
<CardTitle>RDP</CardTitle>
<CardTitle>{t("rdpTitle")}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-destructive text-sm">{error}</p>
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
</CardContent>
</Card>
</div>
</BrandedAuthSurface>
);
}
return (
<>
{showLogin && (
<div>
<div className="text-center mb-2">
<span className="text-sm text-muted-foreground">
Powered by{" "}
<Link
href="https://pangolin.net/"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
Pangolin
</Link>
</span>
</div>
<BrandedAuthSurface primaryColor={primaryColor}>
<PoweredByPangolin />
<Card className="w-full">
<CardHeader>
<CardTitle>Sign in to Remote Desktop</CardTitle>
<CardTitle>
{resourceName
? `${t("rdpSignInTitle")} - ${resourceName}`
: t("rdpSignInTitle")}
</CardTitle>
<CardDescription>
Enter Windows credentials to access xxxx
{resourceName
? `${t("rdpSignInDescription")} (${resourceName})`
: t("rdpSignInDescription")}
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<Field label="Domain" id="domain">
<Input
id="domain"
value={form.domain}
onChange={(e) =>
update("domain", e.target.value)
}
/>
</Field>
<Field label="Username" id="username">
<Input
id="username"
value={form.username}
onChange={(e) =>
update("username", e.target.value)
}
/>
</Field>
<Field label="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> */}
<Button
onClick={startSession}
disabled={!moduleReady}
loading={connecting}
className="w-full"
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
>
{moduleReady
? "Connect"
: "Loading module..."}
</Button>
</div>
<FormField
control={form.control}
name="domain"
render={({ field }) => (
<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>
</Card>
</div>
</BrandedAuthSurface>
)}
<div
@@ -454,35 +456,35 @@ export default function RdpClient({
variant="secondary"
onClick={() => ui()?.setScale(1)}
>
Fit
{t("rdpFit")}
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => ui()?.setScale(2)}
>
Full
{t("rdpFull")}
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => ui()?.setScale(3)}
>
Real
{t("rdpReal")}
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => ui()?.ctrlAltDel()}
>
Ctrl+Alt+Del
{t("browserGatewayCtrlAltDel")}
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => ui()?.metaKey()}
>
Meta
{t("rdpMeta")}
</Button>
{/* <Button
size="sm"
@@ -504,19 +506,22 @@ export default function RdpClient({
try {
ft.uploadFiles(files);
toast({
title: "Files ready to paste",
description: `${files.length} file(s) copied to remote clipboard — press Ctrl+V on the remote desktop to paste.`
title: t("rdpFilesReadyToPaste"),
description: t(
"rdpFilesReadyToPasteDescription",
{ count: files.length }
)
});
} catch (err) {
toast({
variant: "destructive",
title: "Upload failed",
title: t("rdpUploadFailed"),
description: `${err}`
});
}
}}
>
Upload files
{t("rdpUploadFiles")}
</Button>
<Button
size="sm"
@@ -526,7 +531,7 @@ export default function RdpClient({
setShowLogin(true);
}}
>
Terminate
{t("sshTerminate")}
</Button>
<label className="ml-2 flex items-center gap-2">
<input
@@ -537,7 +542,7 @@ export default function RdpClient({
ui()?.setKeyboardUnicodeMode(e.target.checked);
}}
/>
Unicode keyboard mode
{t("rdpUnicodeKeyboardMode")}
</label>
</div>
@@ -554,20 +559,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>
);
}

View File

@@ -1,40 +1,33 @@
import { headers } from "next/headers";
import { priv } from "@app/lib/api";
import { AxiosResponse } from "axios";
import { GetBrowserTargetResponse } from "@server/routers/browserGatewayTarget";
import { generateBrowserGatewayMetadata } from "@app/lib/browserGatewayMetadata";
import { getBrowserTargetForRequest } from "@app/lib/getBrowserTargetForRequest";
import { loadOrgLoginPageBranding } from "@app/lib/loadOrgLoginPageBranding";
import RdpClient from "./RdpClient";
import AuthFooter from "@app/components/AuthFooter";
import { getTranslations } from "next-intl/server";
export const dynamic = "force-dynamic";
export const metadata = {
title: "RDP"
};
export async function generateMetadata() {
return generateBrowserGatewayMetadata("RDP");
}
export default async function RdpPage() {
const headersList = await headers();
const host = headersList.get("host") || "";
const hostname = host.split(":")[0];
let target: GetBrowserTargetResponse | null = null;
let error: string | null = null;
try {
const res = await priv.get<AxiosResponse<GetBrowserTargetResponse>>(
`/resource/browser-target?fullDomain=${encodeURIComponent(hostname)}`
);
target = res.data.data;
console.log("Fetched browser target:", target);
} catch (error) {
console.error("Error fetching browser target:", error);
error = "No resource found for this domain";
}
const t = await getTranslations();
const { target } = await getBrowserTargetForRequest();
const error = target ? null : t("browserGatewayNoResourceForDomain");
const { primaryColor } = target
? await loadOrgLoginPageBranding(target.orgId)
: { primaryColor: null };
return (
<div className="h-full flex flex-col">
<div className="flex-1 flex md:items-center justify-center">
<div className="w-full max-w-md p-3">
<RdpClient target={target} error={error} />
<RdpClient
target={target}
error={error}
primaryColor={primaryColor}
/>
</div>
</div>
<AuthFooter />

View File

@@ -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,
@@ -15,15 +24,17 @@ import {
CardDescription
} from "@app/components/ui/card";
import Link from "next/link";
import { ExternalLink, Loader2, AlertCircle } from "lucide-react";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { cn } from "@app/lib/cn";
import { ExternalLink, Loader2 } from "lucide-react";
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";
import BrandedAuthSurface from "@app/components/BrandedAuthSurface";
import PoweredByPangolin from "@app/components/PoweredByPangolin";
type AuthTab = "password" | "privateKey";
type FormState = {
type SshCredentialsForm = {
username: string;
password: string;
privateKey: string;
@@ -36,32 +47,46 @@ 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,
signedKeyData,
privateKey: signedPrivateKey
privateKey: signedPrivateKey,
primaryColor
}: {
target: GetBrowserTargetResponse | null;
error: string | null;
signedKeyData?: SignSshKeyResponse | null;
privateKey?: string | null;
primaryColor?: string | null;
}) {
const STORAGE_KEY = "pangolin_ssh_credentials";
const t = useTranslations();
const resourceName = target?.name?.trim() || null;
const [form, setForm] = useState<FormState>(() => {
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 [authTab, setAuthTab] = useState<AuthTab>("password");
const form = useForm<SshCredentialsForm>({
defaultValues: loadStoredCredentials(STORAGE_KEY)
});
function handleKeyFile(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
@@ -70,11 +95,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 = "";
}
@@ -126,14 +150,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(
@@ -142,7 +164,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(
@@ -156,14 +177,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();
@@ -171,7 +190,6 @@ export default function SshClient({
};
}, []);
// Auto-connect when signed key data is provided (push PAM mode).
useEffect(() => {
if (signedKeyData && signedPrivateKey && target) {
connect({
@@ -180,11 +198,12 @@ export default function SshClient({
certificate: signedKeyData.certificate
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
function connect(override?: ConnectCredentials) {
setConnectError(null);
function connect(
override?: ConnectCredentials,
authMethod: AuthTab = "password"
) {
setConnecting(true);
if (!target) {
@@ -193,12 +212,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 ?? (authTab === "password" ? form.password : "");
override?.password ??
(authMethod === "password" ? values.password : "");
const privateKey =
override?.privateKey ??
(authTab === "privateKey" ? form.privateKey : "");
(authMethod === "privateKey" ? values.privateKey : "");
const certificate = override?.certificate;
const proxyAddress = `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/gateway/ssh`;
@@ -217,16 +238,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",
@@ -237,7 +252,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
}
@@ -261,7 +279,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(
@@ -269,7 +286,7 @@ export default function SshClient({
);
} else {
xtermRef.current?.writeln(
`\r\n\x1b[31mError: ${msg.error}\x1b[0m\r\n`
`\r\n\x1b[31m${t("sshTerminalError", { error: msg.error ?? "" })}\x1b[0m\r\n`
);
}
}
@@ -282,13 +299,13 @@ export default function SshClient({
xtermRef.current?.write(evt.data);
}
} else if (evt.data instanceof Blob) {
evt.data.text().then((t) => {
evt.data.text().then((text) => {
if (!authConfirmed) {
authConfirmed = true;
setConnecting(false);
setConnected(true);
}
xtermRef.current?.write(t);
xtermRef.current?.write(text);
});
}
};
@@ -304,11 +321,9 @@ export default function SshClient({
if (authConfirmed) {
setConnected(false);
xtermRef.current?.writeln(
`\r\n\x1b[33mConnection closed (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) {
setConnectError(t("sshErrorConnectionClosed"));
}
@@ -322,7 +337,40 @@ export default function SshClient({
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) {
return (
<>
@@ -351,7 +399,6 @@ export default function SshClient({
variant="destructive"
className="w-full"
>
<AlertCircle className="h-5 w-5" />
<AlertDescription>
{connectError}
</AlertDescription>
@@ -376,202 +423,202 @@ export default function SshClient({
if (error) {
return (
<div>
<div className="text-center mb-2">
<span className="text-sm text-muted-foreground">
{t("sshPoweredBy")}{" "}
<Link
href="https://pangolin.net/"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
Pangolin
</Link>
</span>
</div>
<BrandedAuthSurface primaryColor={primaryColor}>
<PoweredByPangolin />
<Card className="w-full">
<CardHeader>
<CardTitle>{t("sshTitle")}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-destructive text-sm">{error}</p>
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
</CardContent>
</Card>
</div>
</BrandedAuthSurface>
);
}
return (
<>
{!connected && (
<div>
<div className="text-center mb-2">
<span className="text-sm text-muted-foreground">
{t("sshPoweredBy")}{" "}
<Link
href="https://pangolin.net/"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
Pangolin
</Link>
</span>
</div>
<BrandedAuthSurface primaryColor={primaryColor}>
<PoweredByPangolin />
<Card className="w-full">
<CardHeader>
<CardTitle>{t("sshSignInTitle")}</CardTitle>
<CardTitle>
{resourceName
? `${t("sshSignInTitle")} - ${resourceName}`
: t("sshSignInTitle")}
</CardTitle>
<CardDescription>
{t("sshSignInDescription")}
{resourceName
? `${t("sshSignInDescription")} (${resourceName})`
: t("sshSignInDescription")}
</CardDescription>
</CardHeader>
<CardContent>
{/* Tab row */}
<div className="flex space-x-4 border-b mb-4">
{(["password", "privateKey"] as const).map(
(tab) => (
<button
key={tab}
type="button"
onClick={() => setAuthTab(tab)}
className={cn(
"px-4 py-2 text-sm font-medium transition-colors whitespace-nowrap relative",
authTab === tab
? "text-primary after:absolute after:bottom-0 after:left-0 after:right-0 after:h-0.5 after:bg-primary after:rounded-full"
: "text-muted-foreground hover:text-foreground"
)}
>
{tab === "password"
? t("sshPasswordTab")
: t("sshPrivateKeyTab")}
</button>
)
)}
</div>
{authTab === "password" && (
<div className="space-y-4">
<Field
label={t("username")}
id="username-pw"
>
<Input
id="username-pw"
value={form.username}
onChange={(e) =>
setForm({
...form,
username: e.target.value
})
}
placeholder="root"
/>
</Field>
<Field label={t("password")} id="password">
<Input
id="password"
type="password"
value={form.password}
onChange={(e) =>
setForm({
...form,
password: e.target.value
})
}
/>
</Field>
</div>
)}
{authTab === "privateKey" && (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
{t("sshPrivateKeyDisclaimer")}{" "}
<Link
href="https://docs.pangolin.net/"
target="_blank"
rel="noopener noreferrer"
className="underline inline-flex items-center gap-1"
>
{t("sshLearnMore")}
<ExternalLink className="h-3 w-3" />
</Link>
</p>
<Field
label={t("username")}
id="username-key"
>
<Input
id="username-key"
value={form.username}
onChange={(e) =>
setForm({
...form,
username: e.target.value
})
}
placeholder="root"
/>
</Field>
<Field
label={t("sshPrivateKeyField")}
id="privateKey"
>
<Textarea
id="privateKey"
value={form.privateKey}
onChange={(e) =>
setForm({
...form,
privateKey: e.target.value
})
}
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"
rows={5}
className="font-mono text-xs"
/>
</Field>
<Field
label={t("sshPrivateKeyFile")}
id="privateKeyFile"
>
<Input
id="privateKeyFile"
type="file"
accept=".pem,.key,.pub,*"
onChange={handleKeyFile}
/>
</Field>
</div>
)}
<div className="mt-4 space-y-3">
{connectError && (
<p className="text-destructive text-sm">
{connectError}
</p>
)}
<Button
onClick={() => connect()}
loading={connecting}
disabled={
!form.username ||
(authTab === "password"
? !form.password
: !form.privateKey)
}
className="w-full"
<Form {...form}>
<HorizontalTabs
clientSide
defaultTab={0}
items={[
{
title: t("sshPasswordTab"),
href: "#"
},
{
title: t("sshPrivateKeyTab"),
href: "#"
}
]}
>
{connecting
? t("sshConnecting")
: t("sshAuthenticate")}
</Button>
</div>
<form
onSubmit={onPasswordSubmit}
className="space-y-4 mt-4 p-1"
>
<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>
)}
/>
<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>
<form
onSubmit={onPrivateKeySubmit}
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>
<FormField
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>
</Card>
</div>
</BrandedAuthSurface>
)}
{connected && (
@@ -595,20 +642,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>
);
}

View File

@@ -1,11 +1,15 @@
import { headers } from "next/headers";
import { priv } from "@app/lib/api";
import { generateBrowserGatewayMetadata } from "@app/lib/browserGatewayMetadata";
import { getBrowserTargetForRequest } from "@app/lib/getBrowserTargetForRequest";
import { loadOrgLoginPageBranding } from "@app/lib/loadOrgLoginPageBranding";
import { AxiosResponse } from "axios";
import { GetBrowserTargetResponse } from "@server/routers/browserGatewayTarget";
import SshClient from "./SshClient";
import crypto from "crypto";
import AuthFooter from "@app/components/AuthFooter";
import type { SignSshKeyResponse } from "@server/routers/ssh/types";
import { getTranslations } from "next-intl/server";
const pollInitialDelayMs = 250;
const pollStartIntervalMs = 250;
@@ -99,14 +103,13 @@ function generateEphemeralKeyPair(): {
export const dynamic = "force-dynamic";
export const metadata = {
title: "SSH"
};
export async function generateMetadata() {
return generateBrowserGatewayMetadata("SSH");
}
export default async function SshPage() {
const t = await getTranslations();
const headersList = await headers();
const host = headersList.get("host") || "";
const hostname = host.split(":")[0];
const cookieHeader = headersList.get("cookie") || "";
let target: GetBrowserTargetResponse | null = null;
@@ -114,51 +117,49 @@ export default async function SshPage() {
let privateKey: string | null = null;
let error: string | null = null;
try {
const res = await priv.get<AxiosResponse<GetBrowserTargetResponse>>(
`/resource/browser-target?fullDomain=${encodeURIComponent(hostname)}`
);
target = res.data.data;
const { target: browserTarget } = await getBrowserTargetForRequest();
target = browserTarget;
if (target.pamMode === "push") {
try {
const { privateKeyPem, publicKeyOpenSSH } =
generateEphemeralKeyPair();
privateKey = privateKeyPem;
const res = await priv.post<AxiosResponse<SignSshKeyResponse>>(
`/org/${target.orgId}/ssh/sign-key`,
{
publicKey: publicKeyOpenSSH,
resourceId: target.resourceId,
type: "public"
},
{
headers: {
Cookie: cookieHeader
}
if (!target) {
error = t("browserGatewayNoResourceForDomain");
} else if (target.pamMode === "push") {
try {
const { privateKeyPem, publicKeyOpenSSH } =
generateEphemeralKeyPair();
privateKey = privateKeyPem;
const res = await priv.post<AxiosResponse<SignSshKeyResponse>>(
`/org/${target.orgId}/ssh/sign-key`,
{
publicKey: publicKeyOpenSSH,
resourceId: target.resourceId,
type: "public"
},
{
headers: {
Cookie: cookieHeader
}
);
signedKeyData = res.data.data;
}
);
signedKeyData = res.data.data;
const messageIds =
signedKeyData.messageIds.length > 0
? signedKeyData.messageIds
: signedKeyData.messageId
? [signedKeyData.messageId]
: [];
const messageIds =
signedKeyData.messageIds.length > 0
? signedKeyData.messageIds
: signedKeyData.messageId
? [signedKeyData.messageId]
: [];
await waitForRoundTripCompletion(messageIds, cookieHeader);
} catch (err) {
console.error("Error signing SSH key:", err);
error =
"Failed to sign SSH key for PAM push authentication. Did you sign in as a user?";
}
await waitForRoundTripCompletion(messageIds, cookieHeader);
} catch (err) {
console.error("Error signing SSH key:", err);
error = t("sshErrorSignKeyFailed");
}
} catch (err) {
console.error("Error fetching browser target:", err);
error = "No resource found for this domain";
}
const { primaryColor } = target
? await loadOrgLoginPageBranding(target.orgId)
: { primaryColor: null };
return (
<div className="h-full flex flex-col">
<div className="flex-1 flex md:items-center justify-center">
@@ -168,6 +169,7 @@ export default async function SshPage() {
error={error}
signedKeyData={signedKeyData}
privateKey={privateKey}
primaryColor={primaryColor}
/>
</div>
</div>

View File

@@ -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 { GetBrowserTargetResponse } from "@server/routers/browserGatewayTarget";
import {
@@ -13,41 +23,52 @@ import {
CardTitle,
CardDescription
} from "@app/components/ui/card";
import Link from "next/link";
import { Alert, AlertDescription } from "@app/components/ui/alert";
import BrandedAuthSurface from "@app/components/BrandedAuthSurface";
import PoweredByPangolin from "@app/components/PoweredByPangolin";
import { useTranslations } from "next-intl";
type FormState = {
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
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 [form, setForm] = useState<FormState>(() => {
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) return JSON.parse(saved) as FormState;
} catch {
// ignore
}
return { password: "" };
const formSchema = z.object({
password: z.string()
});
const form = useForm<VncCredentialsForm>({
resolver: zodResolver(formSchema),
defaultValues: loadStoredCredentials(STORAGE_KEY)
});
const [connected, setConnected] = useState(false);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const [connectError, setConnectError] = useState<string | null>(null);
const rfbRef = useRef<any>(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 = () => {
if (rfbRef.current) {
rfbRef.current.disconnect();
@@ -56,28 +77,20 @@ export default function VncClient({
setConnected(false);
};
// Clean up on unmount.
useEffect(() => {
return () => disconnect();
}, []); // eslint-disable-line react-hooks/exhaustive-deps
}, []);
const connect = async () => {
const connect = async (values: VncCredentialsForm) => {
if (!target) {
toast({
variant: "destructive",
title: "No target",
description: "No resource target is available"
});
setConnectError(t("vncNoResourceTarget"));
return;
}
if (!screenRef.current) return;
// Disconnect any existing session first.
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 (
target: HTMLElement,
url: string,
@@ -90,14 +103,12 @@ export default function VncClient({
} catch (err) {
toast({
variant: "destructive",
title: "Failed to load noVNC",
title: t("vncFailedToLoadNovnc"),
description: `${err}`
});
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 base = proxyAddress.replace(/\/$/, "");
const params = new URLSearchParams({
@@ -107,15 +118,13 @@ export default function VncClient({
});
const wsUrl = `${base}?${params.toString()}`;
// Clear the container so noVNC gets a clean mount point.
screenRef.current.innerHTML = "";
const options: Record<string, unknown> = {};
if (form.password) {
options.credentials = { password: form.password };
if (values.password) {
options.credentials = { password: values.password };
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const rfb: any = new RFB(screenRef.current, wsUrl, options);
rfb.scaleViewport = true;
@@ -123,7 +132,7 @@ export default function VncClient({
rfb.addEventListener("connect", () => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(form));
localStorage.setItem(STORAGE_KEY, JSON.stringify(values));
} catch {
// ignore
}
@@ -141,92 +150,99 @@ export default function VncClient({
rfb.addEventListener(
"securityfailure",
(e: { detail: { status: number; reason?: string } }) => {
toast({
variant: "destructive",
title: "Authentication failed",
description: e.detail.reason ?? `Status ${e.detail.status}`
});
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 (
<div>
<div className="text-center mb-2">
<span className="text-sm text-muted-foreground">
Powered by{" "}
<Link
href="https://pangolin.net/"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
Pangolin
</Link>
</span>
</div>
<BrandedAuthSurface primaryColor={primaryColor}>
<PoweredByPangolin />
<Card className="w-full">
<CardHeader>
<CardTitle>VNC</CardTitle>
<CardTitle>{t("vncTitle")}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-destructive text-sm">{error}</p>
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
</CardContent>
</Card>
</div>
</BrandedAuthSurface>
);
}
return (
<>
{!connected && (
<div>
<div className="text-center mb-2">
<span className="text-sm text-muted-foreground">
Powered by{" "}
<Link
href="https://pangolin.net/"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
Pangolin
</Link>
</span>
</div>
<BrandedAuthSurface primaryColor={primaryColor}>
<PoweredByPangolin />
<Card className="w-full">
<CardHeader>
<CardTitle>VNC</CardTitle>
<CardTitle>
{resourceName
? `${t("vncTitle")} - ${resourceName}`
: t("vncTitle")}
</CardTitle>
<CardDescription>
Enter your credentials to access xxxx
{resourceName
? `${t("vncSignInDescription")} (${resourceName})`
: t("vncSignInDescription")}
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<Field
label="Password (optional)"
id="password"
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
>
<Input
id="password"
type="password"
value={form.password}
onChange={(e) =>
update("password", e.target.value)
}
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("vncPasswordOptional")}
</FormLabel>
<FormControl>
<Input
type="password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</Field>
<Button onClick={connect} className="w-full">
Connect
</Button>
</div>
<Button type="submit" className="w-full">
{t("browserGatewayConnect")}
</Button>
{connectError && (
<Alert variant="destructive">
<AlertDescription>
{connectError}
</AlertDescription>
</Alert>
)}
</form>
</Form>
</CardContent>
</Card>
</div>
</BrandedAuthSurface>
)}
<div
@@ -243,7 +259,7 @@ export default function VncClient({
}
}}
>
Ctrl+Alt+Del
{t("browserGatewayCtrlAltDel")}
</Button>
<Button
size="sm"
@@ -257,18 +273,17 @@ export default function VncClient({
.catch(() => {});
}}
>
Paste clipboard
{t("vncPasteClipboard")}
</Button>
<Button
size="sm"
variant="destructive"
onClick={disconnect}
>
Terminate
{t("sshTerminate")}
</Button>
</div>
{/* noVNC mounts a <canvas> inside this div */}
<div
ref={screenRef}
className="flex-1 overflow-hidden"
@@ -278,20 +293,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>
);
}

View File

@@ -1,39 +1,33 @@
import { headers } from "next/headers";
import { priv } from "@app/lib/api";
import { AxiosResponse } from "axios";
import { GetBrowserTargetResponse } from "@server/routers/browserGatewayTarget";
import { generateBrowserGatewayMetadata } from "@app/lib/browserGatewayMetadata";
import { getBrowserTargetForRequest } from "@app/lib/getBrowserTargetForRequest";
import { loadOrgLoginPageBranding } from "@app/lib/loadOrgLoginPageBranding";
import VncClient from "./VncClient";
import AuthFooter from "@app/components/AuthFooter";
import { getTranslations } from "next-intl/server";
export const dynamic = "force-dynamic";
export const metadata = {
title: "VNC"
};
export async function generateMetadata() {
return generateBrowserGatewayMetadata("VNC");
}
export default async function VncPage() {
const headersList = await headers();
const host = headersList.get("host") || "";
const hostname = host.split(":")[0];
let target: GetBrowserTargetResponse | null = null;
let error: string | null = null;
try {
const res = await priv.get<AxiosResponse<GetBrowserTargetResponse>>(
`/resource/browser-target?fullDomain=${encodeURIComponent(hostname)}`
);
target = res.data.data;
} catch (error) {
console.error("Error fetching browser target:", error);
error = "No resource found for this domain";
}
const t = await getTranslations();
const { target } = await getBrowserTargetForRequest();
const error = target ? null : t("browserGatewayNoResourceForDomain");
const { primaryColor } = target
? await loadOrgLoginPageBranding(target.orgId)
: { primaryColor: null };
return (
<div className="h-full flex flex-col">
<div className="flex-1 flex md:items-center justify-center">
<div className="w-full max-w-md p-3">
<VncClient target={target} error={error} />
<VncClient
target={target}
error={error}
primaryColor={primaryColor}
/>
</div>
</div>
<AuthFooter />