Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions site/src/modules/apps/WorkspaceAppFrame.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ export const WorkspaceAppFrame: FC<WorkspaceAppFrameProps> = ({
variant="subtle"
onClick={(e) => {
e.preventDefault();
if (frameRef.current?.contentWindow) {
if (link.href && frameRef.current?.contentWindow) {
frameRef.current.contentWindow.location.href = link.href;
}
}}
Expand All @@ -83,7 +83,11 @@ export const WorkspaceAppFrame: FC<WorkspaceAppFrameProps> = ({
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<RouterLink to={link.href} target="_blank" rel="noreferrer">
<RouterLink
to={link.href ?? ""}
target="_blank"
rel="noreferrer"
>
<ExternalLinkIcon />
Open app in new tab
</RouterLink>
Expand Down
153 changes: 102 additions & 51 deletions site/src/modules/apps/useAppLink.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import type React from "react";
import { useQuery } from "react-query";
import { useMutation } from "react-query";
import { toast } from "sonner";
import { apiKey } from "#/api/queries/users";
import { API } from "#/api/api";
import { getErrorMessage } from "#/api/errors";
import type {
Workspace,
WorkspaceAgent,
Expand All @@ -21,10 +22,12 @@ type UseAppLinkParams = {
};

type AppLink = {
href: string;
// Token-backed external apps intentionally expose no href: their URL is only
// complete once a session token is minted on click.
href: string | undefined;
onClick: (e: React.MouseEvent) => void;
label: string;
hasToken: boolean;
isLoading: boolean;
};

export const useAppLink = (
Expand All @@ -33,20 +36,100 @@ export const useAppLink = (
): AppLink => {
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,
// External apps that embed the session token in their URL need a freshly
// minted key. We defer minting until the user clicks (see `onClick`) rather
// than on mount, so that merely rendering a link no longer mints (and
// audits) a session key for an app the user may never open.
const requiresSessionToken = isExternalApp(app) && needsSessionToken(app);

const buildHref = (token: string): string =>
getAppHref(app, {
agent,
workspace,
token,
path: proxy.preferredPathAppURL,
host: proxy.preferredWildcardHostname,
});

// Custom-protocol (non-HTTP) external apps can silently fail when the target
// application isn't installed. The browser blurs when it hands control to
// the protocol handler, which clears the timeout before the error fires.
const notifyOnOpenExternalAppFailed = () => {
const openAppExternallyFailedTimeout = 1500;
const openAppExternallyFailed = setTimeout(() => {
// Check if this is a JetBrains IDE app
// starts with "jetbrains-gateway://connect#type=coder" (from https://registry.coder.com/modules/coder/jetbrains-gateway)
const isJetBrainsGateway = app.url?.startsWith("jetbrains-gateway:");
// starts with "jetbrains://gateway/coder" (from https://registry.coder.com/modules/coder/jetbrains)
const isJetBrainsToolbox = app.url?.startsWith("jetbrains:");

// Check if this is a coder:// URL
const isCoderApp = app.url?.startsWith("coder:");

if (isJetBrainsGateway) {
toast.error(`Failed to open "${label}".`, {
description: "JetBrains Gateway must be installed.",
});
} else if (isJetBrainsToolbox) {
toast.error(`Failed to open "${label}".`, {
description: "JetBrains Toolbox must be installed.",
});
} else if (isCoderApp) {
toast.error(`Failed to open "${label}".`, {
description: "Coder Desktop must be installed.",
});
} else {
toast.error(`Failed to open "${label}".`, {
description: "The app must be installed first.",
});
}
}, openAppExternallyFailedTimeout);
window.addEventListener(
"blur",
() => {
clearTimeout(openAppExternallyFailed);
},
{ once: true },
);
};

// The success/error handlers live on the mutation (not on the `mutate` call)
// so they still run when the triggering element unmounts before the request
// settles, e.g. a dropdown menu item that closes on select. Callbacks passed
// to `mutate` are dropped once the observer unmounts, which would otherwise
// swallow both the navigation and the failure toast.
const generateKeyMutation = useMutation({
mutationFn: () => API.getApiKey(),
onSuccess: ({ key }) => {
notifyOnOpenExternalAppFailed();
location.href = buildHref(key);
},
onError: (error) => {
toast.error(getErrorMessage(error, `Failed to open "${label}".`));
},
});

// Token-backed apps expose no navigable href: the token is minted on click
// and the final URL is built then. Exposing a tokenless href would let
// middle-click or "Open link" launch the custom protocol with an empty
// token, so we omit it entirely. Non-token apps still render as anchors.
const href = requiresSessionToken ? undefined : buildHref("");

const onClick = (e: React.MouseEvent) => {
// Apps that embed a session token mint it on click instead of on mount.
// These are always custom-protocol (non-HTTP) external apps, so we build
// the final URL with the freshly minted token and navigate to it via
// `location.href`, relying on the browser's protocol handler.
if (requiresSessionToken) {
e.preventDefault();
if (generateKeyMutation.isPending) {
return;
}
generateKeyMutation.mutate();
return;
}

if (!e.currentTarget.getAttribute("href")) {
return;
}
Expand All @@ -57,41 +140,7 @@ export const useAppLink = (
app.external && app.url && !app.url.startsWith("http");

if (isExternalProtocolApp) {
// 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 = 1500;
const openAppExternallyFailed = setTimeout(() => {
// Check if this is a JetBrains IDE app
// starts with "jetbrains-gateway://connect#type=coder" (from https://registry.coder.com/modules/coder/jetbrains-gateway)
const isJetBrainsGateway = app.url?.startsWith("jetbrains-gateway:");
// starts with "jetbrains://gateway/coder" (from https://registry.coder.com/modules/coder/jetbrains)
const isJetBrainsToolbox = app.url?.startsWith("jetbrains:");

// Check if this is a coder:// URL
const isCoderApp = app.url?.startsWith("coder:");

if (isJetBrainsGateway) {
toast.error(`Failed to open "${label}".`, {
description: "JetBrains Gateway must be installed.",
});
} else if (isJetBrainsToolbox) {
toast.error(`Failed to open "${label}".`, {
description: "JetBrains Toolbox must be installed.",
});
} else if (isCoderApp) {
toast.error(`Failed to open "${label}".`, {
description: "Coder Desktop must be installed.",
});
} else {
toast.error(`Failed to open "${label}".`, {
description: "The app must be installed first.",
});
}
}, openAppExternallyFailedTimeout);
window.addEventListener("blur", () => {
clearTimeout(openAppExternallyFailed);
});
notifyOnOpenExternalAppFailed();

// Custom protocol external apps don't support open_in since they
// rely on the browser's protocol handling.
Expand All @@ -101,7 +150,9 @@ export const useAppLink = (
switch (app.open_in) {
case "slim-window": {
e.preventDefault();
openAppInNewWindow(href);
if (href) {
openAppInNewWindow(href);
}
return;
}
}
Expand All @@ -111,6 +162,6 @@ export const useAppLink = (
href,
onClick,
label,
hasToken: Boolean(apiKeyResponse?.key),
isLoading: generateKeyMutation.isPending,
};
};
95 changes: 94 additions & 1 deletion site/src/modules/resources/AppLink/AppLink.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, screen, spyOn, userEvent, within } from "storybook/test";
import {
expect,
screen,
spyOn,
userEvent,
waitFor,
within,
} from "storybook/test";
import { API } from "#/api/api";
import { getPreferredProxy } from "#/contexts/ProxyContext";
import {
MockPrimaryWorkspaceProxy,
Expand Down Expand Up @@ -257,6 +265,91 @@ export const WithTooltip: Story = {
},
};

// Regression test for DEVEX-460: external apps that embed the session token
// must not mint an API key on render. The key is minted only when the user
// clicks the link.
export const ExternalAppDefersSessionToken: Story = {
decorators: [withToaster],
// Install the spy before the component renders. `play` runs after render and
// its effects, so a regression back to eager (on-mount) minting would fetch a
// key before a spy installed in `play` exists, and the later
// `not.toHaveBeenCalled()` assertion would still pass.
beforeEach: () => {
// Never resolve: we only assert whether/when the request fires, and
// leaving it pending avoids the subsequent protocol-handler navigation.
const getApiKey = spyOn(API, "getApiKey").mockImplementation(
() => new Promise(() => {}),
);
return () => {
getApiKey.mockRestore();
};
},
args: {
workspace: MockWorkspace,
app: {
...MockWorkspaceApp,
external: true,
url: "jetbrains-gateway://connect?token=$SESSION_TOKEN",
},
agent: MockWorkspaceAgent,
},
play: async ({ canvasElement, step }) => {
const canvas = within(canvasElement);
// Token-backed apps expose no href and render as a button (the token is
// minted on click), so query by role "button".
const trigger = await canvas.findByRole("button");
const user = userEvent.setup();

await step("no API key is minted on render", async () => {
expect(API.getApiKey).not.toHaveBeenCalled();
});

await step("clicking mints the API key on demand", async () => {
await user.click(trigger);
await waitFor(() => expect(API.getApiKey).toHaveBeenCalledTimes(1));
});
},
};

// External apps that do not embed the session token must never mint a key,
// even on click, so we don't create session keys for apps that don't need one.
export const ExternalAppWithoutSessionTokenNeverMints: Story = {
decorators: [withToaster],
// Install the spy before render so an on-mount mint would be observed.
beforeEach: () => {
const getApiKey = spyOn(API, "getApiKey").mockResolvedValue({
key: "test-key",
});
return () => {
getApiKey.mockRestore();
};
},
args: {
workspace: MockWorkspace,
app: {
...MockWorkspaceApp,
external: true,
url: "https://example.com",
open_in: "slim-window",
},
agent: MockWorkspaceAgent,
},
play: async ({ canvasElement, step }) => {
// The app opens in a slim window, so stub window.open to keep the click
// from navigating the test frame.
spyOn(window, "open").mockReturnValue(null);
const canvas = within(canvasElement);
const link = await canvas.findByRole("link");
const user = userEvent.setup();

await step("no API key is minted on render or click", async () => {
expect(API.getApiKey).not.toHaveBeenCalled();
await user.click(link);
expect(API.getApiKey).not.toHaveBeenCalled();
});
},
};

export const SlimWindowPopupBlocked: Story = {
decorators: [withToaster],
args: {
Expand Down
Loading
Loading