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}
+