From 9cff5f66b189a3db772d9d8348f3435de2f28822 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 4 Jun 2026 19:40:24 +0200 Subject: [PATCH 001/114] =?UTF-8?q?=F0=9F=9A=A7=20wip:=20site=20label=20co?= =?UTF-8?q?lumn=20filter=20standardized?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 1 + src/components/LabelColumnFilterButton.tsx | 2 +- src/components/PrivateResourcesTable.tsx | 53 +++---- src/components/ProxyResourcesTable.tsx | 123 +++------------- src/components/SitesColumnFilterButton.tsx | 160 +++++++++++++++++++++ src/components/multi-site-selector.tsx | 1 - src/components/site-selector.tsx | 6 +- 7 files changed, 208 insertions(+), 138 deletions(-) create mode 100644 src/components/SitesColumnFilterButton.tsx diff --git a/messages/en-US.json b/messages/en-US.json index 2264f1332..42bbdf2c0 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1225,6 +1225,7 @@ "accessLabelFilterCount": "{count, plural, one {# label} other {# labels}}", "labelOverflowCount": "+{count, plural, one {# label} other {# labels}}", "accessLabelFilterClear": "Clear label filters", + "accessFilterClear": "Clear filters", "selectColor": "Select color", "createNewLabel": "Create new org label \"{label}\"", "inviteInvalidDescription": "The invite link is invalid.", diff --git a/src/components/LabelColumnFilterButton.tsx b/src/components/LabelColumnFilterButton.tsx index c6b083967..ed6a1f744 100644 --- a/src/components/LabelColumnFilterButton.tsx +++ b/src/components/LabelColumnFilterButton.tsx @@ -168,7 +168,7 @@ export function LabelColumnFilterButton({ }} className="text-muted-foreground" > - {t("accessLabelFilterClear")} + {t("accessFilterClear")} )} {labels.map((label) => ( diff --git a/src/components/PrivateResourcesTable.tsx b/src/components/PrivateResourcesTable.tsx index 396ba9759..37acb3e94 100644 --- a/src/components/PrivateResourcesTable.tsx +++ b/src/components/PrivateResourcesTable.tsx @@ -2,9 +2,17 @@ import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import CopyToClipboard from "@app/components/CopyToClipboard"; -import { ExtendedColumnDef } from "@app/components/ui/data-table"; +import CreatePrivateResourceDialog from "@app/components/CreatePrivateResourceDialog"; +import EditPrivateResourceDialog from "@app/components/EditPrivateResourceDialog"; +import { ResourceAccessCertIndicator } from "@app/components/ResourceAccessCertIndicator"; +import { + ResourceSitesStatusCell, + type ResourceSiteRow +} from "@app/components/ResourceSitesStatusCell"; +import { Selectedsite, SitesSelector } from "@app/components/site-selector"; import { Badge } from "@app/components/ui/badge"; import { Button } from "@app/components/ui/button"; +import { ExtendedColumnDef } from "@app/components/ui/data-table"; import { DropdownMenu, DropdownMenuContent, @@ -18,53 +26,34 @@ import { PopoverTrigger } from "@app/components/ui/popover"; import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useNavigationContext } from "@app/hooks/useNavigationContext"; +import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels"; +import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { cn } from "@app/lib/cn"; +import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover"; +import { formatSiteResourceDestinationDisplay } from "@app/lib/formatSiteResourceAccess"; import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn"; +import { build } from "@server/build"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import type { PaginationState } from "@tanstack/react-table"; import { ArrowDown01Icon, ArrowUp10Icon, ArrowUpDown, - ArrowUpRight, - ChevronDown, ChevronsUpDownIcon, Funnel, MoreHorizontal } from "lucide-react"; import { useTranslations } from "next-intl"; -import Link from "next/link"; import { useRouter } from "next/navigation"; -import { Selectedsite, SitesSelector } from "@app/components/site-selector"; -import { - startTransition, - useEffect, - useMemo, - useState, - useTransition -} from "react"; -import CreatePrivateResourceDialog from "@app/components/CreatePrivateResourceDialog"; -import EditPrivateResourceDialog from "@app/components/EditPrivateResourceDialog"; -import type { PaginationState } from "@tanstack/react-table"; -import { ControlledDataTable } from "./ui/controlled-data-table"; -import { useNavigationContext } from "@app/hooks/useNavigationContext"; +import { startTransition, useMemo, useState, useTransition } from "react"; import { useDebouncedCallback } from "use-debounce"; import { ColumnFilterButton } from "./ColumnFilterButton"; -import { cn } from "@app/lib/cn"; -import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover"; -import { formatSiteResourceDestinationDisplay } from "@app/lib/formatSiteResourceAccess"; -import { - ResourceSitesStatusCell, - type ResourceSiteRow -} from "@app/components/ResourceSitesStatusCell"; -import { ResourceAccessCertIndicator } from "@app/components/ResourceAccessCertIndicator"; -import { build } from "@server/build"; -import { usePaidStatus } from "@app/hooks/usePaidStatus"; -import { tierMatrix } from "@server/lib/billing/tierMatrix"; -import { type SelectedLabel } from "./labels-selector"; -import { LabelsTableCell } from "./LabelsTableCell"; import { LabelColumnFilterButton } from "./LabelColumnFilterButton"; -import { useLocalLabels } from "@app/hooks/useLocalLabels"; -import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels"; +import { LabelsTableCell } from "./LabelsTableCell"; +import { ControlledDataTable } from "./ui/controlled-data-table"; export type InternalResourceSiteRow = ResourceSiteRow; diff --git a/src/components/ProxyResourcesTable.tsx b/src/components/ProxyResourcesTable.tsx index 0b761a540..edfe06dfd 100644 --- a/src/components/ProxyResourcesTable.tsx +++ b/src/components/ProxyResourcesTable.tsx @@ -76,6 +76,7 @@ import { useLocalLabels } from "@app/hooks/useLocalLabels"; import { LabelsTableCell } from "./LabelsTableCell"; import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels"; import { refresh } from "next/cache"; +import { SitesColumnFilterButton } from "./SitesColumnFilterButton"; export type TargetHealth = { targetId: number; @@ -154,30 +155,6 @@ export default function ProxyResourcesTable({ const [isRefreshing, startTransition] = useTransition(); const [isNavigatingToAddPage, startNavigation] = useTransition(); - const [siteFilterOpen, setSiteFilterOpen] = useState(false); - - const siteIdQ = searchParams.get("siteId"); - const siteIdNum = siteIdQ ? parseInt(siteIdQ, 10) : NaN; - const selectedSite: Selectedsite | null = useMemo(() => { - if (!siteIdQ || !Number.isInteger(siteIdNum) || siteIdNum <= 0) { - return null; - } - if (initialFilterSite && initialFilterSite.siteId === siteIdNum) { - return initialFilterSite; - } - return { - siteId: siteIdNum, - name: t("standaloneHcFilterSiteIdFallback", { id: siteIdNum }), - type: "newt" - }; - }, [initialFilterSite, siteIdQ, siteIdNum, t]); - - // useEffect(() => { - // const interval = setInterval(() => { - // router.refresh(); - // }, 30_000); - // return () => clearInterval(interval); - // }, [router]); const refreshData = () => { startTransition(() => { @@ -227,28 +204,6 @@ export default function ProxyResourcesTable({ } } - const clearSiteFilter = () => { - handleFilterChange("siteId", undefined); - setSiteFilterOpen(false); - }; - - const onPickSite = (site: Selectedsite) => { - handleFilterChange("siteId", String(site.siteId)); - setSiteFilterOpen(false); - }; - - const siteFilterOpenRef = useRef(siteFilterOpen); - siteFilterOpenRef.current = siteFilterOpen; - - const selectedSiteRef = useRef(selectedSite); - selectedSiteRef.current = selectedSite; - - const clearSiteFilterRef = useRef(clearSiteFilter); - clearSiteFilterRef.current = clearSiteFilter; - - const onPickSiteRef = useRef(onPickSite); - onPickSiteRef.current = onPickSite; - const proxyColumns = useMemo[]>(() => { const cols: ExtendedColumnDef[] = [ { @@ -291,61 +246,27 @@ export default function ProxyResourcesTable({ accessorFn: (row) => row.sites.map((s) => s.siteName).join(", "), friendlyName: t("sites"), - header: () => ( - - - - - -
- -
- - onPickSiteRef.current(site) - } - /> -
-
- ), + header: () => { + const siteIdQ = searchParams.get("siteId"); + const siteIdNum = siteIdQ ? parseInt(siteIdQ, 10) : NaN; + + const selectedSiteId = + !siteIdQ || + !Number.isInteger(siteIdNum) || + siteIdNum <= 0 + ? null + : siteIdNum; + + return ( + + handleFilterChange("siteId", value?.toString()) + } + orgId={orgId} + /> + ); + }, cell: ({ row }) => ( void; + orgId: string; +}; + +export function SitesColumnFilterButton({ + selectedSiteId, + onValueChange, + orgId +}: SitesColumnFilterButtonProps) { + const [open, setOpen] = useState(false); + + const t = useTranslations(); + + const [siteSearchQuery, setSiteSearchQuery] = useState(""); + const [debouncedQuery] = useDebounce(siteSearchQuery, 150); + + const { data: sites = [] } = useQuery( + orgQueries.sites({ + orgId, + query: debouncedQuery, + perPage: 500 + }) + ); + + const selectedSite = useMemo(() => { + let selected = undefined; + if (selectedSiteId) { + selected = sites.find((site) => site.siteId === selectedSiteId) ?? { + siteId: Number(selectedSiteId), + name: t("standaloneHcFilterSiteIdFallback", { + id: Number(selectedSiteId) + }), + type: "newt" + }; + } + + return selected; + }, [selectedSiteId, sites]); + + // always include the selected site in the list of sites shown + const sitesShown = useMemo(() => { + const allSites: Array = [...sites]; + if ( + debouncedQuery.trim().length === 0 && + selectedSite && + !allSites.find((site) => site.siteId === selectedSite?.siteId) + ) { + allSites.unshift(selectedSite); + } + return allSites; + }, [debouncedQuery, sites, selectedSite]); + + return ( + + + + + + + setSiteSearchQuery(v)} + /> + + {t("siteNotFound")} + + {selectedSite && ( + { + onValueChange(undefined); + }} + className="text-muted-foreground" + > + {t("accessFilterClear")} + + )} + {sitesShown.map((site) => ( + { + onValueChange(site.siteId); + }} + > + +
+ + {site.name} + + {site.online != null && ( + + )} +
+
+ ))} +
+
+
+
+
+ ); +} diff --git a/src/components/multi-site-selector.tsx b/src/components/multi-site-selector.tsx index 76255e824..acb8b7dd9 100644 --- a/src/components/multi-site-selector.tsx +++ b/src/components/multi-site-selector.tsx @@ -115,7 +115,6 @@ export function MultiSitesSelector({ )} diff --git a/src/components/site-selector.tsx b/src/components/site-selector.tsx index 778a6fcf6..2a7717572 100644 --- a/src/components/site-selector.tsx +++ b/src/components/site-selector.tsx @@ -26,11 +26,12 @@ export type Selectedsite = Pick< type SiteOnlineStatusProps = { type: Selectedsite["type"]; online: Selectedsite["online"]; - t: (key: "online" | "offline") => string; }; /** Dot-only indicator matching `SitesTable` colors (newt/wireguard only; nothing for local or missing status). */ -export function SiteOnlineStatus({ type, online, t }: SiteOnlineStatusProps) { +export function SiteOnlineStatus({ type, online }: SiteOnlineStatusProps) { + const t = useTranslations(); + if (type !== "newt" && type !== "wireguard") { return null; } @@ -128,7 +129,6 @@ export function SitesSelector({ )} From d485a09318cfcf595d2ff2689393daa771666e25 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 4 Jun 2026 19:45:54 +0200 Subject: [PATCH 002/114] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20use=20site=20label?= =?UTF-8?q?=20filter=20column?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/PrivateResourcesTable.tsx | 101 +++++------------------ 1 file changed, 22 insertions(+), 79 deletions(-) diff --git a/src/components/PrivateResourcesTable.tsx b/src/components/PrivateResourcesTable.tsx index 37acb3e94..6472a23f2 100644 --- a/src/components/PrivateResourcesTable.tsx +++ b/src/components/PrivateResourcesTable.tsx @@ -54,6 +54,7 @@ import { ColumnFilterButton } from "./ColumnFilterButton"; import { LabelColumnFilterButton } from "./LabelColumnFilterButton"; import { LabelsTableCell } from "./LabelsTableCell"; import { ControlledDataTable } from "./ui/controlled-data-table"; +import { SitesColumnFilterButton } from "./SitesColumnFilterButton"; export type InternalResourceSiteRow = ResourceSiteRow; @@ -146,7 +147,6 @@ export default function PrivateResourcesTable({ const [editingResource, setEditingResource] = useState(); const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); - const [siteFilterOpen, setSiteFilterOpen] = useState(false); const [isRefreshing, startRefreshTransition] = useTransition(); @@ -160,22 +160,6 @@ export default function PrivateResourcesTable({ // return () => clearInterval(interval); // }, [router]); - const siteIdQ = searchParams.get("siteId"); - const siteIdNum = siteIdQ ? parseInt(siteIdQ, 10) : NaN; - const selectedSite: Selectedsite | null = useMemo(() => { - if (!siteIdQ || !Number.isInteger(siteIdNum) || siteIdNum <= 0) { - return null; - } - if (initialFilterSite && initialFilterSite.siteId === siteIdNum) { - return initialFilterSite; - } - return { - siteId: siteIdNum, - name: t("standaloneHcFilterSiteIdFallback", { id: siteIdNum }), - type: "newt" - }; - }, [initialFilterSite, siteIdQ, siteIdNum, t]); - const refreshData = () => { startRefreshTransition(() => { try { @@ -269,58 +253,27 @@ export default function PrivateResourcesTable({ accessorFn: (row) => row.sites.map((s) => s.siteName).join(", "), friendlyName: t("sites"), - header: () => ( - - - - - -
- -
- -
-
- ), + header: () => { + const siteIdQ = searchParams.get("siteId"); + const siteIdNum = siteIdQ ? parseInt(siteIdQ, 10) : NaN; + + const selectedSiteId = + !siteIdQ || + !Number.isInteger(siteIdNum) || + siteIdNum <= 0 + ? null + : siteIdNum; + + return ( + + handleFilterChange("siteId", value?.toString()) + } + orgId={orgId} + /> + ); + }, cell: ({ row }) => { const resourceRow = row.original; return ( @@ -570,16 +523,6 @@ export default function PrivateResourcesTable({ }); } - const clearSiteFilter = () => { - handleFilterChange("siteId", undefined); - setSiteFilterOpen(false); - }; - - const onPickSite = (site: Selectedsite) => { - handleFilterChange("siteId", String(site.siteId)); - setSiteFilterOpen(false); - }; - function toggleSort(column: string) { const newSearch = getNextSortOrder(column, searchParams); From feb8045643f06c6f8d5f23e26df78f64833c09fc Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 4 Jun 2026 19:54:43 +0200 Subject: [PATCH 003/114] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/messages/en-US.json b/messages/en-US.json index 42bbdf2c0..f77644df2 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1225,7 +1225,7 @@ "accessLabelFilterCount": "{count, plural, one {# label} other {# labels}}", "labelOverflowCount": "+{count, plural, one {# label} other {# labels}}", "accessLabelFilterClear": "Clear label filters", - "accessFilterClear": "Clear filters", + "accessFilterClear": "Clear filter", "selectColor": "Select color", "createNewLabel": "Create new org label \"{label}\"", "inviteInvalidDescription": "The invite link is invalid.", From db014e3446146c4db474d61242e5075dae649bcf Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 4 Jun 2026 20:08:27 +0200 Subject: [PATCH 004/114] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20use=20the=20same?= =?UTF-8?q?=20`clear=20filter`=20text=20for=20clearing=20filters=20in=20th?= =?UTF-8?q?e=20column=20filter=20buttons?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/ColumnFilterButton.tsx | 7 ++++--- src/components/ColumnMultiFilterButton.tsx | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/components/ColumnFilterButton.tsx b/src/components/ColumnFilterButton.tsx index 689f78983..340166ced 100644 --- a/src/components/ColumnFilterButton.tsx +++ b/src/components/ColumnFilterButton.tsx @@ -17,6 +17,7 @@ import { CheckIcon, ChevronDownIcon, Funnel } from "lucide-react"; import { cn } from "@app/lib/cn"; import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover"; import { Badge } from "./ui/badge"; +import { useTranslations } from "next-intl"; interface FilterOption { value: string; @@ -27,7 +28,6 @@ interface ColumnFilterButtonProps { options: FilterOption[]; selectedValue?: string; onValueChange: (value: string | undefined) => void; - placeholder?: string; searchPlaceholder?: string; emptyMessage?: string; className?: string; @@ -38,7 +38,6 @@ export function ColumnFilterButton({ options, selectedValue, onValueChange, - placeholder, searchPlaceholder = "Search...", emptyMessage = "No options found", className, @@ -50,6 +49,8 @@ export function ColumnFilterButton({ (option) => option.value === selectedValue ); + const t = useTranslations(); + return ( @@ -94,7 +95,7 @@ export function ColumnFilterButton({ }} className="text-muted-foreground" > - Clear filter + {t("accessFilterClear")} )} {options.map((option) => ( diff --git a/src/components/ColumnMultiFilterButton.tsx b/src/components/ColumnMultiFilterButton.tsx index 17332a9ae..33e81e8dd 100644 --- a/src/components/ColumnMultiFilterButton.tsx +++ b/src/components/ColumnMultiFilterButton.tsx @@ -120,7 +120,7 @@ export function ColumnMultiFilterButton({ }} className="text-muted-foreground" > - {t("accessUsersRoleFilterClear")} + {t("accessFilterClear")} )} {options.map((option) => ( From c86026c9412e42631d59ad6c9426102e3a4cf191 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 4 Jun 2026 20:09:07 +0200 Subject: [PATCH 005/114] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/messages/en-US.json b/messages/en-US.json index f77644df2..42bbdf2c0 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1225,7 +1225,7 @@ "accessLabelFilterCount": "{count, plural, one {# label} other {# labels}}", "labelOverflowCount": "+{count, plural, one {# label} other {# labels}}", "accessLabelFilterClear": "Clear label filters", - "accessFilterClear": "Clear filter", + "accessFilterClear": "Clear filters", "selectColor": "Select color", "createNewLabel": "Create new org label \"{label}\"", "inviteInvalidDescription": "The invite link is invalid.", From 33fdc9a94f2a81c2f7de57b33c3c2ad5d529146b Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 4 Jun 2026 21:04:15 +0200 Subject: [PATCH 006/114] =?UTF-8?q?=F0=9F=9A=A7=20wip:=20column=20filter?= =?UTF-8?q?=20button?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/[orgId]/settings/logs/request/page.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/app/[orgId]/settings/logs/request/page.tsx b/src/app/[orgId]/settings/logs/request/page.tsx index ae11da78a..ce2b99b84 100644 --- a/src/app/[orgId]/settings/logs/request/page.tsx +++ b/src/app/[orgId]/settings/logs/request/page.tsx @@ -20,6 +20,7 @@ import { useMemo, useState, useTransition } from "react"; import { useStoredPageSize } from "@app/hooks/useStoredPageSize"; import { build } from "@server/build"; import type { QueryRequestAuditLogResponse } from "@server/routers/auditLogs/types"; +import { ColumnFilterButton } from "@app/components/ColumnFilterButton"; export default function GeneralPage() { const router = useRouter(); @@ -302,19 +303,18 @@ export default function GeneralPage() { header: ({ column }) => { return (
- {t("action")} - handleFilterChange("action", value) } - // placeholder="" - searchPlaceholder="Search..." - emptyMessage="None found" + searchPlaceholder={t("searchPlaceholder")} + emptyMessage={t("emptySearchOptions")} />
); From 6420a90d08b19e5e685801b2fe9cde287fb24fa7 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 4 Jun 2026 16:21:30 -0700 Subject: [PATCH 007/114] Replace tab component --- src/app/ssh/SshClient.tsx | 135 ++++++++++++----------- src/components/newt-install-commands.tsx | 30 ++++- 2 files changed, 97 insertions(+), 68 deletions(-) diff --git a/src/app/ssh/SshClient.tsx b/src/app/ssh/SshClient.tsx index a9601738b..4ba1c9211 100644 --- a/src/app/ssh/SshClient.tsx +++ b/src/app/ssh/SshClient.tsx @@ -17,7 +17,7 @@ import { 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 { HorizontalTabs } from "@app/components/HorizontalTabs"; import type { SignSshKeyResponse } from "@server/routers/ssh/types"; import { useTranslations } from "next-intl"; @@ -61,8 +61,6 @@ export default function SshClient({ const t = useTranslations(); - const [authTab, setAuthTab] = useState("password"); - function handleKeyFile(e: React.ChangeEvent) { const file = e.target.files?.[0]; if (!file) return; @@ -182,7 +180,10 @@ export default function SshClient({ } }, []); - function connect(override?: ConnectCredentials) { + function connect( + override?: ConnectCredentials, + authMethod: AuthTab = "password" + ) { setConnectError(null); setConnecting(true); @@ -194,10 +195,11 @@ export default function SshClient({ const username = override?.username ?? form.username; const password = - override?.password ?? (authTab === "password" ? form.password : ""); + override?.password ?? + (authMethod === "password" ? form.password : ""); const privateKey = override?.privateKey ?? - (authTab === "privateKey" ? form.privateKey : ""); + (authMethod === "privateKey" ? form.privateKey : ""); const certificate = override?.certificate; const proxyAddress = `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/gateway/ssh`; @@ -224,7 +226,7 @@ export default function SshClient({ 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 + // 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({ @@ -260,7 +262,7 @@ export default function SshClient({ xtermRef.current?.write(msg.data); } else if (msg.type === "error") { if (!authConfirmed) { - // Auth-phase error — show in the login form. + // Auth-phase error - show in the login form. authErrorShown = true; setConnecting(false); setConnectError( @@ -281,13 +283,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); }); } }; @@ -426,31 +428,15 @@ export default function SshClient({ - {/* Tab row */} -
- {(["password", "privateKey"] as const).map( - (tab) => ( - - ) - )} -
- - {authTab === "password" && ( -
+ +
@@ -480,11 +465,31 @@ export default function SshClient({ } /> -
- )} +
+ {connectError && ( +

+ {connectError} +

+ )} - {authTab === "privateKey" && ( -
+ +
+
+ +

{t("sshPrivateKeyDisclaimer")}{" "} +

+ {connectError && ( +

+ {connectError} +

+ )} + + +
- )} - -
- {connectError && ( -

- {connectError} -

- )} - - -
+
diff --git a/src/components/newt-install-commands.tsx b/src/components/newt-install-commands.tsx index 422bc476d..ac8109eab 100644 --- a/src/components/newt-install-commands.tsx +++ b/src/components/newt-install-commands.tsx @@ -43,11 +43,14 @@ export function NewtSiteInstallCommands({ const t = useTranslations(); const [acceptClients, setAcceptClients] = useState(true); + const [allowPangolinSsh, setAllowPangolinSsh] = useState(true); const [platform, setPlatform] = useState("linux"); const [architecture, setArchitecture] = useState( () => getArchitectures(platform)[0] ); + const supportsSshOption = platform === "linux" || platform === "nixos"; + const acceptClientsFlag = !acceptClients ? " --disable-clients" : ""; const acceptClientsEnv = !acceptClients ? "\n - DISABLE_CLIENTS=true" @@ -57,6 +60,11 @@ export function NewtSiteInstallCommands({ --set newtInstances[0].acceptClients=true` : ""; + const disableSshFlag = + supportsSshOption && !allowPangolinSsh ? " --disable-ssh" : ""; + const runAsRootPrefix = + supportsSshOption && allowPangolinSsh ? "sudo " : ""; + const commandList: Record> = { linux: { Run: [ @@ -66,7 +74,7 @@ export function NewtSiteInstallCommands({ }, { title: t("run"), - command: `newt --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}` + command: `${runAsRootPrefix}newt --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}${disableSshFlag}` } ], "Systemd Service": [ @@ -86,6 +94,11 @@ PANGOLIN_ENDPOINT=${endpoint}${ ? ` DISABLE_CLIENTS=true` : "" + }${ + !allowPangolinSsh + ? ` +DISABLE_SSH=true` + : "" } EOF sudo chmod 600 /etc/newt/newt.env` @@ -205,7 +218,7 @@ WantedBy=default.target` }, nixos: { Flake: [ - `nix run 'nixpkgs#fosrl-newt' -- --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}` + `${runAsRootPrefix}nix run 'nixpkgs#fosrl-newt' -- --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}${disableSshFlag}` ] } }; @@ -273,6 +286,19 @@ WantedBy=default.target` label={t("siteAcceptClientConnections")} /> + {supportsSshOption && ( +
+ { + const value = checked as boolean; + setAllowPangolinSsh(value); + }} + label="Allow Pangolin SSH" + /> +
+ )}

Date: Thu, 4 Jun 2026 16:24:29 -0700 Subject: [PATCH 008/114] remove check on oidc login --- server/routers/idp/validateOidcCallback.ts | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/server/routers/idp/validateOidcCallback.ts b/server/routers/idp/validateOidcCallback.ts index b5415c52d..8188f46da 100644 --- a/server/routers/idp/validateOidcCallback.ts +++ b/server/routers/idp/validateOidcCallback.ts @@ -332,17 +332,6 @@ export async function validateOidcCallback( .where(eq(idpOrg.idpId, existingIdp.idp.idpId)) .innerJoin(orgs, eq(orgs.orgId, idpOrg.orgId)); allOrgs = idpOrgs.map((o) => o.orgs); - - for (const org of allOrgs) { - const subscribed = await isSubscribed( - org.orgId, - tierMatrix.autoProvisioning - ); - if (!subscribed) { - // filter out the org - allOrgs = allOrgs.filter((o) => o.orgId !== org.orgId); - } - } } else { allOrgs = await db.select().from(orgs); } From e5d0673bbf994819ef3d587801902dc7834e653c Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Thu, 4 Jun 2026 16:30:14 -0700 Subject: [PATCH 009/114] prefill site field on create private resource when filtering sites --- src/components/CreatePrivateResourceDialog.tsx | 6 +++++- src/components/PrivateResourceForm.tsx | 14 +++++++++++--- src/components/PrivateResourcesTable.tsx | 6 ++++++ 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/src/components/CreatePrivateResourceDialog.tsx b/src/components/CreatePrivateResourceDialog.tsx index 4bfb478ba..38907d5d8 100644 --- a/src/components/CreatePrivateResourceDialog.tsx +++ b/src/components/CreatePrivateResourceDialog.tsx @@ -23,19 +23,22 @@ import { isHostname, type InternalResourceFormValues } from "./PrivateResourceForm"; +import type { Selectedsite } from "./site-selector"; type CreateInternalResourceDialogProps = { open: boolean; setOpen: (val: boolean) => void; orgId: string; onSuccess?: () => void; + initialSites?: Selectedsite[]; }; export default function CreatePrivateResourceDialog({ open, setOpen, orgId, - onSuccess + onSuccess, + initialSites }: CreateInternalResourceDialogProps) { const t = useTranslations(); const api = createApiClient(useEnvContext()); @@ -175,6 +178,7 @@ export default function CreatePrivateResourceDialog({ formId="create-internal-resource-form" onSubmit={handleSubmit} onSubmitDisabledChange={setIsHttpModeDisabled} + initialSites={initialSites} /> diff --git a/src/components/PrivateResourceForm.tsx b/src/components/PrivateResourceForm.tsx index 856e18885..4a8b0b62b 100644 --- a/src/components/PrivateResourceForm.tsx +++ b/src/components/PrivateResourceForm.tsx @@ -208,6 +208,7 @@ type InternalResourceFormProps = { formId: string; onSubmit: (values: InternalResourceFormValues) => void | Promise; onSubmitDisabledChange?: (disabled: boolean) => void; + initialSites?: Selectedsite[]; }; export function PrivateResourceForm({ @@ -218,7 +219,8 @@ export function PrivateResourceForm({ siteResourceId, formId, onSubmit, - onSubmitDisabledChange + onSubmitDisabledChange, + initialSites = [] }: InternalResourceFormProps) { const t = useTranslations(); const { env } = useEnvContext(); @@ -609,6 +611,8 @@ export function PrivateResourceForm({ authDaemonMode === "remote"; const hasInitialized = useRef(false); const previousResourceId = useRef(null); + const initialSitesRef = useRef(initialSites); + initialSitesRef.current = initialSites; useEffect(() => { const tcpValue = getPortStringFromMode(tcpPortMode, tcpCustomPorts); @@ -623,9 +627,13 @@ export function PrivateResourceForm({ // Reset when create dialog opens useEffect(() => { if (variant === "create" && open) { + const prefillSites = + initialSitesRef.current.length > 0 + ? initialSitesRef.current + : []; form.reset({ name: "", - siteIds: [], + siteIds: prefillSites.map((s) => s.siteId), mode: "host", destination: "", alias: null, @@ -645,7 +653,7 @@ export function PrivateResourceForm({ users: [], clients: [] }); - setSelectedSites([]); + setSelectedSites(prefillSites); setSshServerMode("native"); setTcpPortMode("all"); setUdpPortMode("all"); diff --git a/src/components/PrivateResourcesTable.tsx b/src/components/PrivateResourcesTable.tsx index 396ba9759..0a356a059 100644 --- a/src/components/PrivateResourcesTable.tsx +++ b/src/components/PrivateResourcesTable.tsx @@ -187,6 +187,11 @@ export default function PrivateResourcesTable({ }; }, [initialFilterSite, siteIdQ, siteIdNum, t]); + const createInitialSites = useMemo( + () => (selectedSite ? [selectedSite] : undefined), + [selectedSite] + ); + const refreshData = () => { startRefreshTransition(() => { try { @@ -686,6 +691,7 @@ export default function PrivateResourcesTable({ open={isCreateDialogOpen} setOpen={setIsCreateDialogOpen} orgId={orgId} + initialSites={createInitialSites} onSuccess={() => { // Delay refresh to allow modal to close smoothly setTimeout(() => { From 9d3f96cf836d4683c1ccc846b559de16a402fedb Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 4 Jun 2026 16:31:45 -0700 Subject: [PATCH 010/114] Add disable_private_http_placeholder --- server/private/lib/readConfigFile.ts | 6 +++++- server/private/lib/traefik/getTraefikConfig.ts | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/server/private/lib/readConfigFile.ts b/server/private/lib/readConfigFile.ts index 087143007..565a0151a 100644 --- a/server/private/lib/readConfigFile.ts +++ b/server/private/lib/readConfigFile.ts @@ -109,7 +109,11 @@ export const privateConfigSchema = z enable_redis: z.boolean().optional().default(false), use_pangolin_dns: z.boolean().optional().default(false), use_org_only_idp: z.boolean().optional(), - enable_acme_cert_sync: z.boolean().optional().default(true) + enable_acme_cert_sync: z.boolean().optional().default(true), + disable_private_http_placeholder: z + .boolean() + .optional() + .default(false) }) .optional() .prefault({}), diff --git a/server/private/lib/traefik/getTraefikConfig.ts b/server/private/lib/traefik/getTraefikConfig.ts index a46033196..7ff452880 100644 --- a/server/private/lib/traefik/getTraefikConfig.ts +++ b/server/private/lib/traefik/getTraefikConfig.ts @@ -410,7 +410,11 @@ export async function getTraefikConfig( fullDomain: string | null; mode: "http" | "host" | "cidr" | "ssh"; }[] = []; - if (build == "enterprise") { + if ( + build == "enterprise" && + !privateConfig.getRawPrivateConfig().flags + .disable_private_http_placeholder + ) { // we dont want to do this on the cloud // Query siteResources in HTTP mode with SSL enabled and aliases - cert generation / HTTPS edge siteResourcesWithFullDomain = await db From 889f78ddb849fb70a433e5b056d17587f1f6ad03 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Thu, 4 Jun 2026 16:45:22 -0700 Subject: [PATCH 011/114] use resource name in ssh/rdp/vnc page meta --- .../browserGatewayTarget/getBrowserTarget.ts | 4 +- server/routers/browserGatewayTarget/types.ts | 1 + src/app/rdp/page.tsx | 31 ++----- src/app/ssh/page.tsx | 87 +++++++++---------- src/app/vnc/page.tsx | 30 ++----- src/lib/browserGatewayMetadata.ts | 13 +++ src/lib/getBrowserTargetForRequest.ts | 20 +++++ 7 files changed, 92 insertions(+), 94 deletions(-) create mode 100644 src/lib/browserGatewayMetadata.ts create mode 100644 src/lib/getBrowserTargetForRequest.ts diff --git a/server/private/routers/browserGatewayTarget/getBrowserTarget.ts b/server/private/routers/browserGatewayTarget/getBrowserTarget.ts index 7feda01e5..51e16de75 100644 --- a/server/private/routers/browserGatewayTarget/getBrowserTarget.ts +++ b/server/private/routers/browserGatewayTarget/getBrowserTarget.ts @@ -58,6 +58,7 @@ export async function getBrowserTarget( authToken: browserGatewayTarget.authToken, resourceId: resources.resourceId, niceId: resources.niceId, + name: resources.name, orgId: resources.orgId, pamMode: resources.pamMode, authDaemonMode: resources.authDaemonMode @@ -93,7 +94,8 @@ export async function getBrowserTarget( authDaemonMode: browserTarget.authDaemonMode, orgId: browserTarget.orgId, resourceId: browserTarget.resourceId, - niceId: browserTarget.niceId + niceId: browserTarget.niceId, + name: browserTarget.name }, success: true, error: false, diff --git a/server/routers/browserGatewayTarget/types.ts b/server/routers/browserGatewayTarget/types.ts index e644c952a..df6302391 100644 --- a/server/routers/browserGatewayTarget/types.ts +++ b/server/routers/browserGatewayTarget/types.ts @@ -5,6 +5,7 @@ export type GetBrowserTargetResponse = { orgId: string; resourceId: number; niceId: string; + name: string; pamMode: "passthrough" | "push" | null; authDaemonMode: "site" | "remote" | "native" | null; }; diff --git a/src/app/rdp/page.tsx b/src/app/rdp/page.tsx index 980edaf24..c6da3f4bf 100644 --- a/src/app/rdp/page.tsx +++ b/src/app/rdp/page.tsx @@ -1,34 +1,17 @@ -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 RdpClient from "./RdpClient"; import AuthFooter from "@app/components/AuthFooter"; 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; - const error: string | null = null; - - try { - const res = await priv.get>( - `/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 { target } = await getBrowserTargetForRequest(); + const error = target ? null : "No resource found for this domain"; return (

diff --git a/src/app/ssh/page.tsx b/src/app/ssh/page.tsx index 23cc9d908..44d5f1201 100644 --- a/src/app/ssh/page.tsx +++ b/src/app/ssh/page.tsx @@ -1,5 +1,7 @@ import { headers } from "next/headers"; import { priv } from "@app/lib/api"; +import { generateBrowserGatewayMetadata } from "@app/lib/browserGatewayMetadata"; +import { getBrowserTargetForRequest } from "@app/lib/getBrowserTargetForRequest"; import { AxiosResponse } from "axios"; import { GetBrowserTargetResponse } from "@server/routers/browserGatewayTarget"; import SshClient from "./SshClient"; @@ -99,14 +101,12 @@ 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 headersList = await headers(); - const host = headersList.get("host") || ""; - const hostname = host.split(":")[0]; const cookieHeader = headersList.get("cookie") || ""; let target: GetBrowserTargetResponse | null = null; @@ -114,49 +114,44 @@ export default async function SshPage() { let privateKey: string | null = null; let error: string | null = null; - try { - const res = await priv.get>( - `/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>( - `/org/${target.orgId}/ssh/sign-key`, - { - publicKey: publicKeyOpenSSH, - resourceId: target.resourceId, - type: "public" - }, - { - headers: { - Cookie: cookieHeader - } - } - ); - signedKeyData = res.data.data; - - 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?"; - } - } - } catch (err) { - console.error("Error fetching browser target:", err); + if (!target) { error = "No resource found for this domain"; + } else if (target.pamMode === "push") { + try { + const { privateKeyPem, publicKeyOpenSSH } = + generateEphemeralKeyPair(); + privateKey = privateKeyPem; + const res = await priv.post>( + `/org/${target.orgId}/ssh/sign-key`, + { + publicKey: publicKeyOpenSSH, + resourceId: target.resourceId, + type: "public" + }, + { + headers: { + Cookie: cookieHeader + } + } + ); + signedKeyData = res.data.data; + + 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?"; + } } return ( diff --git a/src/app/vnc/page.tsx b/src/app/vnc/page.tsx index 7de845578..85eec047b 100644 --- a/src/app/vnc/page.tsx +++ b/src/app/vnc/page.tsx @@ -1,33 +1,17 @@ -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 VncClient from "./VncClient"; import AuthFooter from "@app/components/AuthFooter"; 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; - const error: string | null = null; - - try { - const res = await priv.get>( - `/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 { target } = await getBrowserTargetForRequest(); + const error = target ? null : "No resource found for this domain"; return (
diff --git a/src/lib/browserGatewayMetadata.ts b/src/lib/browserGatewayMetadata.ts new file mode 100644 index 000000000..6d44c6b9e --- /dev/null +++ b/src/lib/browserGatewayMetadata.ts @@ -0,0 +1,13 @@ +import { getBrowserTargetForRequest } from "@app/lib/getBrowserTargetForRequest"; +import type { Metadata } from "next"; + +export async function generateBrowserGatewayMetadata( + protocol: "SSH" | "RDP" | "VNC" +): Promise { + const { target } = await getBrowserTargetForRequest(); + return { + title: target?.name + ? `${protocol} - ${target.name}` + : `${protocol} - Pangolin` + }; +} diff --git a/src/lib/getBrowserTargetForRequest.ts b/src/lib/getBrowserTargetForRequest.ts new file mode 100644 index 000000000..179e6e6f1 --- /dev/null +++ b/src/lib/getBrowserTargetForRequest.ts @@ -0,0 +1,20 @@ +import { priv } from "@app/lib/api"; +import { GetBrowserTargetResponse } from "@server/routers/browserGatewayTarget"; +import { AxiosResponse } from "axios"; +import { headers } from "next/headers"; +import { cache } from "react"; + +export const getBrowserTargetForRequest = cache(async () => { + const headersList = await headers(); + const host = headersList.get("host") || ""; + const hostname = host.split(":")[0]; + + try { + const res = await priv.get>( + `/resource/browser-target?fullDomain=${encodeURIComponent(hostname)}` + ); + return { target: res.data.data }; + } catch { + return { target: null }; + } +}); From 6affebc6660ed91f89b17923dd9f483fca4b1f81 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 4 Jun 2026 16:50:52 -0700 Subject: [PATCH 012/114] Finish adding ssh toggle --- messages/en-US.json | 3 ++- src/components/newt-install-commands.tsx | 34 +++++++++++++++--------- 2 files changed, 23 insertions(+), 14 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 0745f861a..0727a9a2d 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -3455,5 +3455,6 @@ "sshErrorNoTarget": "No target specified", "sshErrorWebSocket": "WebSocket connection failed", "sshErrorAuthFailed": "Authentication failed", - "sshErrorConnectionClosed": "Connection closed before authentication completed" + "sshErrorConnectionClosed": "Connection closed before authentication completed", + "sitePangolinSshDescription": "Allow SSH access to resources on this site. This can be changed later." } diff --git a/src/components/newt-install-commands.tsx b/src/components/newt-install-commands.tsx index ac8109eab..c2d6f48b6 100644 --- a/src/components/newt-install-commands.tsx +++ b/src/components/newt-install-commands.tsx @@ -286,25 +286,33 @@ WantedBy=default.target` label={t("siteAcceptClientConnections")} />
- {supportsSshOption && ( -
- { - const value = checked as boolean; - setAllowPangolinSsh(value); - }} - label="Allow Pangolin SSH" - /> -
- )}

{t("siteAcceptClientConnectionsDescription")}

+ {supportsSshOption && ( + <> +
+ { + const value = checked as boolean; + setAllowPangolinSsh(value); + }} + label="Allow Pangolin SSH" + /> +
+

+ {t("sitePangolinSshDescription")} +

+ + )}
From 567ef23ac40fb79ede9da03c029fd25c93050941 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 4 Jun 2026 16:53:57 -0700 Subject: [PATCH 013/114] Add initial advantech install commands --- src/components/newt-install-commands.tsx | 122 +++++++++++++++-------- 1 file changed, 78 insertions(+), 44 deletions(-) diff --git a/src/components/newt-install-commands.tsx b/src/components/newt-install-commands.tsx index c2d6f48b6..0d5ecad4c 100644 --- a/src/components/newt-install-commands.tsx +++ b/src/components/newt-install-commands.tsx @@ -10,7 +10,14 @@ import { import { CheckboxWithLabel } from "./ui/checkbox"; import { OptionSelect, type OptionSelectOption } from "./OptionSelect"; import { useState } from "react"; -import { FaApple, FaCubes, FaDocker, FaLinux, FaWindows } from "react-icons/fa"; +import { + FaApple, + FaCubes, + FaDocker, + FaHdd, + FaLinux, + FaWindows +} from "react-icons/fa"; import { SiKubernetes, SiNixos } from "react-icons/si"; export type CommandItem = string | { title: string; command: string }; @@ -20,6 +27,7 @@ const PLATFORMS = [ "macos", "docker", "kubernetes", + "advantech", "podman", "nixos", "windows" @@ -49,6 +57,7 @@ export function NewtSiteInstallCommands({ () => getArchitectures(platform)[0] ); + const showSiteConfiguration = platform !== "advantech"; const supportsSshOption = platform === "linux" || platform === "nixos"; const acceptClientsFlag = !acceptClients ? " --disable-clients" : ""; @@ -193,6 +202,9 @@ sudo systemctl enable --now newt` --set-string newtInstances[0].auth.existingSecretName="newt-main-tunnel-auth"${acceptClientsHelmValue}` ] }, + advantech: { + Documentation: [] + }, podman: { "Podman Quadlet": [ `[Unit] @@ -270,50 +282,52 @@ WantedBy=default.target` className="mt-4" /> -
-

- {t("siteConfiguration")} -

-
- { - const value = checked as boolean; - setAcceptClients(value); - }} - label={t("siteAcceptClientConnections")} - /> + {showSiteConfiguration && ( +
+

+ {t("siteConfiguration")} +

+
+ { + const value = checked as boolean; + setAcceptClients(value); + }} + label={t("siteAcceptClientConnections")} + /> +
+

+ {t("siteAcceptClientConnectionsDescription")} +

+ {supportsSshOption && ( + <> +
+ { + const value = checked as boolean; + setAllowPangolinSsh(value); + }} + label="Allow Pangolin SSH" + /> +
+

+ {t("sitePangolinSshDescription")} +

+ + )}
-

- {t("siteAcceptClientConnectionsDescription")} -

- {supportsSshOption && ( - <> -
- { - const value = checked as boolean; - setAllowPangolinSsh(value); - }} - label="Allow Pangolin SSH" - /> -
-

- {t("sitePangolinSshDescription")} -

- - )} -
+ )}

{t("commands")}

@@ -332,6 +346,20 @@ WantedBy=default.target` .

)} + {platform === "advantech" && ( +

+ For Advantech modem installation instructions, see{" "} + + docs.pangolin.net/manage/sites/install-advantech + + . +

+ )}
{commands.map((item, index) => { const commandText = @@ -376,6 +404,8 @@ function getPlatformIcon(platformName: Platform) { return ; case "kubernetes": return ; + case "advantech": + return ; case "podman": return ; case "nixos": @@ -397,6 +427,8 @@ function getPlatformName(platformName: Platform) { return "Docker"; case "kubernetes": return "Kubernetes"; + case "advantech": + return "Advantech"; case "podman": return "Podman"; case "nixos": @@ -418,6 +450,8 @@ function getArchitectures(platform: Platform) { return ["Docker Compose", "Docker Run"]; case "kubernetes": return ["Helm Chart"]; + case "advantech": + return ["Documentation"]; case "podman": return ["Podman Quadlet", "Podman Run"]; case "nixos": From b2f1115ef83cded6766e0ef5df55b311b3e997cb Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Thu, 4 Jun 2026 17:23:49 -0700 Subject: [PATCH 014/114] standardize and fix branding on new resources auth pages --- src/app/rdp/RdpClient.tsx | 43 ++++++---------------- src/app/rdp/page.tsx | 10 ++++- src/app/ssh/SshClient.tsx | 46 +++++++---------------- src/app/ssh/page.tsx | 6 +++ src/app/vnc/VncClient.tsx | 43 ++++++---------------- src/app/vnc/page.tsx | 10 ++++- src/components/BrandedAuthSurface.tsx | 26 +++++++++++++ src/components/OrgLoginPage.tsx | 23 +++--------- src/components/PoweredByPangolin.tsx | 53 +++++++++++++++++++++++++++ src/components/ResourceAuthPortal.tsx | 50 ++++--------------------- src/lib/loadOrgLoginPageBranding.ts | 31 ++++++++++++++++ 11 files changed, 181 insertions(+), 160 deletions(-) create mode 100644 src/components/BrandedAuthSurface.tsx create mode 100644 src/components/PoweredByPangolin.tsx create mode 100644 src/lib/loadOrgLoginPageBranding.ts diff --git a/src/app/rdp/RdpClient.tsx b/src/app/rdp/RdpClient.tsx index d4b708fbf..721fd037b 100644 --- a/src/app/rdp/RdpClient.tsx +++ b/src/app/rdp/RdpClient.tsx @@ -22,7 +22,8 @@ import { CardTitle, CardDescription } from "@app/components/ui/card"; -import Link from "next/link"; +import BrandedAuthSurface from "@app/components/BrandedAuthSurface"; +import PoweredByPangolin from "@app/components/PoweredByPangolin"; declare module "react" { namespace JSX { @@ -60,10 +61,12 @@ 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 STORAGE_KEY = "pangolin_rdp_credentials"; @@ -315,20 +318,8 @@ export default function RdpClient({ if (error) { return ( -
-
- - Powered by{" "} - - Pangolin - - -
+ + RDP @@ -337,27 +328,15 @@ export default function RdpClient({

{error}

-
+ ); } return ( <> {showLogin && ( -
-
- - Powered by{" "} - - Pangolin - - -
+ + Sign in to Remote Desktop @@ -441,7 +420,7 @@ export default function RdpClient({
-
+ )}
- +
diff --git a/src/app/ssh/SshClient.tsx b/src/app/ssh/SshClient.tsx index 4ba1c9211..945963ec0 100644 --- a/src/app/ssh/SshClient.tsx +++ b/src/app/ssh/SshClient.tsx @@ -20,6 +20,8 @@ import { Alert, AlertDescription } from "@/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"; @@ -40,12 +42,14 @@ 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"; @@ -377,20 +381,8 @@ export default function SshClient({ if (error) { return ( -
-
- - {t("sshPoweredBy")}{" "} - - Pangolin - - -
+ + {t("sshTitle")} @@ -399,27 +391,15 @@ export default function SshClient({

{error}

-
+ ); } return ( <> {!connected && ( -
-
- - {t("sshPoweredBy")}{" "} - - Pangolin - - -
+ + {t("sshSignInTitle")} @@ -496,10 +476,10 @@ export default function SshClient({ href="https://docs.pangolin.net/" target="_blank" rel="noopener noreferrer" - className="underline inline-flex items-center gap-1" + className="text-primary hover:underline inline-flex items-center gap-1" > {t("sshLearnMore")} - +

-
+ )} {connected && ( diff --git a/src/app/ssh/page.tsx b/src/app/ssh/page.tsx index 44d5f1201..5e2e057b0 100644 --- a/src/app/ssh/page.tsx +++ b/src/app/ssh/page.tsx @@ -2,6 +2,7 @@ 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"; @@ -154,6 +155,10 @@ export default async function SshPage() { } } + const { primaryColor } = target + ? await loadOrgLoginPageBranding(target.orgId) + : { primaryColor: null }; + return (
@@ -163,6 +168,7 @@ export default async function SshPage() { error={error} signedKeyData={signedKeyData} privateKey={privateKey} + primaryColor={primaryColor} />
diff --git a/src/app/vnc/VncClient.tsx b/src/app/vnc/VncClient.tsx index 03857169e..7a93537fd 100644 --- a/src/app/vnc/VncClient.tsx +++ b/src/app/vnc/VncClient.tsx @@ -13,7 +13,8 @@ import { CardTitle, CardDescription } from "@app/components/ui/card"; -import Link from "next/link"; +import BrandedAuthSurface from "@app/components/BrandedAuthSurface"; +import PoweredByPangolin from "@app/components/PoweredByPangolin"; type FormState = { password: string; @@ -21,10 +22,12 @@ type FormState = { export default function VncClient({ target, - error + error, + primaryColor }: { target: GetBrowserTargetResponse | null; error: string | null; + primaryColor?: string | null; }) { const STORAGE_KEY = "pangolin_vnc_credentials"; @@ -152,20 +155,8 @@ export default function VncClient({ if (error) { return ( -
-
- - Powered by{" "} - - Pangolin - - -
+ + VNC @@ -174,27 +165,15 @@ export default function VncClient({

{error}

-
+ ); } return ( <> {!connected && ( -
-
- - Powered by{" "} - - Pangolin - - -
+ + VNC @@ -224,7 +203,7 @@ export default function VncClient({
-
+ )}
- +
diff --git a/src/components/BrandedAuthSurface.tsx b/src/components/BrandedAuthSurface.tsx new file mode 100644 index 000000000..2b12808aa --- /dev/null +++ b/src/components/BrandedAuthSurface.tsx @@ -0,0 +1,26 @@ +"use client"; + +import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; + +type BrandedAuthSurfaceProps = { + primaryColor?: string | null; + children: React.ReactNode; +}; + +export default function BrandedAuthSurface({ + primaryColor, + children +}: BrandedAuthSurfaceProps) { + const { isUnlocked } = useLicenseStatusContext(); + + return ( +
+ {children} +
+ ); +} diff --git a/src/components/OrgLoginPage.tsx b/src/components/OrgLoginPage.tsx index 3270b7cb4..d70c278cf 100644 --- a/src/components/OrgLoginPage.tsx +++ b/src/components/OrgLoginPage.tsx @@ -14,9 +14,10 @@ import { import { Button } from "@app/components/ui/button"; import Link from "next/link"; import { replacePlaceholder } from "@app/lib/replacePlaceholder"; +import PoweredByPangolin from "@app/components/PoweredByPangolin"; +import BrandedAuthSurface from "@app/components/BrandedAuthSurface"; import { getTranslations } from "next-intl/server"; import { pullEnv } from "@app/lib/pullEnv"; -import { build } from "@server/build"; type OrgLoginPageProps = { loginPage: LoadLoginPageResponse | undefined; @@ -52,22 +53,8 @@ export default async function OrgLoginPage({ const env = pullEnv(); const t = await getTranslations(); return ( -
- {build !== "enterprise" || !env.branding.hidePoweredBy ? ( -
- - {t("poweredBy")}{" "} - - {env.branding.appName || "Pangolin"} - - -
- ) : null} + + {branding?.logoUrl && ( @@ -127,6 +114,6 @@ export default async function OrgLoginPage({ {t("loginBack")}

-
+ ); } diff --git a/src/components/PoweredByPangolin.tsx b/src/components/PoweredByPangolin.tsx new file mode 100644 index 000000000..cca479a4f --- /dev/null +++ b/src/components/PoweredByPangolin.tsx @@ -0,0 +1,53 @@ +"use client"; + +import Link from "next/link"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; +import { useTranslations } from "next-intl"; +import { build } from "@server/build"; + +function PoweredByLabel({ brandName }: { brandName: string }) { + const t = useTranslations(); + + return ( +
+ + {t("poweredBy")}{" "} + {brandName === "Pangolin" ? ( + + Pangolin + + ) : ( + brandName + )} + +
+ ); +} + +export default function PoweredByPangolin() { + const { env } = useEnvContext(); + const { isUnlocked } = useLicenseStatusContext(); + + if (isUnlocked() && build === "enterprise") { + if ( + env.branding.resourceAuthPage?.hidePoweredBy || + env.branding.hidePoweredBy + ) { + return null; + } + + return ( + + ); + } + + return ; +} diff --git a/src/components/ResourceAuthPortal.tsx b/src/components/ResourceAuthPortal.tsx index 64e1d2725..018a08179 100644 --- a/src/components/ResourceAuthPortal.tsx +++ b/src/components/ResourceAuthPortal.tsx @@ -41,8 +41,9 @@ import { } from "@app/actions/server"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { toast } from "@app/hooks/useToast"; -import Link from "next/link"; import BrandingLogo from "@app/components/BrandingLogo"; +import BrandedAuthSurface from "@app/components/BrandedAuthSurface"; +import PoweredByPangolin from "@app/components/PoweredByPangolin"; import { useSupporterStatusContext } from "@app/hooks/useSupporterStatusContext"; import { useTranslations } from "next-intl"; import { build } from "@server/build"; @@ -366,57 +367,20 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) { : 100; return ( -
+ {!accessDenied ? (
- {isUnlocked() && build === "enterprise" ? ( - !env.branding.resourceAuthPage?.hidePoweredBy && - !env.branding.hidePoweredBy && ( -
- - {t("poweredBy")}{" "} - - {env.branding.appName || "Pangolin"} - - -
- ) - ) : ( -
- - {t("poweredBy")}{" "} - - Pangolin - - -
- )} + {isUnlocked() && build !== "oss" && - (env.branding?.resourceAuthPage?.showLogo || - props.branding) && ( + props.branding?.logoUrl && (
)} @@ -790,6 +754,6 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) { ) : ( )} -
+
); } diff --git a/src/lib/loadOrgLoginPageBranding.ts b/src/lib/loadOrgLoginPageBranding.ts new file mode 100644 index 000000000..7e549622a --- /dev/null +++ b/src/lib/loadOrgLoginPageBranding.ts @@ -0,0 +1,31 @@ +import { priv } from "@app/lib/api"; +import { isOrgSubscribed } from "@app/lib/api/isOrgSubscribed"; +import { build } from "@server/build"; +import { LoadLoginPageBrandingResponse } from "@server/routers/loginPage/types"; +import { AxiosResponse } from "axios"; + +export async function loadOrgLoginPageBranding(orgId: string): Promise<{ + primaryColor: string | null; +}> { + if (build === "oss") { + return { primaryColor: null }; + } + + const subscribed = await isOrgSubscribed(orgId); + if (!subscribed) { + return { primaryColor: null }; + } + + try { + const res = await priv.get< + AxiosResponse + >(`/login-page-branding?orgId=${orgId}`); + if (res.status === 200) { + return { primaryColor: res.data.data.primaryColor ?? null }; + } + } catch { + // ignore + } + + return { primaryColor: null }; +} From 6b04bcb383a370398a58d457d9ee813b701f9bfe Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Thu, 4 Jun 2026 17:35:43 -0700 Subject: [PATCH 015/114] translate strings in auth pages for ssh, vnc, and rdp --- messages/en-US.json | 38 +++++++++++++++++++++- src/app/rdp/RdpClient.tsx | 67 ++++++++++++++++++++++----------------- src/app/rdp/page.tsx | 4 ++- src/app/ssh/SshClient.tsx | 8 +++-- src/app/ssh/page.tsx | 7 ++-- src/app/vnc/VncClient.tsx | 32 +++++++++++-------- src/app/vnc/page.tsx | 4 ++- 7 files changed, 109 insertions(+), 51 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 0727a9a2d..4d4a41e43 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -3456,5 +3456,41 @@ "sshErrorWebSocket": "WebSocket connection failed", "sshErrorAuthFailed": "Authentication failed", "sshErrorConnectionClosed": "Connection closed before authentication completed", - "sitePangolinSshDescription": "Allow SSH access to resources on this site. This can be changed later." + "sitePangolinSshDescription": "Allow SSH access to resources on this site. This can be changed later.", + "browserGatewayNoResourceForDomain": "No resource found for this domain", + "browserGatewayNoTarget": "No target", + "browserGatewayConnect": "Connect", + "browserGatewayCtrlAltDel": "Ctrl+Alt+Del", + "sshErrorSignKeyFailed": "Failed to sign SSH key for PAM push authentication. Did you sign in as a user?", + "sshTerminalError": "Error: {error}", + "sshConnectionClosedCode": "Connection closed (code {code})", + "sshPrivateKeyPlaceholder": "-----BEGIN OPENSSH PRIVATE KEY-----", + "vncTitle": "VNC", + "vncSignInDescription": "Enter your VNC password to connect", + "vncPasswordOptional": "Password (optional)", + "vncNoResourceTarget": "No resource target is available", + "vncFailedToLoadNovnc": "Failed to load noVNC", + "vncAuthFailedStatus": "Status {status}", + "vncPasteClipboard": "Paste clipboard", + "rdpTitle": "RDP", + "rdpSignInTitle": "Sign in to Remote Desktop", + "rdpSignInDescription": "Enter Windows credentials to connect", + "rdpLoadingModule": "Loading module...", + "rdpFailedToLoadModule": "Failed to load RDP module", + "rdpNotReady": "Not ready", + "rdpModuleInitializing": "RDP module is still initializing", + "rdpDownloadingFiles": "Downloading {count} file(s) from remote…", + "rdpDownloadFailed": "Download failed: {fileName}", + "rdpUploaded": "Uploaded: {fileName}", + "rdpNoConnectionTarget": "No connection target available", + "rdpConnectionFailed": "Connection failed", + "rdpFit": "Fit", + "rdpFull": "Full", + "rdpReal": "Real", + "rdpMeta": "Meta", + "rdpUploadFiles": "Upload files", + "rdpFilesReadyToPaste": "Files ready to paste", + "rdpFilesReadyToPasteDescription": "{count} file(s) copied to remote clipboard — press Ctrl+V on the remote desktop to paste.", + "rdpUploadFailed": "Upload failed", + "rdpUnicodeKeyboardMode": "Unicode keyboard mode" } diff --git a/src/app/rdp/RdpClient.tsx b/src/app/rdp/RdpClient.tsx index 721fd037b..def63fff0 100644 --- a/src/app/rdp/RdpClient.tsx +++ b/src/app/rdp/RdpClient.tsx @@ -24,6 +24,7 @@ import { } from "@app/components/ui/card"; import BrandedAuthSurface from "@app/components/BrandedAuthSurface"; import PoweredByPangolin from "@app/components/PoweredByPangolin"; +import { useTranslations } from "next-intl"; declare module "react" { namespace JSX { @@ -68,6 +69,7 @@ export default function RdpClient({ error: string | null; primaryColor?: string | null; }) { + const t = useTranslations(); const STORAGE_KEY = "pangolin_rdp_credentials"; const [form, setForm] = useState(() => { @@ -141,7 +143,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}` }); }); @@ -175,8 +177,8 @@ export default function RdpClient({ setConnecting(false); toast({ variant: "destructive", - title: "Not ready", - description: "RDP module is still initializing" + title: t("rdpNotReady"), + description: t("rdpModuleInitializing") }); return; } @@ -196,7 +198,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]; @@ -214,7 +218,9 @@ export default function RdpClient({ .catch((err) => { toast({ variant: "destructive", - title: `Download failed: ${file.name}`, + title: t("rdpDownloadFailed", { + fileName: file.name + }), description: `${err}` }); }); @@ -223,7 +229,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 @@ -237,8 +243,8 @@ export default function RdpClient({ setConnecting(false); toast({ variant: "destructive", - title: "No target", - description: "No connection target available" + title: t("browserGatewayNoTarget"), + description: t("rdpNoConnectionTarget") }); return; } @@ -290,13 +296,13 @@ export default function RdpClient({ if (isIronError(err)) { toast({ variant: "destructive", - title: "Connection failed", + title: t("rdpConnectionFailed"), description: err.backtrace() }); } else { toast({ variant: "destructive", - title: "Connection failed", + title: t("rdpConnectionFailed"), description: `${err}` }); } @@ -322,7 +328,7 @@ export default function RdpClient({ - RDP + {t("rdpTitle")}

{error}

@@ -339,14 +345,14 @@ export default function RdpClient({ - Sign in to Remote Desktop + {t("rdpSignInTitle")} - Enter Windows credentials to access xxxx + {t("rdpSignInDescription")}
- + - + - + {moduleReady - ? "Connect" - : "Loading module..."} + ? t("browserGatewayConnect") + : t("rdpLoadingModule")}
@@ -433,35 +439,35 @@ export default function RdpClient({ variant="secondary" onClick={() => ui()?.setScale(1)} > - Fit + {t("rdpFit")} {/*
diff --git a/src/app/rdp/page.tsx b/src/app/rdp/page.tsx index 408a95dea..b7190b428 100644 --- a/src/app/rdp/page.tsx +++ b/src/app/rdp/page.tsx @@ -3,6 +3,7 @@ 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"; @@ -11,8 +12,9 @@ export async function generateMetadata() { } export default async function RdpPage() { + const t = await getTranslations(); const { target } = await getBrowserTargetForRequest(); - const error = target ? null : "No resource found for this domain"; + const error = target ? null : t("browserGatewayNoResourceForDomain"); const { primaryColor } = target ? await loadOrgLoginPageBranding(target.orgId) : { primaryColor: null }; diff --git a/src/app/ssh/SshClient.tsx b/src/app/ssh/SshClient.tsx index 945963ec0..e4ca9c806 100644 --- a/src/app/ssh/SshClient.tsx +++ b/src/app/ssh/SshClient.tsx @@ -274,7 +274,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` ); } } @@ -309,7 +309,7 @@ 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; @@ -510,7 +510,9 @@ export default function SshClient({ privateKey: e.target.value }) } - placeholder="-----BEGIN OPENSSH PRIVATE KEY-----" + placeholder={t( + "sshPrivateKeyPlaceholder" + )} rows={5} className="font-mono text-xs" /> diff --git a/src/app/ssh/page.tsx b/src/app/ssh/page.tsx index 5e2e057b0..8ab62d110 100644 --- a/src/app/ssh/page.tsx +++ b/src/app/ssh/page.tsx @@ -9,6 +9,7 @@ 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; @@ -107,6 +108,7 @@ export async function generateMetadata() { } export default async function SshPage() { + const t = await getTranslations(); const headersList = await headers(); const cookieHeader = headersList.get("cookie") || ""; @@ -119,7 +121,7 @@ export default async function SshPage() { target = browserTarget; if (!target) { - error = "No resource found for this domain"; + error = t("browserGatewayNoResourceForDomain"); } else if (target.pamMode === "push") { try { const { privateKeyPem, publicKeyOpenSSH } = @@ -150,8 +152,7 @@ export default async function SshPage() { 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?"; + error = t("sshErrorSignKeyFailed"); } } diff --git a/src/app/vnc/VncClient.tsx b/src/app/vnc/VncClient.tsx index 7a93537fd..cec474df3 100644 --- a/src/app/vnc/VncClient.tsx +++ b/src/app/vnc/VncClient.tsx @@ -15,6 +15,7 @@ import { } from "@app/components/ui/card"; import BrandedAuthSurface from "@app/components/BrandedAuthSurface"; import PoweredByPangolin from "@app/components/PoweredByPangolin"; +import { useTranslations } from "next-intl"; type FormState = { password: string; @@ -29,6 +30,7 @@ export default function VncClient({ error: string | null; primaryColor?: string | null; }) { + const t = useTranslations(); const STORAGE_KEY = "pangolin_vnc_credentials"; const [form, setForm] = useState(() => { @@ -67,8 +69,8 @@ export default function VncClient({ if (!target) { toast({ variant: "destructive", - title: "No target", - description: "No resource target is available" + title: t("browserGatewayNoTarget"), + description: t("vncNoResourceTarget") }); return; } @@ -92,7 +94,7 @@ export default function VncClient({ } catch (err) { toast({ variant: "destructive", - title: "Failed to load noVNC", + title: t("vncFailedToLoadNovnc"), description: `${err}` }); return; @@ -144,8 +146,12 @@ export default function VncClient({ (e: { detail: { status: number; reason?: string } }) => { toast({ variant: "destructive", - title: "Authentication failed", - description: e.detail.reason ?? `Status ${e.detail.status}` + title: t("sshErrorAuthFailed"), + description: + e.detail.reason ?? + t("vncAuthFailedStatus", { + status: e.detail.status + }) }); } ); @@ -159,7 +165,7 @@ export default function VncClient({ - VNC + {t("vncTitle")}

{error}

@@ -176,15 +182,15 @@ export default function VncClient({ - VNC + {t("vncTitle")} - Enter your credentials to access xxxx + {t("vncSignInDescription")}
@@ -220,7 +226,7 @@ export default function VncClient({ } }} > - Ctrl+Alt+Del + {t("browserGatewayCtrlAltDel")}
diff --git a/src/app/vnc/page.tsx b/src/app/vnc/page.tsx index b7ec90c59..01c8c15cf 100644 --- a/src/app/vnc/page.tsx +++ b/src/app/vnc/page.tsx @@ -3,6 +3,7 @@ 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"; @@ -11,8 +12,9 @@ export async function generateMetadata() { } export default async function VncPage() { + const t = await getTranslations(); const { target } = await getBrowserTargetForRequest(); - const error = target ? null : "No resource found for this domain"; + const error = target ? null : t("browserGatewayNoResourceForDomain"); const { primaryColor } = target ? await loadOrgLoginPageBranding(target.orgId) : { primaryColor: null }; From ff507f12759f3e8a2a519954ed995c695349d4ee Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Thu, 4 Jun 2026 17:44:45 -0700 Subject: [PATCH 016/114] use standard error alert --- src/app/rdp/RdpClient.tsx | 39 ++++++++++++++++++--------------------- src/app/ssh/SshClient.tsx | 28 +++++++++++++++------------- src/app/vnc/VncClient.tsx | 32 ++++++++++++++++++++------------ 3 files changed, 53 insertions(+), 46 deletions(-) diff --git a/src/app/rdp/RdpClient.tsx b/src/app/rdp/RdpClient.tsx index def63fff0..9b5292b1b 100644 --- a/src/app/rdp/RdpClient.tsx +++ b/src/app/rdp/RdpClient.tsx @@ -22,6 +22,7 @@ import { CardTitle, CardDescription } from "@app/components/ui/card"; +import { Alert, AlertDescription } from "@app/components/ui/alert"; import BrandedAuthSurface from "@app/components/BrandedAuthSurface"; import PoweredByPangolin from "@app/components/PoweredByPangolin"; import { useTranslations } from "next-intl"; @@ -92,6 +93,7 @@ export default function RdpClient({ const [showLogin, setShowLogin] = useState(true); const [moduleReady, setModuleReady] = useState(false); const [connecting, setConnecting] = useState(false); + const [submitError, setSubmitError] = useState(null); const [unicodeMode, setUnicodeMode] = useState(false); const [cursorOverrideActive, setCursorOverrideActive] = useState(false); @@ -170,16 +172,13 @@ export default function RdpClient({ }; const startSession = async () => { + setSubmitError(null); setConnecting(true); const userInteraction = userInteractionRef.current; const exts = extensionsRef.current; if (!userInteraction || !exts) { setConnecting(false); - toast({ - variant: "destructive", - title: t("rdpNotReady"), - description: t("rdpModuleInitializing") - }); + setSubmitError(t("rdpModuleInitializing")); return; } @@ -241,11 +240,7 @@ export default function RdpClient({ if (!target) { setConnecting(false); - toast({ - variant: "destructive", - title: t("browserGatewayNoTarget"), - description: t("rdpNoConnectionTarget") - }); + setSubmitError(t("rdpNoConnectionTarget")); return; } @@ -294,17 +289,9 @@ export default function RdpClient({ setConnecting(false); setShowLogin(true); if (isIronError(err)) { - toast({ - variant: "destructive", - title: t("rdpConnectionFailed"), - description: err.backtrace() - }); + setSubmitError(err.backtrace()); } else { - toast({ - variant: "destructive", - title: t("rdpConnectionFailed"), - description: `${err}` - }); + setSubmitError(`${err}`); } } }; @@ -331,7 +318,9 @@ export default function RdpClient({ {t("rdpTitle")} -

{error}

+ + {error} +
@@ -413,6 +402,14 @@ export default function RdpClient({ Enable Clipboard
*/} + {submitError && ( + + + {submitError} + + + )} + From d2793dfad713f585f2f62c0c0d9ed62f2cf875db Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Thu, 4 Jun 2026 18:07:50 -0700 Subject: [PATCH 017/114] 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 - }) - } - /> - - -