Make the first ssh page and conditional http page

This commit is contained in:
Owen
2026-05-22 15:12:37 -07:00
parent 454449ec8a
commit 9d77fcc457
7 changed files with 398 additions and 8 deletions

View File

@@ -1970,6 +1970,25 @@
"timeIsInSeconds": "Time is in seconds",
"requireDeviceApproval": "Require Device Approvals",
"requireDeviceApprovalDescription": "Users with this role need new devices approved by an admin before they can connect and access resources.",
"sshSettings": "SSH Settings",
"sshServer": "SSH Server",
"sshServerDescription": "Set up the authentication method, daemon location, and server destination",
"sshServerMode": "Mode",
"sshServerModeStandard": "Standard SSH Server",
"sshAuthenticationMethod": "Authentication Method",
"sshAuthMethodManual": "Manual Authentication",
"sshAuthMethodManualDescription": "Requires existing host credentials. Bypasses automatic provisioning.",
"sshAuthMethodAutomated": "Automated Provisioning",
"sshAuthMethodAutomatedDescription": "Automatically creates users, groups, and sudo permissions on host.",
"sshAuthDaemonLocation": "Auth Daemon Location",
"sshDaemonLocationSiteDescription": "Executes locally on the machine hosting the site connector.",
"sshDaemonLocationRemote": "On Remote Host",
"sshDaemonLocationRemoteDescription": "Executes on a separate target machine on the same network.",
"sshDaemonDisclaimer": "Ensure your target host is properly configured to run the auth daemon before completing this setup, or provisioning will fail.",
"sshDaemonPort": "Daemon Port",
"sshServerDestination": "Server Destination",
"sshServerDestinationDescription": "Configure the destination and port of the SSH server",
"destination": "Destination",
"sshAccess": "SSH Access",
"roleAllowSsh": "Allow SSH",
"roleAllowSshAllow": "Allow",

View File

@@ -147,7 +147,6 @@ export const resources = pgTable("resources", {
headers: text("headers"), // comma-separated list of headers to add to the request
proxyProtocol: boolean("proxyProtocol").notNull().default(false),
proxyProtocolVersion: integer("proxyProtocolVersion").default(1),
maintenanceModeEnabled: boolean("maintenanceModeEnabled")
.notNull()
.default(false),
@@ -166,7 +165,8 @@ export const resources = pgTable("resources", {
.default("passthrough"),
authDaemonMode: varchar("authDaemonMode", { length: 32 })
.$type<"site" | "remote" | "native">()
.default("site")
.default("site"),
authDaemonPort: integer("authDaemonPort").default(22123)
});
export const labels = pgTable("labels", {

View File

@@ -187,7 +187,8 @@ export const resources = sqliteTable("resources", {
.default("passthrough"),
authDaemonMode: text("authDaemonMode")
.$type<"site" | "remote" | "native">()
.default("site")
.default("site"),
authDaemonPort: integer("authDaemonPort").default(22123)
});
export const labels = sqliteTable("labels", {

View File

@@ -72,7 +72,11 @@ const updateHttpResourceBodySchema = z
maintenanceMessage: z.string().max(2000).nullable().optional(),
maintenanceEstimatedTime: z.string().max(100).nullable().optional(),
postAuthPath: z.string().nullable().optional(),
browserAccessType: z.enum(["http", "ssh", "rdp", "vnc"]).optional()
browserAccessType: z.enum(["http", "ssh", "rdp", "vnc"]).optional(),
// SSH settings
pamMode: z.enum(["passthrough", "push"]).optional(),
authDaemonMode: z.enum(["site", "remote", "native"]).optional(),
authDaemonPort: z.int().min(1).max(65535).nullable().optional()
})
.refine((data) => Object.keys(data).length > 0, {
error: "At least one field must be provided for update"

View File

@@ -84,13 +84,23 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
{
title: t("general"),
href: `/{orgId}/settings/resources/proxy/{niceId}/general`
},
{
title: t("proxy"),
href: `/{orgId}/settings/resources/proxy/{niceId}/proxy`
}
];
if (resource.browserAccessType === "http") {
navItems.push({
title: t("httpSettings"),
href: `/{orgId}/settings/resources/proxy/{niceId}/http`
});
}
if (resource.browserAccessType === "ssh") {
navItems.push({
title: t("sshSettings"),
href: `/{orgId}/settings/resources/proxy/{niceId}/ssh`
});
}
if (resource.http) {
navItems.push({
title: t("authentication"),

View File

@@ -0,0 +1,356 @@
"use client";
import {
SettingsContainer,
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
SettingsSectionHeader,
SettingsSectionTitle
} from "@app/components/Settings";
import { StrategySelect, StrategyOption } from "@app/components/StrategySelect";
import { Button } from "@app/components/ui/button";
import { Input } from "@app/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@app/components/ui/select";
import { ExternalLink } from "lucide-react";
import { toast } from "@app/hooks/useToast";
import { useResourceContext } from "@app/hooks/useResourceContext";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { createApiClient } from "@app/lib/api";
import { formatAxiosError } from "@app/lib/api/formatAxiosError";
import { orgQueries } from "@app/lib/queries";
import { useQuery } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { use, useActionState, useEffect, useState } from "react";
import { GetResourceResponse } from "@server/routers/resource";
import type { ResourceContextType } from "@app/contexts/resourceContext";
export default function SshSettingsPage(props: {
params: Promise<{ orgId: string }>;
}) {
const params = use(props.params);
const { resource, updateResource } = useResourceContext();
return (
<SettingsContainer>
<SshServerForm
orgId={params.orgId}
resource={resource}
updateResource={updateResource}
/>
</SettingsContainer>
);
}
function SshServerForm({
orgId,
resource,
updateResource
}: {
orgId: string;
resource: GetResourceResponse;
updateResource: ResourceContextType["updateResource"];
}) {
const t = useTranslations();
const api = createApiClient(useEnvContext());
const router = useRouter();
const [pamMode, setPamMode] = useState<"passthrough" | "push">(
(resource.pamMode as "passthrough" | "push") || "passthrough"
);
const [authDaemonMode, setAuthDaemonMode] = useState<"site" | "remote">(
(resource.authDaemonMode as "site" | "remote") || "site"
);
const [authDaemonPort, setAuthDaemonPort] = useState<string>(
(resource as any).authDaemonPort
? String((resource as any).authDaemonPort)
: "22123"
);
const [bgDestination, setBgDestination] = useState("");
const [bgDestinationPort, setBgDestinationPort] = useState("22");
const [bgSiteId, setBgSiteId] = useState<number | null>(null);
const [bgTargetId, setBgTargetId] = useState<number | null>(null);
const { data: sites = [] } = useQuery(orgQueries.sites({ orgId }));
const { data: bgTargetsResponse } = useQuery({
queryKey: ["browserGatewayTargets", resource.resourceId, orgId],
queryFn: async () => {
const res = await api.get(
`/org/${orgId}/resource/${resource.resourceId}/browser-gateway-targets`
);
return res.data.data as {
targets: Array<{
browserGatewayTargetId: number;
resourceId: number;
siteId: number;
type: string;
destination: string;
destinationPort: number;
}>;
};
}
});
useEffect(() => {
if (!bgTargetsResponse?.targets?.length) return;
const bgt = bgTargetsResponse.targets[0];
setBgDestination(bgt.destination);
setBgDestinationPort(String(bgt.destinationPort));
setBgSiteId(bgt.siteId);
setBgTargetId(bgt.browserGatewayTargetId);
}, [bgTargetsResponse]);
useEffect(() => {
if (sites.length > 0 && bgSiteId === null) {
setBgSiteId(sites[0].siteId);
}
}, [sites, bgSiteId]);
const [, formAction, isSubmitting] = useActionState(save, null);
async function save() {
try {
await api.post(`/resource/${resource.resourceId}`, {
pamMode,
authDaemonMode,
authDaemonPort: authDaemonPort ? Number(authDaemonPort) : null
});
updateResource({ ...resource, pamMode, authDaemonMode });
if (bgDestination && bgDestinationPort) {
if (bgTargetId) {
await api.post(
`/org/${orgId}/browser-gateway-target/${bgTargetId}`,
{
type: "ssh",
destination: bgDestination,
destinationPort: Number(bgDestinationPort),
siteId: bgSiteId
}
);
} else {
const res = await api.put(
`/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);
}
}
toast({
title: t("settingsUpdated"),
description: t("settingsUpdatedDescription")
});
router.refresh();
} catch (err) {
console.error(err);
toast({
variant: "destructive",
title: t("settingsErrorUpdate"),
description: formatAxiosError(
err,
t("settingsErrorUpdateDescription")
)
});
}
}
const authMethodOptions: StrategyOption<"passthrough" | "push">[] = [
{
id: "passthrough",
title: t("sshAuthMethodManual"),
description: t("sshAuthMethodManualDescription")
},
{
id: "push",
title: t("sshAuthMethodAutomated"),
description: t("sshAuthMethodAutomatedDescription")
}
];
const daemonLocationOptions: StrategyOption<"site" | "remote">[] = [
{
id: "site",
title: t("internalResourceAuthDaemonSite"),
description: t("sshDaemonLocationSiteDescription")
},
{
id: "remote",
title: t("sshDaemonLocationRemote"),
description: t("sshDaemonLocationRemoteDescription")
}
];
return (
<>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("sshServer")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("sshServerDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<div className="space-y-6">
<div className="space-y-2">
<p className="text-sm font-semibold">
{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">
<p className="text-sm font-semibold">
{t("sshAuthenticationMethod")}
</p>
<StrategySelect<"passthrough" | "push">
value={pamMode}
options={authMethodOptions}
onChange={setPamMode}
cols={2}
/>
</div>
<div className="space-y-3">
<p className="text-sm font-semibold">
{t("sshAuthDaemonLocation")}
</p>
<StrategySelect<"site" | "remote">
value={authDaemonMode}
options={daemonLocationOptions}
onChange={setAuthDaemonMode}
cols={2}
/>
<p className="text-sm text-muted-foreground">
{t("sshDaemonDisclaimer")}{" "}
<a
href="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>
<div className="space-y-2 max-w-xs">
<label className="text-sm font-semibold">
{t("sshDaemonPort")}
</label>
<Input
type="number"
min={1}
max={65535}
value={authDaemonPort}
onChange={(e) =>
setAuthDaemonPort(e.target.value)
}
/>
</div>
</div>
</SettingsSectionBody>
</SettingsSection>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("sshServerDestination")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("sshServerDestinationDescription")}
</SettingsSectionDescription>
</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>
</SettingsSectionBody>
<form action={formAction} className="flex justify-end mt-4">
<Button
disabled={isSubmitting}
loading={isSubmitting}
type="submit"
>
{t("saveSettings")}
</Button>
</form>
</SettingsSection>
</>
);
}