mirror of
https://github.com/fosrl/pangolin.git
synced 2026-09-12 14:01:18 +02:00
add resource info to side panel
This commit is contained in:
+10
-1
@@ -3607,7 +3607,16 @@
|
|||||||
"resourceLauncherDeleteViewQuestion": "Are you sure you want to delete this launcher view?",
|
"resourceLauncherDeleteViewQuestion": "Are you sure you want to delete this launcher view?",
|
||||||
"resourceLauncherDeleteViewConfirm": "Delete View",
|
"resourceLauncherDeleteViewConfirm": "Delete View",
|
||||||
"resourceLauncherViewAsAdmin": "View as Admin",
|
"resourceLauncherViewAsAdmin": "View as Admin",
|
||||||
"resourceLauncherResourceDetailsDescription": "View details for this resource.",
|
"resourceLauncherResourceDetailsDescription": "Connection information and status for this resource.",
|
||||||
|
"resourceLauncherResourceDetails": "Resource Details",
|
||||||
|
"resourceLauncherAuthMethodsDescription": "Authentication methods enabled for this resource.",
|
||||||
|
"resourceLauncherPrivateClientRequired": "Connect with a client on your device to access this resource privately.",
|
||||||
|
"resourceLauncherPrivateClientRequiredTitle": "Client Connection Required",
|
||||||
|
"resourceLauncherDownloadClient": "Download client",
|
||||||
|
"resourceLauncherFailedToLoadDetails": "Could not load resource details. You may no longer have access to this resource.",
|
||||||
|
"resourceLauncherNoPortRestrictions": "No port restrictions",
|
||||||
|
"resourceLauncherTcp": "TCP",
|
||||||
|
"resourceLauncherUdp": "UDP",
|
||||||
"resourceLauncherUnlabeled": "Unlabeled",
|
"resourceLauncherUnlabeled": "Unlabeled",
|
||||||
"resourceLauncherNoSite": "No Site",
|
"resourceLauncherNoSite": "No Site",
|
||||||
"resourceLauncherNoResourcesInGroup": "No resources in this group",
|
"resourceLauncherNoResourcesInGroup": "No resources in this group",
|
||||||
|
|||||||
@@ -327,6 +327,14 @@ authenticated.get(
|
|||||||
siteResource.listAllSiteResourcesByOrg
|
siteResource.listAllSiteResourcesByOrg
|
||||||
);
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
"/org/:orgId/site-resource/:siteResourceId",
|
||||||
|
verifyOrgAccess,
|
||||||
|
verifySiteResourceAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.getSiteResource),
|
||||||
|
siteResource.getSiteResource
|
||||||
|
);
|
||||||
|
|
||||||
authenticated.get(
|
authenticated.get(
|
||||||
"/site-resource/:siteResourceId",
|
"/site-resource/:siteResourceId",
|
||||||
verifySiteResourceAccess,
|
verifySiteResourceAccess,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { cn } from "@app/lib/cn";
|
||||||
import { Check, Copy } from "lucide-react";
|
import { Check, Copy } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
@@ -7,12 +8,14 @@ type CopyToClipboardProps = {
|
|||||||
text: string;
|
text: string;
|
||||||
displayText?: string;
|
displayText?: string;
|
||||||
isLink?: boolean;
|
isLink?: boolean;
|
||||||
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const CopyToClipboard = ({
|
const CopyToClipboard = ({
|
||||||
text,
|
text,
|
||||||
displayText,
|
displayText,
|
||||||
isLink
|
isLink,
|
||||||
|
className
|
||||||
}: CopyToClipboardProps) => {
|
}: CopyToClipboardProps) => {
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
@@ -48,14 +51,20 @@ const CopyToClipboard = ({
|
|||||||
href={text}
|
href={text}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="truncate hover:underline text-sm min-w-0 max-w-full"
|
className={cn(
|
||||||
|
"truncate hover:underline text-sm min-w-0 max-w-full",
|
||||||
|
className
|
||||||
|
)}
|
||||||
title={text} // Shows full text on hover
|
title={text} // Shows full text on hover
|
||||||
>
|
>
|
||||||
{displayValue}
|
{displayValue}
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<span
|
<span
|
||||||
className="truncate text-sm min-w-0 max-w-full"
|
className={cn(
|
||||||
|
"truncate text-sm min-w-0 max-w-full",
|
||||||
|
className
|
||||||
|
)}
|
||||||
style={{
|
style={{
|
||||||
whiteSpace: "nowrap",
|
whiteSpace: "nowrap",
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
|
|||||||
@@ -5,12 +5,15 @@ import { cn } from "@app/lib/cn";
|
|||||||
export function InfoSections({
|
export function InfoSections({
|
||||||
children,
|
children,
|
||||||
cols,
|
cols,
|
||||||
columnSizing = "content"
|
columnSizing = "content",
|
||||||
|
layout = "default"
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
cols?: number;
|
cols?: number;
|
||||||
/** content (default): fixed gap, columns hug content, left-aligned; fill: equal-width columns across the row */
|
/** content (default): fixed gap, columns hug content, left-aligned; fill: equal-width columns across the row */
|
||||||
columnSizing?: "fill" | "content";
|
columnSizing?: "fill" | "content";
|
||||||
|
/** panel: 2 columns until xl for narrow containers such as side panels */
|
||||||
|
layout?: "default" | "panel";
|
||||||
}) {
|
}) {
|
||||||
const n = cols || 1;
|
const n = cols || 1;
|
||||||
const track =
|
const track =
|
||||||
@@ -19,9 +22,14 @@ export function InfoSections({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"grid w-full min-w-0 grid-cols-2 md:grid-cols-(--columns) md:space-x-16 gap-4 md:items-start",
|
"grid w-full min-w-0 gap-4",
|
||||||
|
layout === "panel"
|
||||||
|
? "grid-cols-2 xl:grid-cols-(--columns) xl:items-start xl:space-x-16"
|
||||||
|
: "grid-cols-2 md:grid-cols-(--columns) md:items-start md:space-x-16",
|
||||||
columnSizing === "content" &&
|
columnSizing === "content" &&
|
||||||
"md:justify-items-start md:justify-start"
|
(layout === "panel"
|
||||||
|
? "xl:justify-items-start xl:justify-start"
|
||||||
|
: "md:justify-items-start md:justify-start")
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
// @ts-expect-error dynamic props don't work with tailwind, but we can set the
|
// @ts-expect-error dynamic props don't work with tailwind, but we can set the
|
||||||
|
|||||||
@@ -1,17 +1,55 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import CopyToClipboard from "@app/components/CopyToClipboard";
|
||||||
|
import {
|
||||||
|
InfoSection,
|
||||||
|
InfoSectionContent,
|
||||||
|
InfoSections,
|
||||||
|
InfoSectionTitle
|
||||||
|
} from "@app/components/InfoSection";
|
||||||
|
import {
|
||||||
|
SettingsSection,
|
||||||
|
SettingsSectionBody,
|
||||||
|
SettingsSectionDescription,
|
||||||
|
SettingsSectionHeader,
|
||||||
|
SettingsSectionTitle
|
||||||
|
} from "@app/components/Settings";
|
||||||
import {
|
import {
|
||||||
SidePanel,
|
SidePanel,
|
||||||
SidePanelBody,
|
SidePanelBody,
|
||||||
SidePanelContent,
|
SidePanelContent,
|
||||||
SidePanelDescription,
|
|
||||||
SidePanelFooter,
|
SidePanelFooter,
|
||||||
SidePanelHeader,
|
SidePanelHeader,
|
||||||
SidePanelTitle
|
SidePanelTitle
|
||||||
} from "@app/components/SidePanel";
|
} from "@app/components/SidePanel";
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
||||||
import { Button } from "@app/components/ui/button";
|
import { Button } from "@app/components/ui/button";
|
||||||
|
import {
|
||||||
|
derivePublicAuthState,
|
||||||
|
formatPortRestrictionDisplay,
|
||||||
|
formatPublicResourceType
|
||||||
|
} from "@app/lib/launcherResourceDetails";
|
||||||
import { getLauncherResourceAdminHref } from "@app/lib/launcherResourceAdminHref";
|
import { getLauncherResourceAdminHref } from "@app/lib/launcherResourceAdminHref";
|
||||||
|
import {
|
||||||
|
formatSiteResourceDestinationDisplay,
|
||||||
|
isSafeUrlForLink
|
||||||
|
} from "@app/lib/launcherResourceAccess";
|
||||||
|
import { launcherQueries } from "@app/lib/queries";
|
||||||
import type { LauncherResource } from "@server/routers/launcher/types";
|
import type { LauncherResource } from "@server/routers/launcher/types";
|
||||||
|
import type { GetResourceAuthInfoResponse } from "@server/routers/resource/getResourceAuthInfo";
|
||||||
|
import type { GetResourceResponse } from "@server/routers/resource/getResource";
|
||||||
|
import type { GetSiteResourceResponse } from "@server/routers/siteResource/getSiteResource";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
CheckCircle2,
|
||||||
|
Clock,
|
||||||
|
ExternalLink,
|
||||||
|
Loader2,
|
||||||
|
ShieldCheck,
|
||||||
|
ShieldOff,
|
||||||
|
XCircle
|
||||||
|
} from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
@@ -23,6 +61,460 @@ type LauncherResourcePanelProps = {
|
|||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type LauncherResourceDetailResult =
|
||||||
|
| {
|
||||||
|
resourceType: "public";
|
||||||
|
data: GetResourceResponse;
|
||||||
|
authInfo: GetResourceAuthInfoResponse;
|
||||||
|
}
|
||||||
|
| { resourceType: "site"; data: GetSiteResourceResponse };
|
||||||
|
|
||||||
|
function AccessMethodContent({
|
||||||
|
accessDisplay,
|
||||||
|
accessCopyValue,
|
||||||
|
accessUrl
|
||||||
|
}: {
|
||||||
|
accessDisplay: string;
|
||||||
|
accessCopyValue: string;
|
||||||
|
accessUrl?: string | null;
|
||||||
|
}) {
|
||||||
|
if (!accessDisplay) {
|
||||||
|
return <span>-</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const href = accessUrl ?? undefined;
|
||||||
|
const canLink = Boolean(href && isSafeUrlForLink(href));
|
||||||
|
|
||||||
|
if (canLink && href) {
|
||||||
|
return (
|
||||||
|
<CopyToClipboard
|
||||||
|
text={href}
|
||||||
|
displayText={accessDisplay}
|
||||||
|
isLink={true}
|
||||||
|
className="text-base"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CopyToClipboard
|
||||||
|
text={accessCopyValue || accessDisplay}
|
||||||
|
displayText={accessDisplay}
|
||||||
|
isLink={false}
|
||||||
|
className="text-base"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HealthStatusDisplay({
|
||||||
|
health
|
||||||
|
}: {
|
||||||
|
health: string | null | undefined;
|
||||||
|
}) {
|
||||||
|
const t = useTranslations();
|
||||||
|
const status = health ?? "unknown";
|
||||||
|
|
||||||
|
if (status === "healthy") {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<CheckCircle2 className="size-4 shrink-0 text-green-500" />
|
||||||
|
<span>{t("resourcesTableHealthy")}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === "degraded") {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<CheckCircle2 className="size-4 shrink-0 text-yellow-500" />
|
||||||
|
<span>{t("resourcesTableDegraded")}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === "unhealthy") {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<XCircle className="size-4 shrink-0 text-destructive" />
|
||||||
|
<span>{t("resourcesTableUnhealthy")}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Clock className="size-4 shrink-0 text-muted-foreground" />
|
||||||
|
<span>{t("resourcesTableUnknown")}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const PUBLIC_AUTH_BROWSER_MODES = ["http", "ssh", "rdp", "vnc"];
|
||||||
|
|
||||||
|
function AuthMethodStatusDisplay({ enabled }: { enabled: boolean }) {
|
||||||
|
const t = useTranslations();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{enabled ? (
|
||||||
|
<CheckCircle2 className="size-4 text-green-600" />
|
||||||
|
) : (
|
||||||
|
<XCircle className="size-4 text-red-600" />
|
||||||
|
)}
|
||||||
|
<span>{enabled ? t("enabled") : t("disabled")}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PublicResourceAuthMethods({
|
||||||
|
authInfo
|
||||||
|
}: {
|
||||||
|
authInfo: GetResourceAuthInfoResponse;
|
||||||
|
}) {
|
||||||
|
const t = useTranslations();
|
||||||
|
|
||||||
|
const authMethods = [
|
||||||
|
{
|
||||||
|
key: "sso",
|
||||||
|
title: t("policyAuthSsoTitle"),
|
||||||
|
enabled: authInfo.sso
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "password",
|
||||||
|
title: t("policyAuthPasscodeTitle"),
|
||||||
|
enabled: authInfo.password
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "pincode",
|
||||||
|
title: t("policyAuthPincodeTitle"),
|
||||||
|
enabled: authInfo.pincode
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "whitelist",
|
||||||
|
title: t("policyAuthEmailTitle"),
|
||||||
|
enabled: authInfo.whitelist
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "headerAuth",
|
||||||
|
title: t("policyAuthHeaderAuthTitle"),
|
||||||
|
enabled: authInfo.headerAuth
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsSection>
|
||||||
|
<SettingsSectionHeader>
|
||||||
|
<SettingsSectionTitle>
|
||||||
|
{t("authentication")}
|
||||||
|
</SettingsSectionTitle>
|
||||||
|
<SettingsSectionDescription>
|
||||||
|
{t("resourceLauncherAuthMethodsDescription")}
|
||||||
|
</SettingsSectionDescription>
|
||||||
|
</SettingsSectionHeader>
|
||||||
|
<SettingsSectionBody>
|
||||||
|
<InfoSections cols={authMethods.length} layout="panel">
|
||||||
|
{authMethods.map((method) => (
|
||||||
|
<InfoSection key={method.key}>
|
||||||
|
<InfoSectionTitle>{method.title}</InfoSectionTitle>
|
||||||
|
<InfoSectionContent>
|
||||||
|
<AuthMethodStatusDisplay
|
||||||
|
enabled={method.enabled}
|
||||||
|
/>
|
||||||
|
</InfoSectionContent>
|
||||||
|
</InfoSection>
|
||||||
|
))}
|
||||||
|
</InfoSections>
|
||||||
|
</SettingsSectionBody>
|
||||||
|
</SettingsSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PublicResourceDetails({
|
||||||
|
launcherResource,
|
||||||
|
resource,
|
||||||
|
authInfo
|
||||||
|
}: {
|
||||||
|
launcherResource: LauncherResource;
|
||||||
|
resource: GetResourceResponse;
|
||||||
|
authInfo: GetResourceAuthInfoResponse;
|
||||||
|
}) {
|
||||||
|
const t = useTranslations();
|
||||||
|
const supportsAuth = PUBLIC_AUTH_BROWSER_MODES.includes(
|
||||||
|
resource.mode || ""
|
||||||
|
);
|
||||||
|
const authState = derivePublicAuthState(resource.mode, authInfo);
|
||||||
|
const infoSectionCount = supportsAuth ? 4 : 3;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<SettingsSection>
|
||||||
|
<SettingsSectionHeader>
|
||||||
|
<SettingsSectionTitle>
|
||||||
|
{t("resourceLauncherResourceDetails")}
|
||||||
|
</SettingsSectionTitle>
|
||||||
|
<SettingsSectionDescription>
|
||||||
|
{t("resourceLauncherResourceDetailsDescription")}
|
||||||
|
</SettingsSectionDescription>
|
||||||
|
</SettingsSectionHeader>
|
||||||
|
<SettingsSectionBody>
|
||||||
|
<InfoSections cols={infoSectionCount} layout="panel">
|
||||||
|
<InfoSection>
|
||||||
|
<InfoSectionTitle>{t("type")}</InfoSectionTitle>
|
||||||
|
<InfoSectionContent>
|
||||||
|
{formatPublicResourceType(resource)}
|
||||||
|
</InfoSectionContent>
|
||||||
|
</InfoSection>
|
||||||
|
<InfoSection>
|
||||||
|
<InfoSectionTitle>{t("access")}</InfoSectionTitle>
|
||||||
|
<InfoSectionContent>
|
||||||
|
<AccessMethodContent
|
||||||
|
accessDisplay={
|
||||||
|
launcherResource.accessDisplay
|
||||||
|
}
|
||||||
|
accessCopyValue={
|
||||||
|
launcherResource.accessCopyValue
|
||||||
|
}
|
||||||
|
accessUrl={launcherResource.accessUrl}
|
||||||
|
/>
|
||||||
|
</InfoSectionContent>
|
||||||
|
</InfoSection>
|
||||||
|
{supportsAuth ? (
|
||||||
|
<InfoSection>
|
||||||
|
<InfoSectionTitle>
|
||||||
|
{t("authentication")}
|
||||||
|
</InfoSectionTitle>
|
||||||
|
<InfoSectionContent>
|
||||||
|
{authState === "protected" ? (
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<ShieldCheck className="size-4 shrink-0 text-green-500" />
|
||||||
|
<span>{t("protected")}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<ShieldOff className="size-4 shrink-0 text-yellow-500" />
|
||||||
|
<span>{t("notProtected")}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</InfoSectionContent>
|
||||||
|
</InfoSection>
|
||||||
|
) : null}
|
||||||
|
<InfoSection>
|
||||||
|
<InfoSectionTitle>{t("health")}</InfoSectionTitle>
|
||||||
|
<InfoSectionContent>
|
||||||
|
<HealthStatusDisplay health={resource.health} />
|
||||||
|
</InfoSectionContent>
|
||||||
|
</InfoSection>
|
||||||
|
</InfoSections>
|
||||||
|
</SettingsSectionBody>
|
||||||
|
</SettingsSection>
|
||||||
|
{supportsAuth ? (
|
||||||
|
<PublicResourceAuthMethods authInfo={authInfo} />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PrivateResourceDetails({
|
||||||
|
launcherResource,
|
||||||
|
resource
|
||||||
|
}: {
|
||||||
|
launcherResource: LauncherResource;
|
||||||
|
resource: GetSiteResourceResponse;
|
||||||
|
}) {
|
||||||
|
const t = useTranslations();
|
||||||
|
const modeLabel: Record<GetSiteResourceResponse["mode"], string> = {
|
||||||
|
host: t("editInternalResourceDialogModeHost"),
|
||||||
|
cidr: t("editInternalResourceDialogModeCidr"),
|
||||||
|
http: t("editInternalResourceDialogModeHttp"),
|
||||||
|
ssh: t("editInternalResourceDialogModeSsh")
|
||||||
|
};
|
||||||
|
const destination = formatSiteResourceDestinationDisplay({
|
||||||
|
mode: resource.mode,
|
||||||
|
destination: resource.destination,
|
||||||
|
destinationPort: resource.destinationPort,
|
||||||
|
scheme: resource.scheme
|
||||||
|
});
|
||||||
|
const portRestrictions = formatPortRestrictionDisplay(resource);
|
||||||
|
const showAlias = resource.mode !== "cidr" && resource.mode !== "http";
|
||||||
|
const showDestination = !(
|
||||||
|
resource.mode === "ssh" && resource.authDaemonMode === "native"
|
||||||
|
);
|
||||||
|
const infoSectionCount =
|
||||||
|
2 + (showDestination ? 1 : 0) + (showAlias ? 1 : 0) + 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Alert variant="default">
|
||||||
|
<AlertCircle className="size-4" />
|
||||||
|
<AlertTitle>
|
||||||
|
{t("resourceLauncherPrivateClientRequiredTitle")}
|
||||||
|
</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
<span>
|
||||||
|
{t("resourceLauncherPrivateClientRequired")}{" "}
|
||||||
|
<a
|
||||||
|
href="https://pangolin.net/downloads"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-1 text-primary hover:underline"
|
||||||
|
>
|
||||||
|
{t("resourceLauncherDownloadClient")}
|
||||||
|
<ExternalLink className="size-3.5 shrink-0" />
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<SettingsSection>
|
||||||
|
<SettingsSectionHeader>
|
||||||
|
<SettingsSectionTitle>
|
||||||
|
{t("resourceLauncherResourceDetails")}
|
||||||
|
</SettingsSectionTitle>
|
||||||
|
<SettingsSectionDescription>
|
||||||
|
{t("resourceLauncherResourceDetailsDescription")}
|
||||||
|
</SettingsSectionDescription>
|
||||||
|
</SettingsSectionHeader>
|
||||||
|
<SettingsSectionBody>
|
||||||
|
<InfoSections cols={infoSectionCount} layout="panel">
|
||||||
|
<InfoSection>
|
||||||
|
<InfoSectionTitle>{t("type")}</InfoSectionTitle>
|
||||||
|
<InfoSectionContent>
|
||||||
|
{modeLabel[resource.mode]}
|
||||||
|
</InfoSectionContent>
|
||||||
|
</InfoSection>
|
||||||
|
<InfoSection>
|
||||||
|
<InfoSectionTitle>{t("access")}</InfoSectionTitle>
|
||||||
|
<InfoSectionContent>
|
||||||
|
<AccessMethodContent
|
||||||
|
accessDisplay={
|
||||||
|
launcherResource.accessDisplay
|
||||||
|
}
|
||||||
|
accessCopyValue={
|
||||||
|
launcherResource.accessCopyValue
|
||||||
|
}
|
||||||
|
accessUrl={launcherResource.accessUrl}
|
||||||
|
/>
|
||||||
|
</InfoSectionContent>
|
||||||
|
</InfoSection>
|
||||||
|
{showDestination ? (
|
||||||
|
<InfoSection>
|
||||||
|
<InfoSectionTitle>
|
||||||
|
{t("editInternalResourceDialogDestination")}
|
||||||
|
</InfoSectionTitle>
|
||||||
|
<InfoSectionContent>
|
||||||
|
{destination || "-"}
|
||||||
|
</InfoSectionContent>
|
||||||
|
</InfoSection>
|
||||||
|
) : null}
|
||||||
|
{showAlias ? (
|
||||||
|
<InfoSection>
|
||||||
|
<InfoSectionTitle>
|
||||||
|
{t("editInternalResourceDialogAlias")}
|
||||||
|
</InfoSectionTitle>
|
||||||
|
<InfoSectionContent>
|
||||||
|
{resource.alias?.trim()
|
||||||
|
? resource.alias
|
||||||
|
: "-"}
|
||||||
|
</InfoSectionContent>
|
||||||
|
</InfoSection>
|
||||||
|
) : null}
|
||||||
|
<InfoSection>
|
||||||
|
<InfoSectionTitle>
|
||||||
|
{t("portRestrictions")}
|
||||||
|
</InfoSectionTitle>
|
||||||
|
<InfoSectionContent>
|
||||||
|
{!portRestrictions.hasNonDefaultPorts ? (
|
||||||
|
<span>
|
||||||
|
{t(
|
||||||
|
"resourceLauncherNoPortRestrictions"
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{portRestrictions.tcp.state !==
|
||||||
|
"all" ? (
|
||||||
|
<div>
|
||||||
|
{t("resourceLauncherTcp")}:{" "}
|
||||||
|
{portRestrictions.tcp.state ===
|
||||||
|
"blocked"
|
||||||
|
? t("blocked")
|
||||||
|
: portRestrictions.tcp
|
||||||
|
.ports}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{portRestrictions.udp.state !==
|
||||||
|
"all" ? (
|
||||||
|
<div>
|
||||||
|
{t("resourceLauncherUdp")}:{" "}
|
||||||
|
{portRestrictions.udp.state ===
|
||||||
|
"blocked"
|
||||||
|
? t("blocked")
|
||||||
|
: portRestrictions.udp
|
||||||
|
.ports}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</InfoSectionContent>
|
||||||
|
</InfoSection>
|
||||||
|
</InfoSections>
|
||||||
|
</SettingsSectionBody>
|
||||||
|
</SettingsSection>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LauncherResourcePanelBody({
|
||||||
|
orgId,
|
||||||
|
resource,
|
||||||
|
open
|
||||||
|
}: {
|
||||||
|
orgId: string;
|
||||||
|
resource: LauncherResource;
|
||||||
|
open: boolean;
|
||||||
|
}) {
|
||||||
|
const t = useTranslations();
|
||||||
|
const { data, isPending, isError } = useQuery({
|
||||||
|
...launcherQueries.resourceDetail(orgId, resource),
|
||||||
|
enabled: open
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isPending) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12 text-muted-foreground">
|
||||||
|
<Loader2 className="size-6 animate-spin" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError || !data) {
|
||||||
|
return (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t("resourceLauncherFailedToLoadDetails")}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const detail = data as LauncherResourceDetailResult;
|
||||||
|
|
||||||
|
if (detail.resourceType === "public") {
|
||||||
|
return (
|
||||||
|
<PublicResourceDetails
|
||||||
|
launcherResource={resource}
|
||||||
|
resource={detail.data}
|
||||||
|
authInfo={detail.authInfo}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PrivateResourceDetails
|
||||||
|
launcherResource={resource}
|
||||||
|
resource={detail.data}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function LauncherResourcePanel({
|
export function LauncherResourcePanel({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
@@ -37,11 +529,16 @@ export function LauncherResourcePanel({
|
|||||||
<SidePanelContent>
|
<SidePanelContent>
|
||||||
<SidePanelHeader>
|
<SidePanelHeader>
|
||||||
<SidePanelTitle>{resource?.name ?? ""}</SidePanelTitle>
|
<SidePanelTitle>{resource?.name ?? ""}</SidePanelTitle>
|
||||||
<SidePanelDescription>
|
|
||||||
{t("resourceLauncherResourceDetailsDescription")}
|
|
||||||
</SidePanelDescription>
|
|
||||||
</SidePanelHeader>
|
</SidePanelHeader>
|
||||||
<SidePanelBody />
|
<SidePanelBody>
|
||||||
|
{resource ? (
|
||||||
|
<LauncherResourcePanelBody
|
||||||
|
orgId={orgId}
|
||||||
|
resource={resource}
|
||||||
|
open={open}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</SidePanelBody>
|
||||||
<SidePanelFooter>
|
<SidePanelFooter>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import {
|
|||||||
getEffectiveDefaultLauncherConfig,
|
getEffectiveDefaultLauncherConfig,
|
||||||
type LauncherDefaultViewOverrides,
|
type LauncherDefaultViewOverrides,
|
||||||
type LauncherGroup,
|
type LauncherGroup,
|
||||||
|
type LauncherResource,
|
||||||
type LauncherScaleInfo,
|
type LauncherScaleInfo,
|
||||||
type LauncherViewConfig,
|
type LauncherViewConfig,
|
||||||
type LauncherViewRecord
|
type LauncherViewRecord
|
||||||
@@ -66,6 +67,7 @@ import { LauncherFlatResourceList } from "./LauncherFlatResourceList";
|
|||||||
import { LauncherGroupList } from "./LauncherGroupList";
|
import { LauncherGroupList } from "./LauncherGroupList";
|
||||||
import { LauncherSearchFirstGate } from "./LauncherSearchFirstGate";
|
import { LauncherSearchFirstGate } from "./LauncherSearchFirstGate";
|
||||||
import { LauncherRefreshButton } from "./LauncherRefreshButton";
|
import { LauncherRefreshButton } from "./LauncherRefreshButton";
|
||||||
|
import { LauncherResourcePanel } from "./LauncherResourcePanel";
|
||||||
import { LauncherSettingsMenu } from "./LauncherSettingsMenu";
|
import { LauncherSettingsMenu } from "./LauncherSettingsMenu";
|
||||||
import { LauncherSortButton } from "./LauncherSortButton";
|
import { LauncherSortButton } from "./LauncherSortButton";
|
||||||
import { LauncherSaveViewMenu, LauncherViewTabs } from "./LauncherViewTabs";
|
import { LauncherSaveViewMenu, LauncherViewTabs } from "./LauncherViewTabs";
|
||||||
@@ -116,6 +118,9 @@ export default function ResourceLauncher({
|
|||||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
const [newViewName, setNewViewName] = useState("");
|
const [newViewName, setNewViewName] = useState("");
|
||||||
const [saveOrgWide, setSaveOrgWide] = useState(false);
|
const [saveOrgWide, setSaveOrgWide] = useState(false);
|
||||||
|
const [selectedResource, setSelectedResource] =
|
||||||
|
useState<LauncherResource | null>(null);
|
||||||
|
const [panelOpen, setPanelOpen] = useState(false);
|
||||||
|
|
||||||
const isDesktop = useMediaQuery("(min-width: 768px)");
|
const isDesktop = useMediaQuery("(min-width: 768px)");
|
||||||
|
|
||||||
@@ -563,6 +568,18 @@ export default function ResourceLauncher({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleResourceSelect = useCallback((resource: LauncherResource) => {
|
||||||
|
setSelectedResource(resource);
|
||||||
|
setPanelOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handlePanelOpenChange = useCallback((open: boolean) => {
|
||||||
|
setPanelOpen(open);
|
||||||
|
if (!open) {
|
||||||
|
setSelectedResource(null);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const savedViewTabs = views.map((view) => ({
|
const savedViewTabs = views.map((view) => ({
|
||||||
viewId: view.viewId,
|
viewId: view.viewId,
|
||||||
name: view.name
|
name: view.name
|
||||||
@@ -699,6 +716,7 @@ export default function ResourceLauncher({
|
|||||||
initialGroups={groups}
|
initialGroups={groups}
|
||||||
groupsPagination={groupsPagination}
|
groupsPagination={groupsPagination}
|
||||||
onClearFilters={handleClearFilters}
|
onClearFilters={handleClearFilters}
|
||||||
|
onResourceSelect={handleResourceSelect}
|
||||||
/>
|
/>
|
||||||
) : showFlatResourceList ? (
|
) : showFlatResourceList ? (
|
||||||
<LauncherFlatResourceList
|
<LauncherFlatResourceList
|
||||||
@@ -706,9 +724,18 @@ export default function ResourceLauncher({
|
|||||||
activeViewId={activeViewId}
|
activeViewId={activeViewId}
|
||||||
config={effectiveConfig}
|
config={effectiveConfig}
|
||||||
onClearFilters={handleClearFilters}
|
onClearFilters={handleClearFilters}
|
||||||
|
onResourceSelect={handleResourceSelect}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
<LauncherResourcePanel
|
||||||
|
open={panelOpen}
|
||||||
|
onOpenChange={handlePanelOpenChange}
|
||||||
|
resource={selectedResource}
|
||||||
|
orgId={orgId}
|
||||||
|
isAdmin={isAdmin}
|
||||||
|
/>
|
||||||
|
|
||||||
{activeSavedView ? (
|
{activeSavedView ? (
|
||||||
<ConfirmDeleteDialog
|
<ConfirmDeleteDialog
|
||||||
open={deleteDialogOpen}
|
open={deleteDialogOpen}
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import type { GetResourceResponse } from "@server/routers/resource/getResource";
|
||||||
|
import type { GetResourceAuthInfoResponse } from "@server/routers/resource/getResourceAuthInfo";
|
||||||
|
import type { GetSiteResourceResponse } from "@server/routers/siteResource/getSiteResource";
|
||||||
|
|
||||||
|
export type PublicAuthState = "protected" | "not_protected" | "none";
|
||||||
|
|
||||||
|
const BROWSER_MODES = ["http", "ssh", "rdp", "vnc"];
|
||||||
|
|
||||||
|
export function derivePublicAuthState(
|
||||||
|
mode: string | null,
|
||||||
|
authInfo: Pick<
|
||||||
|
GetResourceAuthInfoResponse,
|
||||||
|
"password" | "pincode" | "sso" | "whitelist" | "headerAuth"
|
||||||
|
>
|
||||||
|
): PublicAuthState {
|
||||||
|
if (!BROWSER_MODES.includes(mode || "")) {
|
||||||
|
return "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
authInfo.password ||
|
||||||
|
authInfo.pincode ||
|
||||||
|
authInfo.sso ||
|
||||||
|
authInfo.whitelist ||
|
||||||
|
authInfo.headerAuth
|
||||||
|
) {
|
||||||
|
return "protected";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "not_protected";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPublicResourceType(
|
||||||
|
resource: Pick<GetResourceResponse, "mode" | "ssl">
|
||||||
|
): string {
|
||||||
|
if (resource.mode === "http") {
|
||||||
|
return resource.ssl ? "HTTPS" : "HTTP";
|
||||||
|
}
|
||||||
|
|
||||||
|
const mode = (resource.mode || "").toLowerCase();
|
||||||
|
if (mode === "tcp") {
|
||||||
|
return "TCP";
|
||||||
|
}
|
||||||
|
if (mode === "udp") {
|
||||||
|
return "UDP";
|
||||||
|
}
|
||||||
|
|
||||||
|
return (resource.mode || "—").toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PortProtocolState = "all" | "blocked" | "custom";
|
||||||
|
|
||||||
|
function getPortProtocolState(
|
||||||
|
value: string | null | undefined
|
||||||
|
): PortProtocolState {
|
||||||
|
if (value === "*") {
|
||||||
|
return "all";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!value || value.trim() === "") {
|
||||||
|
return "blocked";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "custom";
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PortRestrictionDisplay = {
|
||||||
|
hasNonDefaultPorts: boolean;
|
||||||
|
tcp: { state: PortProtocolState; ports: string | null };
|
||||||
|
udp: { state: PortProtocolState; ports: string | null };
|
||||||
|
};
|
||||||
|
|
||||||
|
export function formatPortRestrictionDisplay(
|
||||||
|
resource: Pick<
|
||||||
|
GetSiteResourceResponse,
|
||||||
|
"tcpPortRangeString" | "udpPortRangeString"
|
||||||
|
>
|
||||||
|
): PortRestrictionDisplay {
|
||||||
|
const tcpState = getPortProtocolState(resource.tcpPortRangeString);
|
||||||
|
const udpState = getPortProtocolState(resource.udpPortRangeString);
|
||||||
|
|
||||||
|
return {
|
||||||
|
hasNonDefaultPorts: tcpState !== "all" || udpState !== "all",
|
||||||
|
tcp: {
|
||||||
|
state: tcpState,
|
||||||
|
ports: tcpState === "custom" ? resource.tcpPortRangeString : null
|
||||||
|
},
|
||||||
|
udp: {
|
||||||
|
state: udpState,
|
||||||
|
ports: udpState === "custom" ? resource.udpPortRangeString : null
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -54,8 +54,12 @@ import type {
|
|||||||
ListLauncherSitesResponse,
|
ListLauncherSitesResponse,
|
||||||
ListLauncherViewsResponse,
|
ListLauncherViewsResponse,
|
||||||
LauncherListQuery,
|
LauncherListQuery,
|
||||||
|
LauncherResource,
|
||||||
LauncherViewConfig
|
LauncherViewConfig
|
||||||
} from "@server/routers/launcher/types";
|
} from "@server/routers/launcher/types";
|
||||||
|
import type { GetResourceResponse } from "@server/routers/resource/getResource";
|
||||||
|
import type { GetResourceAuthInfoResponse } from "@server/routers/resource/getResourceAuthInfo";
|
||||||
|
import type { GetSiteResourceResponse } from "@server/routers/siteResource/getSiteResource";
|
||||||
import type { LauncherQueryFilters } from "@app/lib/launcherSearchParams";
|
import type { LauncherQueryFilters } from "@app/lib/launcherSearchParams";
|
||||||
import { buildLauncherSearchParams } from "@app/lib/launcherSearchParams";
|
import { buildLauncherSearchParams } from "@app/lib/launcherSearchParams";
|
||||||
|
|
||||||
@@ -1313,5 +1317,48 @@ export const launcherQueries = {
|
|||||||
>(`/org/${orgId}/launcher/scale?${sp.toString()}`, { signal });
|
>(`/org/${orgId}/launcher/scale?${sp.toString()}`, { signal });
|
||||||
return res.data.data.scale;
|
return res.data.data.scale;
|
||||||
}
|
}
|
||||||
|
}),
|
||||||
|
resourceDetail: (orgId: string, resource: LauncherResource | null) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: [
|
||||||
|
"ORG",
|
||||||
|
orgId,
|
||||||
|
"LAUNCHER",
|
||||||
|
"RESOURCE_DETAIL",
|
||||||
|
resource?.launcherResourceKey ?? null
|
||||||
|
] as const,
|
||||||
|
enabled: resource != null,
|
||||||
|
queryFn: async ({ signal, meta }) => {
|
||||||
|
if (!resource) {
|
||||||
|
throw new Error("Resource is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resource.resourceType === "public") {
|
||||||
|
const res = await meta!.api.get<
|
||||||
|
AxiosResponse<GetResourceResponse>
|
||||||
|
>(`/org/${orgId}/resource/${resource.niceId}`, { signal });
|
||||||
|
const resourceData = res.data.data;
|
||||||
|
const authRes = await meta!.api.get<
|
||||||
|
AxiosResponse<GetResourceAuthInfoResponse>
|
||||||
|
>(`/resource/${resourceData.resourceGuid}/auth`, {
|
||||||
|
signal
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
resourceType: "public" as const,
|
||||||
|
data: resourceData,
|
||||||
|
authInfo: authRes.data.data
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const siteResourceId =
|
||||||
|
resource.siteResourceId ?? resource.resourceId;
|
||||||
|
const res = await meta!.api.get<
|
||||||
|
AxiosResponse<GetSiteResourceResponse>
|
||||||
|
>(`/org/${orgId}/site-resource/${siteResourceId}`, { signal });
|
||||||
|
return {
|
||||||
|
resourceType: "site" as const,
|
||||||
|
data: res.data.data
|
||||||
|
};
|
||||||
|
}
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user