chore: extract app access logic for reuse (#17724)

We are starting to add app links in many places in the UI, and to make
it consistent, this PR extracts the most core logic into the
modules/apps for reuse.

Related to https://github.com/coder/coder/issues/17311
This commit is contained in:
Bruno Quaresma
2025-05-09 10:41:52 -03:00
committed by GitHub
parent 2696926003
commit f897981e78
10 changed files with 457 additions and 397 deletions
+119
View File
@@ -0,0 +1,119 @@
import {
MockWorkspace,
MockWorkspaceAgent,
MockWorkspaceApp,
} from "testHelpers/entities";
import { SESSION_TOKEN_PLACEHOLDER, getAppHref } from "./apps";
describe("getAppHref", () => {
it("returns the URL without changes when external app has regular URL", () => {
const externalApp = {
...MockWorkspaceApp,
external: true,
url: "https://example.com",
};
const href = getAppHref(externalApp, {
host: "*.apps-host.tld",
path: "/path-base",
agent: MockWorkspaceAgent,
workspace: MockWorkspace,
});
expect(href).toBe(externalApp.url);
});
it("returns the URL with the session token replaced when external app needs session token", () => {
const externalApp = {
...MockWorkspaceApp,
external: true,
url: `vscode://example.com?token=${SESSION_TOKEN_PLACEHOLDER}`,
};
const href = getAppHref(externalApp, {
host: "*.apps-host.tld",
path: "/path-base",
agent: MockWorkspaceAgent,
workspace: MockWorkspace,
token: "user-session-token",
});
expect(href).toBe("vscode://example.com?token=user-session-token");
});
it("doesn't return the URL with the session token replaced when using the HTTP protocol", () => {
const externalApp = {
...MockWorkspaceApp,
external: true,
url: `https://example.com?token=${SESSION_TOKEN_PLACEHOLDER}`,
};
const href = getAppHref(externalApp, {
host: "*.apps-host.tld",
path: "/path-base",
agent: MockWorkspaceAgent,
workspace: MockWorkspace,
token: "user-session-token",
});
expect(href).toBe(externalApp.url);
});
it("returns a path when app doesn't use a subdomain", () => {
const app = {
...MockWorkspaceApp,
subdomain: false,
};
const href = getAppHref(app, {
host: "*.apps-host.tld",
agent: MockWorkspaceAgent,
workspace: MockWorkspace,
path: "/path-base",
});
expect(href).toBe(
`/path-base/@${MockWorkspace.owner_name}/Test-Workspace.a-workspace-agent/apps/${app.slug}/`,
);
});
it("includes the command in the URL when app has a command", () => {
const app = {
...MockWorkspaceApp,
command: "ls -la",
};
const href = getAppHref(app, {
host: "*.apps-host.tld",
agent: MockWorkspaceAgent,
workspace: MockWorkspace,
path: "",
});
expect(href).toBe(
`/@${MockWorkspace.owner_name}/Test-Workspace.a-workspace-agent/terminal?command=ls%20-la`,
);
});
it("uses the subdomain when app has a subdomain", () => {
const app = {
...MockWorkspaceApp,
subdomain: true,
subdomain_name: "hellocoder",
};
const href = getAppHref(app, {
host: "*.apps-host.tld",
agent: MockWorkspaceAgent,
workspace: MockWorkspace,
path: "/path-base",
});
expect(href).toBe("http://hellocoder.apps-host.tld/");
});
it("returns a path when app has a subdomain but no subdomain name", () => {
const app = {
...MockWorkspaceApp,
subdomain: true,
subdomain_name: undefined,
};
const href = getAppHref(app, {
host: "*.apps-host.tld",
agent: MockWorkspaceAgent,
workspace: MockWorkspace,
path: "/path-base",
});
expect(href).toBe(
`/path-base/@${MockWorkspace.owner_name}/Test-Workspace.a-workspace-agent/apps/${app.slug}/`,
);
});
});
+80 -1
View File
@@ -1,3 +1,15 @@
import type {
Workspace,
WorkspaceAgent,
WorkspaceApp,
} from "api/typesGenerated";
// This is a magic undocumented string that is replaced
// with a brand-new session token from the backend.
// This only exists for external URLs, and should only
// be used internally, and is highly subject to break.
export const SESSION_TOKEN_PLACEHOLDER = "$SESSION_TOKEN";
type GetVSCodeHrefParams = {
owner: string;
workspace: string;
@@ -49,6 +61,73 @@ export const getTerminalHref = ({
}/terminal?${params}`;
};
export const openAppInNewWindow = (name: string, href: string) => {
export const openAppInNewWindow = (href: string) => {
window.open(href, "_blank", "width=900,height=600");
};
export type GetAppHrefParams = {
path: string;
host: string;
workspace: Workspace;
agent: WorkspaceAgent;
token?: string;
};
export const getAppHref = (
app: WorkspaceApp,
{ path, token, workspace, agent, host }: GetAppHrefParams,
): string => {
if (isExternalApp(app)) {
return needsSessionToken(app)
? app.url.replaceAll(SESSION_TOKEN_PLACEHOLDER, token ?? "")
: app.url;
}
// The backend redirects if the trailing slash isn't included, so we add it
// here to avoid extra roundtrips.
let href = `${path}/@${workspace.owner_name}/${workspace.name}.${
agent.name
}/apps/${encodeURIComponent(app.slug)}/`;
if (app.command) {
// Terminal links are relative. The terminal page knows how
// to select the correct workspace proxy for the websocket
// connection.
href = `/@${workspace.owner_name}/${workspace.name}.${
agent.name
}/terminal?command=${encodeURIComponent(app.command)}`;
}
if (host && app.subdomain && app.subdomain_name) {
const baseUrl = `${window.location.protocol}//${host.replace(/\*/g, app.subdomain_name)}`;
const url = new URL(baseUrl);
url.pathname = "/";
href = url.toString();
}
return href;
};
export const needsSessionToken = (app: WorkspaceApp) => {
if (!isExternalApp(app)) {
return false;
}
// HTTP links should never need the session token, since Cookies
// handle sharing it when you access the Coder Dashboard. We should
// never be forwarding the bare session token to other domains!
const isHttp = app.url.startsWith("http");
const requiresSessionToken = app.url.includes(SESSION_TOKEN_PLACEHOLDER);
return requiresSessionToken && !isHttp;
};
type ExternalWorkspaceApp = WorkspaceApp & {
external: true;
url: string;
};
export const isExternalApp = (
app: WorkspaceApp,
): app is ExternalWorkspaceApp => {
return app.external && app.url !== undefined;
};
+75
View File
@@ -0,0 +1,75 @@
import { apiKey } from "api/queries/users";
import type {
Workspace,
WorkspaceAgent,
WorkspaceApp,
} from "api/typesGenerated";
import { displayError } from "components/GlobalSnackbar/utils";
import { useProxy } from "contexts/ProxyContext";
import type React from "react";
import { useQuery } from "react-query";
import {
getAppHref,
isExternalApp,
needsSessionToken,
openAppInNewWindow,
} from "./apps";
type UseAppLinkParams = {
workspace: Workspace;
agent: WorkspaceAgent;
};
export const useAppLink = (
app: WorkspaceApp,
{ agent, workspace }: UseAppLinkParams,
) => {
const label = app.display_name ?? app.slug;
const { proxy } = useProxy();
const { data: apiKeyResponse } = useQuery({
...apiKey(),
enabled: isExternalApp(app) && needsSessionToken(app),
});
const href = getAppHref(app, {
agent,
workspace,
token: apiKeyResponse?.key ?? "",
path: proxy.preferredPathAppURL,
host: proxy.preferredWildcardHostname,
});
const onClick = (e: React.MouseEvent) => {
if (!e.currentTarget.getAttribute("href")) {
return;
}
if (app.external) {
// When browser recognizes the protocol and is able to navigate to the app,
// it will blur away, and will stop the timer. Otherwise,
// an error message will be displayed.
const openAppExternallyFailedTimeout = 500;
const openAppExternallyFailed = setTimeout(() => {
displayError(`${label} must be installed first.`);
}, openAppExternallyFailedTimeout);
window.addEventListener("blur", () => {
clearTimeout(openAppExternallyFailed);
});
}
switch (app.open_in) {
case "slim-window": {
e.preventDefault();
openAppInNewWindow(href);
return;
}
}
};
return {
href,
onClick,
label,
hasToken: !!apiKeyResponse?.key,
};
};
+13 -88
View File
@@ -1,8 +1,6 @@
import { useTheme } from "@emotion/react";
import ErrorOutlineIcon from "@mui/icons-material/ErrorOutline";
import { API } from "api/api";
import type * as TypesGen from "api/typesGenerated";
import { displayError } from "components/GlobalSnackbar/utils";
import { Spinner } from "components/Spinner/Spinner";
import {
Tooltip,
@@ -11,9 +9,9 @@ import {
TooltipTrigger,
} from "components/Tooltip/Tooltip";
import { useProxy } from "contexts/ProxyContext";
import { needsSessionToken } from "modules/apps/apps";
import { useAppLink } from "modules/apps/useAppLink";
import { type FC, useState } from "react";
import { createAppLinkHref } from "utils/apps";
import { generateRandomString } from "utils/random";
import { AgentButton } from "../AgentButton";
import { BaseIcon } from "./BaseIcon";
import { ShareIcon } from "./ShareIcon";
@@ -26,11 +24,6 @@ export const DisplayAppNameMap: Record<TypesGen.DisplayApp, string> = {
web_terminal: "Terminal",
};
const Language = {
appTitle: (appName: string, identifier: string): string =>
`${appName} - ${identifier}`,
};
export interface AppLinkProps {
workspace: TypesGen.Workspace;
app: TypesGen.WorkspaceApp;
@@ -39,24 +32,10 @@ export interface AppLinkProps {
export const AppLink: FC<AppLinkProps> = ({ app, workspace, agent }) => {
const { proxy } = useProxy();
const preferredPathBase = proxy.preferredPathAppURL;
const appsHost = proxy.preferredWildcardHostname;
const [fetchingSessionToken, setFetchingSessionToken] = useState(false);
const host = proxy.preferredWildcardHostname;
const [iconError, setIconError] = useState(false);
const theme = useTheme();
const username = workspace.owner_name;
const displayName = app.display_name || app.slug;
const href = createAppLinkHref(
window.location.protocol,
preferredPathBase,
appsHost,
app.slug,
username,
workspace,
agent,
app,
);
const link = useAppLink(app, { agent, workspace });
// canClick is ONLY false when it's a subdomain app and the admin hasn't
// enabled wildcard access URL or the session token is being fetched.
@@ -64,28 +43,32 @@ export const AppLink: FC<AppLinkProps> = ({ app, workspace, agent }) => {
// To avoid bugs in the healthcheck code locking users out of apps, we no
// longer block access to apps if they are unhealthy/initializing.
let canClick = true;
let primaryTooltip = "";
let icon = !iconError && (
<BaseIcon app={app} onIconPathError={() => setIconError(true)} />
);
let primaryTooltip = "";
if (app.health === "initializing") {
icon = <Spinner loading />;
primaryTooltip = "Initializing...";
}
if (app.health === "unhealthy") {
icon = <ErrorOutlineIcon css={{ color: theme.palette.warning.light }} />;
primaryTooltip = "Unhealthy";
}
if (!appsHost && app.subdomain) {
if (!host && app.subdomain) {
canClick = false;
icon = <ErrorOutlineIcon css={{ color: theme.palette.grey[300] }} />;
primaryTooltip =
"Your admin has not configured subdomain application access";
}
if (fetchingSessionToken) {
if (needsSessionToken(app) && !link.hasToken) {
canClick = false;
}
if (
agent.lifecycle_state === "starting" &&
agent.startup_script_behavior === "blocking"
@@ -97,67 +80,9 @@ export const AppLink: FC<AppLinkProps> = ({ app, workspace, agent }) => {
const button = (
<AgentButton asChild>
<a
href={canClick ? href : undefined}
onClick={async (event) => {
if (!canClick) {
return;
}
event.preventDefault();
// HTTP links should never need the session token, since Cookies
// handle sharing it when you access the Coder Dashboard. We should
// never be forwarding the bare session token to other domains!
const isHttp = app.url?.startsWith("http");
if (app.external && !isHttp) {
// This is a magic undocumented string that is replaced
// with a brand-new session token from the backend.
// This only exists for external URLs, and should only
// be used internally, and is highly subject to break.
const magicTokenString = "$SESSION_TOKEN";
const hasMagicToken = href.indexOf(magicTokenString);
let url = href;
if (hasMagicToken !== -1) {
setFetchingSessionToken(true);
const key = await API.getApiKey();
url = href.replaceAll(magicTokenString, key.key);
setFetchingSessionToken(false);
}
// When browser recognizes the protocol and is able to navigate to the app,
// it will blur away, and will stop the timer. Otherwise,
// an error message will be displayed.
const openAppExternallyFailedTimeout = 500;
const openAppExternallyFailed = setTimeout(() => {
displayError(`${displayName} must be installed first.`);
}, openAppExternallyFailedTimeout);
window.addEventListener("blur", () => {
clearTimeout(openAppExternallyFailed);
});
window.location.href = url;
return;
}
switch (app.open_in) {
case "slim-window": {
window.open(
href,
Language.appTitle(displayName, generateRandomString(12)),
"width=900,height=600",
);
return;
}
default: {
window.open(href);
return;
}
}
}}
>
<a href={canClick ? link.href : undefined} onClick={link.onClick}>
{icon}
{displayName}
{link.label}
{canShare && <ShareIcon app={app} />}
</a>
</AgentButton>
@@ -37,7 +37,7 @@ export const TerminalLink: FC<TerminalLinkProps> = ({
href={href}
onClick={(event: MouseEvent<HTMLElement>) => {
event.preventDefault();
openAppInNewWindow("Terminal", href);
openAppInNewWindow(href);
}}
>
<TerminalIcon />
@@ -13,8 +13,8 @@ import type {
WorkspaceAgent,
WorkspaceApp,
} from "api/typesGenerated";
import { useProxy } from "contexts/ProxyContext";
import { createAppLinkHref } from "utils/apps";
import { useAppLink } from "modules/apps/useAppLink";
import type { FC } from "react";
const formatURI = (uri: string) => {
try {
@@ -68,33 +68,7 @@ export const WorkspaceAppStatus = ({
agent?: WorkspaceAgent;
}) => {
const theme = useTheme();
const { proxy } = useProxy();
const preferredPathBase = proxy.preferredPathAppURL;
const appsHost = proxy.preferredWildcardHostname;
const commonStyles = {
fontSize: "12px",
lineHeight: "15px",
color: theme.palette.text.disabled,
display: "inline-flex",
alignItems: "center",
gap: 4,
padding: "2px 6px",
borderRadius: "6px",
bgcolor: "transparent",
minWidth: 0,
maxWidth: "fit-content",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
textDecoration: "none",
transition: "all 0.15s ease-in-out",
"&:hover": {
textDecoration: "none",
backgroundColor: theme.palette.action.hover,
color: theme.palette.text.secondary,
},
};
const commonStyles = useCommonStyles();
if (!status) {
return (
@@ -122,20 +96,6 @@ export const WorkspaceAppStatus = ({
}
const isFileURI = status.uri?.startsWith("file://");
let appHref: string | undefined;
if (app && agent) {
appHref = createAppLinkHref(
window.location.protocol,
preferredPathBase,
appsHost,
app.slug,
workspace.owner_name,
workspace,
agent,
app,
);
}
return (
<div
css={{
@@ -187,47 +147,8 @@ export const WorkspaceAppStatus = ({
alignItems: "center",
}}
>
{app && appHref && (
<a
href={appHref}
target="_blank"
rel="noopener noreferrer"
css={{
...commonStyles,
marginRight: 8,
position: "relative",
color: theme.palette.text.secondary,
"&:hover": {
...commonStyles["&:hover"],
color: theme.palette.text.primary,
"& img": {
opacity: 1,
},
},
}}
>
{app.icon ? (
<img
src={app.icon}
alt={`${app.display_name} icon`}
width={14}
height={14}
css={{
borderRadius: "3px",
opacity: 0.8,
marginRight: 4,
}}
/>
) : (
<AppsIcon
sx={{
fontSize: 14,
opacity: 0.7,
}}
/>
)}
<span>{app.display_name}</span>
</a>
{app && agent && (
<AppLink app={app} workspace={workspace} agent={agent} />
)}
{status.uri && (
<div
@@ -297,3 +218,87 @@ export const WorkspaceAppStatus = ({
</div>
);
};
type AppLinkProps = {
app: WorkspaceApp;
workspace: Workspace;
agent: WorkspaceAgent;
};
const AppLink: FC<AppLinkProps> = ({ app, workspace, agent }) => {
const theme = useTheme();
const commonStyles = useCommonStyles();
const link = useAppLink(app, { agent, workspace });
return (
<a
href={link.href}
onClick={link.onClick}
target="_blank"
rel="noopener noreferrer"
css={{
...commonStyles,
marginRight: 8,
position: "relative",
color: theme.palette.text.secondary,
"&:hover": {
...commonStyles["&:hover"],
color: theme.palette.text.primary,
"& img": {
opacity: 1,
},
},
}}
>
{app.icon ? (
<img
src={app.icon}
alt={`${app.display_name} icon`}
width={14}
height={14}
css={{
borderRadius: "3px",
opacity: 0.8,
marginRight: 4,
}}
/>
) : (
<AppsIcon
sx={{
fontSize: 14,
opacity: 0.7,
}}
/>
)}
<span>{app.display_name}</span>
</a>
);
};
const useCommonStyles = () => {
const theme = useTheme();
return {
fontSize: "12px",
lineHeight: "15px",
color: theme.palette.text.disabled,
display: "inline-flex",
alignItems: "center",
gap: 4,
padding: "2px 6px",
borderRadius: "6px",
bgcolor: "transparent",
minWidth: 0,
maxWidth: "fit-content",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
textDecoration: "none",
transition: "all 0.15s ease-in-out",
"&:hover": {
textDecoration: "none",
backgroundColor: theme.palette.action.hover,
color: theme.palette.text.secondary,
},
};
};
+75 -76
View File
@@ -17,10 +17,9 @@ import type {
WorkspaceAgent,
WorkspaceApp,
} from "api/typesGenerated";
import { useProxy } from "contexts/ProxyContext";
import { formatDistance, formatDistanceToNow } from "date-fns";
import { useAppLink } from "modules/apps/useAppLink";
import type { FC } from "react";
import { createAppLinkHref } from "utils/apps";
const getStatusColor = (
theme: Theme,
@@ -153,9 +152,6 @@ export const AppStatuses: FC<AppStatusesProps> = ({
referenceDate,
}) => {
const theme = useTheme();
const { proxy } = useProxy();
const preferredPathBase = proxy.preferredPathAppURL;
const appsHost = proxy.preferredWildcardHostname;
// 1. Flatten all statuses and include the parent app object
const allStatuses: StatusWithAppInfo[] = apps.flatMap((app) =>
@@ -194,22 +190,8 @@ export const AppStatuses: FC<AppStatusesProps> = ({
// Get the associated app for this status
const currentApp = status.app;
let appHref: string | undefined;
const agent = agents.find((agent) => agent.id === status.agent_id);
if (currentApp && agent) {
appHref = createAppLinkHref(
window.location.protocol,
preferredPathBase,
appsHost,
currentApp.slug,
workspace.owner_name,
workspace,
agent,
currentApp,
);
}
// Determine if app link should be shown
const showAppLink =
isLatest ||
@@ -280,63 +262,12 @@ export const AppStatuses: FC<AppStatusesProps> = ({
}}
>
{/* Conditional App Link */}
{currentApp && appHref && showAppLink && (
<Tooltip
title={`Open ${currentApp.display_name}`}
placement="top"
>
<Link
href={appHref}
target="_blank"
rel="noopener"
sx={{
...commonStyles,
position: "relative",
"& .MuiSvgIcon-root": {
fontSize: 14,
opacity: 0.7,
mr: 0.5,
},
"& img": {
opacity: 0.8,
marginRight: 0.5,
},
"&:hover": {
...commonStyles["&:hover"],
color: theme.palette.text.primary, // Keep consistent hover color
"& img": {
opacity: 1,
},
"& .MuiSvgIcon-root": {
opacity: 1,
},
},
}}
>
{currentApp.icon ? (
<img
src={currentApp.icon}
alt={`${currentApp.display_name} icon`}
width={14}
height={14}
style={{ borderRadius: "3px" }}
/>
) : (
<AppsIcon />
)}
{/* Keep app name short */}
<span
css={{
lineHeight: 1,
textOverflow: "ellipsis",
overflow: "hidden",
whiteSpace: "nowrap",
}}
>
{currentApp.display_name}
</span>
</Link>
</Tooltip>
{currentApp && agent && showAppLink && (
<AppLink
app={currentApp}
agent={agent}
workspace={workspace}
/>
)}
{/* Existing URI Link */}
@@ -409,3 +340,71 @@ export const AppStatuses: FC<AppStatusesProps> = ({
</div>
);
};
type AppLinkProps = {
app: WorkspaceApp;
agent: WorkspaceAgent;
workspace: Workspace;
};
const AppLink: FC<AppLinkProps> = ({ app, agent, workspace }) => {
const link = useAppLink(app, { agent, workspace });
const theme = useTheme();
return (
<Tooltip title={`Open ${link.label}`} placement="top">
<Link
href={link.href}
onClick={link.onClick}
target="_blank"
rel="noopener"
sx={{
...commonStyles,
position: "relative",
"& .MuiSvgIcon-root": {
fontSize: 14,
opacity: 0.7,
mr: 0.5,
},
"& img": {
opacity: 0.8,
marginRight: 0.5,
},
"&:hover": {
...commonStyles["&:hover"],
color: theme.palette.text.primary, // Keep consistent hover color
"& img": {
opacity: 1,
},
"& .MuiSvgIcon-root": {
opacity: 1,
},
},
}}
>
{app.icon ? (
<img
src={app.icon}
alt={`${link.label} icon`}
width={14}
height={14}
style={{ borderRadius: "3px" }}
/>
) : (
<AppsIcon />
)}
{/* Keep app name short */}
<span
css={{
lineHeight: 1,
textOverflow: "ellipsis",
overflow: "hidden",
whiteSpace: "nowrap",
}}
>
{link.label}
</span>
</Link>
</Tooltip>
);
};
@@ -627,8 +627,8 @@ type WorkspaceAppsProps = {
};
const WorkspaceApps: FC<WorkspaceAppsProps> = ({ workspace }) => {
const { data: apiKeyRes } = useQuery(apiKey());
const token = apiKeyRes?.key;
const { data: apiKeyResponse } = useQuery(apiKey());
const token = apiKeyResponse?.key;
/**
* Coder is pretty flexible and allows an enormous variety of use cases, such
@@ -658,7 +658,7 @@ const WorkspaceApps: FC<WorkspaceAppsProps> = ({ workspace }) => {
owner: workspace.owner_name,
workspace: workspace.name,
agent: agent.name,
token: apiKeyRes?.key ?? "",
token: token ?? "",
folder: agent.expanded_directory,
})}
>
@@ -676,7 +676,7 @@ const WorkspaceApps: FC<WorkspaceAppsProps> = ({ workspace }) => {
owner: workspace.owner_name,
workspace: workspace.name,
agent: agent.name,
token: apiKeyRes?.key ?? "",
token: token ?? "",
folder: agent.expanded_directory,
})}
>
@@ -696,7 +696,7 @@ const WorkspaceApps: FC<WorkspaceAppsProps> = ({ workspace }) => {
href={href}
onClick={(e) => {
e.preventDefault();
openAppInNewWindow("Terminal", href);
openAppInNewWindow(href);
}}
label="Open Terminal"
>
-103
View File
@@ -1,103 +0,0 @@
import {
MockWorkspace,
MockWorkspaceAgent,
MockWorkspaceApp,
} from "testHelpers/entities";
import { createAppLinkHref } from "./apps";
describe("create app link", () => {
it("with external URL", () => {
const externalURL = "https://external-url.tld";
const href = createAppLinkHref(
"http:",
"/path-base",
"*.apps-host.tld",
"app-slug",
"username",
MockWorkspace,
MockWorkspaceAgent,
{
...MockWorkspaceApp,
external: true,
url: externalURL,
},
);
expect(href).toBe(externalURL);
});
it("without subdomain", () => {
const href = createAppLinkHref(
"http:",
"/path-base",
"*.apps-host.tld",
"app-slug",
"username",
MockWorkspace,
MockWorkspaceAgent,
{
...MockWorkspaceApp,
subdomain: false,
},
);
expect(href).toBe(
"/path-base/@username/Test-Workspace.a-workspace-agent/apps/app-slug/",
);
});
it("with command", () => {
const href = createAppLinkHref(
"https:",
"/path-base",
"*.apps-host.tld",
"app-slug",
"username",
MockWorkspace,
MockWorkspaceAgent,
{
...MockWorkspaceApp,
command: "ls -la",
},
);
expect(href).toBe(
"/@username/Test-Workspace.a-workspace-agent/terminal?command=ls%20-la",
);
});
it("with subdomain", () => {
const href = createAppLinkHref(
"ftps:",
"/path-base",
"*.apps-host.tld",
"app-slug",
"username",
MockWorkspace,
MockWorkspaceAgent,
{
...MockWorkspaceApp,
subdomain: true,
subdomain_name: "hellocoder",
},
);
expect(href).toBe("ftps://hellocoder.apps-host.tld/");
});
it("with subdomain, but not apps host", () => {
const href = createAppLinkHref(
"ftps:",
"/path-base",
"",
"app-slug",
"username",
MockWorkspace,
MockWorkspaceAgent,
{
...MockWorkspaceApp,
subdomain: true,
subdomain_name: "hellocoder",
},
);
expect(href).toBe(
"/path-base/@username/Test-Workspace.a-workspace-agent/apps/app-slug/",
);
});
});
-39
View File
@@ -1,39 +0,0 @@
import type * as TypesGen from "api/typesGenerated";
export const createAppLinkHref = (
protocol: string,
preferredPathBase: string,
appsHost: string,
appSlug: string,
username: string,
workspace: TypesGen.Workspace,
agent: TypesGen.WorkspaceAgent,
app: TypesGen.WorkspaceApp,
): string => {
if (app.external) {
return app.url as string;
}
// The backend redirects if the trailing slash isn't included, so we add it
// here to avoid extra roundtrips.
let href = `${preferredPathBase}/@${username}/${workspace.name}.${
agent.name
}/apps/${encodeURIComponent(appSlug)}/`;
if (app.command) {
// Terminal links are relative. The terminal page knows how
// to select the correct workspace proxy for the websocket
// connection.
href = `/@${username}/${workspace.name}.${
agent.name
}/terminal?command=${encodeURIComponent(app.command)}`;
}
if (appsHost && app.subdomain && app.subdomain_name) {
const baseUrl = `${protocol}//${appsHost.replace(/\*/g, app.subdomain_name)}`;
const url = new URL(baseUrl);
url.pathname = "/";
href = url.toString();
}
return href;
};