Rework page to be functional

This commit is contained in:
Owen
2026-05-22 16:09:02 -07:00
parent 9d77fcc457
commit 7c54df7ed1
3 changed files with 476 additions and 180 deletions

View File

@@ -1975,6 +1975,9 @@
"sshServerDescription": "Set up the authentication method, daemon location, and server destination", "sshServerDescription": "Set up the authentication method, daemon location, and server destination",
"sshServerMode": "Mode", "sshServerMode": "Mode",
"sshServerModeStandard": "Standard SSH Server", "sshServerModeStandard": "Standard SSH Server",
"sshServerModeStandardDescription": "Uses a Pangolin auth daemon to manage SSH authentication on the site or remote host.",
"sshServerModeNative": "Native SSH Server",
"sshServerModeNativeDescription": "SSH authentication is handled natively by an existing SSH server without a separate auth daemon.",
"sshAuthenticationMethod": "Authentication Method", "sshAuthenticationMethod": "Authentication Method",
"sshAuthMethodManual": "Manual Authentication", "sshAuthMethodManual": "Manual Authentication",
"sshAuthMethodManualDescription": "Requires existing host credentials. Bypasses automatic provisioning.", "sshAuthMethodManualDescription": "Requires existing host credentials. Bypasses automatic provisioning.",
@@ -1989,6 +1992,7 @@
"sshServerDestination": "Server Destination", "sshServerDestination": "Server Destination",
"sshServerDestinationDescription": "Configure the destination and port of the SSH server", "sshServerDestinationDescription": "Configure the destination and port of the SSH server",
"destination": "Destination", "destination": "Destination",
"bgTargetMultiSiteDisclaimer": "Selecting multiple sites enables resilient routing and failover for high availability.",
"sshAccess": "SSH Access", "sshAccess": "SSH Access",
"roleAllowSsh": "Allow SSH", "roleAllowSsh": "Allow SSH",
"roleAllowSshAllow": "Allow", "roleAllowSshAllow": "Allow",

View File

@@ -5,33 +5,63 @@ import {
SettingsSection, SettingsSection,
SettingsSectionBody, SettingsSectionBody,
SettingsSectionDescription, SettingsSectionDescription,
SettingsSectionForm,
SettingsSectionHeader, SettingsSectionHeader,
SettingsSectionTitle SettingsSectionTitle
} from "@app/components/Settings"; } from "@app/components/Settings";
import { StrategySelect, StrategyOption } from "@app/components/StrategySelect"; import { StrategySelect, StrategyOption } from "@app/components/StrategySelect";
import { BrowserGatewayTargetForm } from "@app/components/BrowserGatewayTargetForm";
import {
SitesSelector,
type Selectedsite
} from "@app/components/site-selector";
import { Button } from "@app/components/ui/button"; import { Button } from "@app/components/ui/button";
import { Input } from "@app/components/ui/input"; import { Input } from "@app/components/ui/input";
import { import {
Select, Form,
SelectContent, FormControl,
SelectItem, FormField,
SelectTrigger, FormItem,
SelectValue FormLabel,
} from "@app/components/ui/select"; FormMessage
import { ExternalLink } from "lucide-react"; } from "@app/components/ui/form";
import {
Popover,
PopoverContent,
PopoverTrigger
} from "@app/components/ui/popover";
import { ChevronsUpDown, ExternalLink } from "lucide-react";
import { toast } from "@app/hooks/useToast"; import { toast } from "@app/hooks/useToast";
import { useResourceContext } from "@app/hooks/useResourceContext"; import { useResourceContext } from "@app/hooks/useResourceContext";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import { createApiClient } from "@app/lib/api"; import { createApiClient } from "@app/lib/api";
import { formatAxiosError } from "@app/lib/api/formatAxiosError"; import { formatAxiosError } from "@app/lib/api/formatAxiosError";
import { orgQueries } from "@app/lib/queries";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { use, useActionState, useEffect, useState } from "react"; import { use, useActionState, useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { GetResourceResponse } from "@server/routers/resource"; import { GetResourceResponse } from "@server/routers/resource";
import type { ResourceContextType } from "@app/contexts/resourceContext"; import type { ResourceContextType } from "@app/contexts/resourceContext";
type ExistingTarget = {
browserGatewayTargetId: number;
siteId: number;
};
const sshFormSchema = z.object({
authDaemonPort: z.string().refine(
(val) => {
if (!val) return true;
const n = Number(val);
return Number.isInteger(n) && n >= 1 && n <= 65535;
},
{ message: "Port must be between 1 and 65535" }
)
});
export default function SshSettingsPage(props: { export default function SshSettingsPage(props: {
params: Promise<{ orgId: string }>; params: Promise<{ orgId: string }>;
}) { }) {
@@ -62,24 +92,48 @@ function SshServerForm({
const api = createApiClient(useEnvContext()); const api = createApiClient(useEnvContext());
const router = useRouter(); const router = useRouter();
const isNativeInitially = resource.authDaemonMode === "native";
const [sshServerMode, setSshServerMode] = useState<"standard" | "native">(
isNativeInitially ? "native" : "standard"
);
const isNative = sshServerMode === "native";
const [pamMode, setPamMode] = useState<"passthrough" | "push">( const [pamMode, setPamMode] = useState<"passthrough" | "push">(
(resource.pamMode as "passthrough" | "push") || "passthrough" (resource.pamMode as "passthrough" | "push") || "passthrough"
); );
const [authDaemonMode, setAuthDaemonMode] = useState<"site" | "remote">(
(resource.authDaemonMode as "site" | "remote") || "site" const [standardDaemonLocation, setStandardDaemonLocation] = useState<
); "site" | "remote"
const [authDaemonPort, setAuthDaemonPort] = useState<string>( >(
(resource as any).authDaemonPort isNativeInitially
? String((resource as any).authDaemonPort) ? "site"
: "22123" : (resource.authDaemonMode as "site" | "remote") || "site"
); );
const form = useForm({
resolver: zodResolver(sshFormSchema),
defaultValues: {
authDaemonPort: (resource as any).authDaemonPort
? String((resource as any).authDaemonPort)
: "22123"
}
});
// Standard mode: multi-site
const [selectedSites, setSelectedSites] = useState<Selectedsite[]>([]);
const [bgDestination, setBgDestination] = useState(""); const [bgDestination, setBgDestination] = useState("");
const [bgDestinationPort, setBgDestinationPort] = useState("22"); const [bgDestinationPort, setBgDestinationPort] = useState("22");
const [bgSiteId, setBgSiteId] = useState<number | null>(null); const [existingTargets, setExistingTargets] = useState<ExistingTarget[]>(
const [bgTargetId, setBgTargetId] = useState<number | null>(null); []
);
const { data: sites = [] } = useQuery(orgQueries.sites({ orgId })); // Native mode: single site
const [selectedNativeSite, setSelectedNativeSite] =
useState<Selectedsite | null>(null);
const [nativeExistingTarget, setNativeExistingTarget] =
useState<ExistingTarget | null>(null);
const [nativeSiteOpen, setNativeSiteOpen] = useState(false);
const { data: bgTargetsResponse } = useQuery({ const { data: bgTargetsResponse } = useQuery({
queryKey: ["browserGatewayTargets", resource.resourceId, orgId], queryKey: ["browserGatewayTargets", resource.resourceId, orgId],
@@ -92,6 +146,7 @@ function SshServerForm({
browserGatewayTargetId: number; browserGatewayTargetId: number;
resourceId: number; resourceId: number;
siteId: number; siteId: number;
siteName?: string;
type: string; type: string;
destination: string; destination: string;
destinationPort: number; destinationPort: number;
@@ -102,53 +157,154 @@ function SshServerForm({
useEffect(() => { useEffect(() => {
if (!bgTargetsResponse?.targets?.length) return; if (!bgTargetsResponse?.targets?.length) return;
const bgt = bgTargetsResponse.targets[0]; const targets = bgTargetsResponse.targets;
setBgDestination(bgt.destination); const first = targets[0];
setBgDestinationPort(String(bgt.destinationPort)); if (isNativeInitially) {
setBgSiteId(bgt.siteId); setSelectedNativeSite({
setBgTargetId(bgt.browserGatewayTargetId); siteId: first.siteId,
}, [bgTargetsResponse]); name: first.siteName ?? String(first.siteId),
type: "newt" as const
useEffect(() => { });
if (sites.length > 0 && bgSiteId === null) { setNativeExistingTarget({
setBgSiteId(sites[0].siteId); browserGatewayTargetId: first.browserGatewayTargetId,
siteId: first.siteId
});
} else {
setBgDestination(first.destination);
setBgDestinationPort(String(first.destinationPort));
setExistingTargets(
targets.map((t) => ({
browserGatewayTargetId: t.browserGatewayTargetId,
siteId: t.siteId
}))
);
setSelectedSites(
targets.map((t) => ({
siteId: t.siteId,
name: t.siteName ?? String(t.siteId),
type: "newt" as const
}))
);
} }
}, [sites, bgSiteId]); }, [bgTargetsResponse]);
const [, formAction, isSubmitting] = useActionState(save, null); const [, formAction, isSubmitting] = useActionState(save, null);
async function save() { async function save() {
const isValid = await form.trigger();
if (!isValid) return;
const effectiveMode = isNative ? "native" : standardDaemonLocation;
const portVal = form.getValues().authDaemonPort;
const effectivePort =
!isNative && standardDaemonLocation === "remote" && portVal
? Number(portVal)
: null;
try { try {
await api.post(`/resource/${resource.resourceId}`, { await api.post(`/resource/${resource.resourceId}`, {
pamMode, pamMode,
authDaemonMode, authDaemonMode: effectiveMode,
authDaemonPort: authDaemonPort ? Number(authDaemonPort) : null authDaemonPort: effectivePort
}); });
updateResource({ ...resource, pamMode, authDaemonMode }); updateResource({
...resource,
pamMode,
authDaemonMode: effectiveMode
});
if (bgDestination && bgDestinationPort) { if (isNative) {
if (bgTargetId) { if (selectedNativeSite) {
await api.post( if (nativeExistingTarget) {
`/org/${orgId}/browser-gateway-target/${bgTargetId}`, await api.post(
{ `/org/${orgId}/browser-gateway-target/${nativeExistingTarget.browserGatewayTargetId}`,
type: "ssh", {
destination: bgDestination, type: "ssh",
destinationPort: Number(bgDestinationPort), destination: "localhost",
siteId: bgSiteId destinationPort: 22,
} siteId: selectedNativeSite.siteId
}
);
} else {
const res = await api.put(
`/org/${orgId}/resource/${resource.resourceId}/browser-gateway-target`,
{
siteId: selectedNativeSite.siteId,
type: "ssh",
destination: "localhost",
destinationPort: 22
}
);
setNativeExistingTarget({
browserGatewayTargetId:
res.data.data.browserGatewayTargetId,
siteId: selectedNativeSite.siteId
});
}
}
} else {
if (bgDestination && bgDestinationPort) {
const selectedSiteIds = new Set(
selectedSites.map((s) => s.siteId)
); );
} else { const existingSiteIds = new Set(
const res = await api.put( existingTargets.map((t) => t.siteId)
`/org/${orgId}/resource/${resource.resourceId}/browser-gateway-target`,
{
siteId: bgSiteId ?? sites[0]?.siteId,
type: "ssh",
destination: bgDestination,
destinationPort: Number(bgDestinationPort)
}
); );
setBgTargetId(res.data.data.browserGatewayTargetId);
const toDelete = existingTargets.filter(
(t) => !selectedSiteIds.has(t.siteId)
);
await Promise.all(
toDelete.map((t) =>
api.delete(
`/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}`
)
)
);
const toUpdate = existingTargets.filter((t) =>
selectedSiteIds.has(t.siteId)
);
await Promise.all(
toUpdate.map((t) =>
api.post(
`/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}`,
{
type: "ssh",
destination: bgDestination,
destinationPort: Number(bgDestinationPort),
siteId: t.siteId
}
)
)
);
const toCreate = selectedSites.filter(
(s) => !existingSiteIds.has(s.siteId)
);
const created = await Promise.all(
toCreate.map((s) =>
api.put(
`/org/${orgId}/resource/${resource.resourceId}/browser-gateway-target`,
{
siteId: s.siteId,
type: "ssh",
destination: bgDestination,
destinationPort: Number(bgDestinationPort)
}
)
)
);
const newTargets: ExistingTarget[] = created.map(
(res, i) => ({
browserGatewayTargetId:
res.data.data.browserGatewayTargetId,
siteId: toCreate[i].siteId
})
);
setExistingTargets([...toUpdate, ...newTargets]);
} }
} }
@@ -196,48 +352,47 @@ function SshServerForm({
} }
]; ];
const showDaemonLocation = !isNative && pamMode === "push";
const showDaemonPort =
!isNative && pamMode === "push" && standardDaemonLocation === "remote";
return ( return (
<> <SettingsSection>
<SettingsSection> <SettingsSectionHeader>
<SettingsSectionHeader> <SettingsSectionTitle>{t("sshServer")}</SettingsSectionTitle>
<SettingsSectionTitle> <SettingsSectionDescription>
{t("sshServer")} {t("sshServerDescription")}
</SettingsSectionTitle> </SettingsSectionDescription>
<SettingsSectionDescription> </SettingsSectionHeader>
{t("sshServerDescription")} <SettingsSectionBody>
</SettingsSectionDescription> <SettingsSectionForm>
</SettingsSectionHeader> <div className="space-y-3">
<SettingsSectionBody> <p className="text-sm font-semibold">
<div className="space-y-6"> {t("sshServerMode")}
<div className="space-y-2"> </p>
<p className="text-sm font-semibold"> </div>
{t("sshServerMode")}
</p>
<span className="inline-flex items-center rounded-full border px-3 py-0.5 text-xs font-medium">
{t("sshServerModeStandard")}
</span>
</div>
<div className="space-y-3"> <div className="space-y-3">
<p className="text-sm font-semibold"> <p className="text-sm font-semibold">
{t("sshAuthenticationMethod")} {t("sshAuthenticationMethod")}
</p> </p>
<StrategySelect<"passthrough" | "push"> <StrategySelect<"passthrough" | "push">
value={pamMode} value={pamMode}
options={authMethodOptions} options={authMethodOptions}
onChange={setPamMode} onChange={setPamMode}
cols={2} cols={2}
/> />
</div> </div>
{showDaemonLocation && (
<div className="space-y-3"> <div className="space-y-3">
<p className="text-sm font-semibold"> <p className="text-sm font-semibold">
{t("sshAuthDaemonLocation")} {t("sshAuthDaemonLocation")}
</p> </p>
<StrategySelect<"site" | "remote"> <StrategySelect<"site" | "remote">
value={authDaemonMode} value={standardDaemonLocation}
options={daemonLocationOptions} options={daemonLocationOptions}
onChange={setAuthDaemonMode} onChange={setStandardDaemonLocation}
cols={2} cols={2}
/> />
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
@@ -253,104 +408,96 @@ function SshServerForm({
</a> </a>
</p> </p>
</div> </div>
)}
<div className="space-y-2 max-w-xs"> {showDaemonPort && (
<label className="text-sm font-semibold"> <Form {...form}>
{t("sshDaemonPort")} <FormField
</label> control={form.control}
<Input name="authDaemonPort"
type="number" render={({ field }) => (
min={1} <FormItem>
max={65535} <FormLabel>
value={authDaemonPort} {t("sshDaemonPort")}
onChange={(e) => </FormLabel>
setAuthDaemonPort(e.target.value) <FormControl>
} <Input
type="number"
min={1}
max={65535}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/> />
</div> </Form>
</div> )}
</SettingsSectionBody> </SettingsSectionForm>
</SettingsSection>
<SettingsSection> <div className="space-y-3">
<SettingsSectionHeader> <div>
<SettingsSectionTitle> <h2 className="text-1xl font-semibold tracking-tight flex items-center gap-2">
{t("sshServerDestination")} {t("sshServerDestination")}
</SettingsSectionTitle> </h2>
<SettingsSectionDescription> <p className="text-sm text-muted-foreground">
{t("sshServerDestinationDescription")} {t("sshServerDestinationDescription")}
</SettingsSectionDescription> </p>
</SettingsSectionHeader>
<SettingsSectionBody>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-sm font-semibold">
{t("destination")}
</label>
<Input
placeholder="192.168.1.1"
value={bgDestination}
onChange={(e) =>
setBgDestination(e.target.value)
}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-semibold">
{t("port")}
</label>
<Input
type="number"
placeholder="22"
value={bgDestinationPort}
onChange={(e) =>
setBgDestinationPort(e.target.value)
}
/>
</div>
</div>
{sites.length > 0 && (
<div className="space-y-2">
<label className="text-sm font-semibold">
{t("site")}
</label>
<Select
value={bgSiteId ? String(bgSiteId) : ""}
onValueChange={(v) =>
setBgSiteId(Number(v))
}
>
<SelectTrigger>
<SelectValue
placeholder={t("siteSelect")}
/>
</SelectTrigger>
<SelectContent>
{sites.map((site) => (
<SelectItem
key={site.siteId}
value={String(site.siteId)}
>
{site.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div> </div>
</SettingsSectionBody> {isNative ? (
<form action={formAction} className="flex justify-end mt-4"> <Popover
<Button open={nativeSiteOpen}
disabled={isSubmitting} onOpenChange={setNativeSiteOpen}
loading={isSubmitting} >
type="submit" <PopoverTrigger asChild>
> <Button
{t("saveSettings")} variant="outline"
</Button> role="combobox"
</form> className="w-full max-w-xs justify-between font-normal"
</SettingsSection> >
</> <span className="truncate">
{selectedNativeSite?.name ??
t("siteSelect")}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
<SitesSelector
orgId={orgId}
selectedSite={selectedNativeSite}
onSelectSite={(site) => {
setSelectedNativeSite(site);
setNativeSiteOpen(false);
}}
/>
</PopoverContent>
</Popover>
) : (
<BrowserGatewayTargetForm
orgId={orgId}
multiSite={true}
selectedSites={selectedSites}
onSitesChange={setSelectedSites}
destination={bgDestination}
destinationPort={bgDestinationPort}
onDestinationChange={setBgDestination}
onDestinationPortChange={setBgDestinationPort}
learnMoreHref="https://docs.pangolin.net/manage/resources/public/ssh"
/>
)}
</div>
</SettingsSectionBody>
<form action={formAction} className="flex justify-end mt-4">
<Button
disabled={isSubmitting}
loading={isSubmitting}
type="submit"
>
{t("saveSettings")}
</Button>
</form>
</SettingsSection>
); );
} }

View File

@@ -0,0 +1,145 @@
"use client";
import { ChevronsUpDown, ExternalLink } from "lucide-react";
import { useTranslations } from "next-intl";
import { useState } from "react";
import {
MultiSitesSelector,
formatMultiSitesSelectorLabel
} from "./multi-site-selector";
import { SitesSelector, type Selectedsite } from "./site-selector";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
type SingleSiteProps = {
multiSite?: false;
selectedSite: Selectedsite | null;
onSiteChange: (site: Selectedsite | null) => void;
};
type MultiSiteProps = {
multiSite: true;
selectedSites: Selectedsite[];
onSitesChange: (sites: Selectedsite[]) => void;
};
export type BrowserGatewayTargetFormProps = {
orgId: string;
destination: string;
destinationPort: string;
onDestinationChange: (v: string) => void;
onDestinationPortChange: (v: string) => void;
learnMoreHref?: string;
} & (SingleSiteProps | MultiSiteProps);
export function BrowserGatewayTargetForm(props: BrowserGatewayTargetFormProps) {
const t = useTranslations();
const [siteOpen, setSiteOpen] = useState(false);
const siteSelector =
props.multiSite === true ? (
<Popover open={siteOpen} onOpenChange={setSiteOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="w-full justify-between font-normal"
>
<span className="truncate">
{formatMultiSitesSelectorLabel(
props.selectedSites,
t
)}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
<MultiSitesSelector
orgId={props.orgId}
selectedSites={props.selectedSites}
onSelectionChange={props.onSitesChange}
/>
</PopoverContent>
</Popover>
) : (
<Popover open={siteOpen} onOpenChange={setSiteOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="w-full justify-between font-normal"
>
<span className="truncate">
{props.selectedSite?.name ?? t("siteSelect")}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
<SitesSelector
orgId={props.orgId}
selectedSite={props.selectedSite}
onSelectSite={(site) => {
props.onSiteChange(site);
setSiteOpen(false);
}}
/>
</PopoverContent>
</Popover>
);
return (
<div className="space-y-2">
<div className="grid grid-cols-3 gap-4">
<div className="space-y-2">
<label className="text-sm font-semibold">
{t("sites")}
</label>
{siteSelector}
</div>
<div className="space-y-2">
<label className="text-sm font-semibold">
{t("destination")}
</label>
<Input
placeholder="192.168.1.1"
value={props.destination}
onChange={(e) =>
props.onDestinationChange(e.target.value)
}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-semibold">{t("port")}</label>
<Input
type="number"
placeholder="22"
value={props.destinationPort}
onChange={(e) =>
props.onDestinationPortChange(e.target.value)
}
/>
</div>
</div>
{props.multiSite && (
<p className="text-sm text-muted-foreground">
{t("bgTargetMultiSiteDisclaimer")}{" "}
<a
href={
props.learnMoreHref ??
"https://docs.pangolin.net/manage/resources/public/ssh"
}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-1"
>
{t("learnMore")}
<ExternalLink className="size-3.5 shrink-0" />
</a>
</p>
)}
</div>
);
}