diff --git a/messages/en-US.json b/messages/en-US.json index 90be25679..c7164aca3 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -3607,7 +3607,16 @@ "resourceLauncherDeleteViewQuestion": "Are you sure you want to delete this launcher view?", "resourceLauncherDeleteViewConfirm": "Delete View", "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", "resourceLauncherNoSite": "No Site", "resourceLauncherNoResourcesInGroup": "No resources in this group", diff --git a/server/routers/external.ts b/server/routers/external.ts index 33fcb3ab3..8a22393ca 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -327,6 +327,14 @@ authenticated.get( siteResource.listAllSiteResourcesByOrg ); +authenticated.get( + "/org/:orgId/site-resource/:siteResourceId", + verifyOrgAccess, + verifySiteResourceAccess, + verifyUserHasAction(ActionsEnum.getSiteResource), + siteResource.getSiteResource +); + authenticated.get( "/site-resource/:siteResourceId", verifySiteResourceAccess, diff --git a/src/components/CopyToClipboard.tsx b/src/components/CopyToClipboard.tsx index 7187694f7..147972e78 100644 --- a/src/components/CopyToClipboard.tsx +++ b/src/components/CopyToClipboard.tsx @@ -1,3 +1,4 @@ +import { cn } from "@app/lib/cn"; import { Check, Copy } from "lucide-react"; import Link from "next/link"; import { useState } from "react"; @@ -7,12 +8,14 @@ type CopyToClipboardProps = { text: string; displayText?: string; isLink?: boolean; + className?: string; }; const CopyToClipboard = ({ text, displayText, - isLink + isLink, + className }: CopyToClipboardProps) => { const [copied, setCopied] = useState(false); @@ -48,14 +51,20 @@ const CopyToClipboard = ({ href={text} target="_blank" 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 > {displayValue} ) : ( -; + } + + const href = accessUrl ?? undefined; + const canLink = Boolean(href && isSafeUrlForLink(href)); + + if (canLink && href) { + return ( + + ); + } + + return ( + + ); +} + +function HealthStatusDisplay({ + health +}: { + health: string | null | undefined; +}) { + const t = useTranslations(); + const status = health ?? "unknown"; + + if (status === "healthy") { + return ( +
+ + {t("resourcesTableHealthy")} +
+ ); + } + + if (status === "degraded") { + return ( +
+ + {t("resourcesTableDegraded")} +
+ ); + } + + if (status === "unhealthy") { + return ( +
+ + {t("resourcesTableUnhealthy")} +
+ ); + } + + return ( +
+ + {t("resourcesTableUnknown")} +
+ ); +} + +const PUBLIC_AUTH_BROWSER_MODES = ["http", "ssh", "rdp", "vnc"]; + +function AuthMethodStatusDisplay({ enabled }: { enabled: boolean }) { + const t = useTranslations(); + + return ( +
+ {enabled ? ( + + ) : ( + + )} + {enabled ? t("enabled") : t("disabled")} +
+ ); +} + +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 ( + + + + {t("authentication")} + + + {t("resourceLauncherAuthMethodsDescription")} + + + + + {authMethods.map((method) => ( + + {method.title} + + + + + ))} + + + + ); +} + +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 ( +
+ + + + {t("resourceLauncherResourceDetails")} + + + {t("resourceLauncherResourceDetailsDescription")} + + + + + + {t("type")} + + {formatPublicResourceType(resource)} + + + + {t("access")} + + + + + {supportsAuth ? ( + + + {t("authentication")} + + + {authState === "protected" ? ( +
+ + {t("protected")} +
+ ) : ( +
+ + {t("notProtected")} +
+ )} +
+
+ ) : null} + + {t("health")} + + + + +
+
+
+ {supportsAuth ? ( + + ) : null} +
+ ); +} + +function PrivateResourceDetails({ + launcherResource, + resource +}: { + launcherResource: LauncherResource; + resource: GetSiteResourceResponse; +}) { + const t = useTranslations(); + const modeLabel: Record = { + 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 ( +
+ + + + {t("resourceLauncherPrivateClientRequiredTitle")} + + + + {t("resourceLauncherPrivateClientRequired")}{" "} + + {t("resourceLauncherDownloadClient")} + + + + + + + + + + {t("resourceLauncherResourceDetails")} + + + {t("resourceLauncherResourceDetailsDescription")} + + + + + + {t("type")} + + {modeLabel[resource.mode]} + + + + {t("access")} + + + + + {showDestination ? ( + + + {t("editInternalResourceDialogDestination")} + + + {destination || "-"} + + + ) : null} + {showAlias ? ( + + + {t("editInternalResourceDialogAlias")} + + + {resource.alias?.trim() + ? resource.alias + : "-"} + + + ) : null} + + + {t("portRestrictions")} + + + {!portRestrictions.hasNonDefaultPorts ? ( + + {t( + "resourceLauncherNoPortRestrictions" + )} + + ) : ( +
+ {portRestrictions.tcp.state !== + "all" ? ( +
+ {t("resourceLauncherTcp")}:{" "} + {portRestrictions.tcp.state === + "blocked" + ? t("blocked") + : portRestrictions.tcp + .ports} +
+ ) : null} + {portRestrictions.udp.state !== + "all" ? ( +
+ {t("resourceLauncherUdp")}:{" "} + {portRestrictions.udp.state === + "blocked" + ? t("blocked") + : portRestrictions.udp + .ports} +
+ ) : null} +
+ )} +
+
+
+
+
+
+ ); +} + +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 ( +
+ +
+ ); + } + + if (isError || !data) { + return ( +

+ {t("resourceLauncherFailedToLoadDetails")} +

+ ); + } + + const detail = data as LauncherResourceDetailResult; + + if (detail.resourceType === "public") { + return ( + + ); + } + + return ( + + ); +} + export function LauncherResourcePanel({ open, onOpenChange, @@ -37,11 +529,16 @@ export function LauncherResourcePanel({ {resource?.name ?? ""} - - {t("resourceLauncherResourceDetailsDescription")} - - + + {resource ? ( + + ) : null} +