diff --git a/web/packages/shared/constants.ts b/web/packages/shared/constants.ts
new file mode 100644
index 0000000000000..0ea4505ed7591
--- /dev/null
+++ b/web/packages/shared/constants.ts
@@ -0,0 +1,42 @@
+/**
+ * Copyright 2023 Gravitational, Inc
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/*
+ * Be wary that this file is being loaded by both Node.js and browser environments, so keep the
+ * definitions simple and do not put environment-specific code here.
+ *
+ * If it turns out that the design package needs to depend on a constant from here, move this file
+ * to the design package or just its own package. This avoids cyclic dependencies as the shared
+ * package depends on the design package.
+ */
+
+/**
+ * TeleportNamespace is used as the namespace prefix for labels defined by Teleport which can
+ * carry metadata such as cloud AWS account or instance. Those labels can be used for RBAC.
+ *
+ * If a label with this prefix is used in a config file, the associated feature must take into
+ * account that the label might be removed, modified or could have been set by the user.
+ *
+ * See also teleport.internal and teleport.hidden.
+ */
+export const TeleportNamespace = 'teleport.dev' as const;
+
+/**
+ * ConnectMyComputerNodeOwnerLabel is a label used to control access to the node managed by
+ * Teleport Connect as part of Connect My Computer.
+ */
+export const ConnectMyComputerNodeOwnerLabel =
+ `${TeleportNamespace}/connect-my-computer/owner` as const;
diff --git a/web/packages/teleport/src/Discover/ConnectMyComputer/SetupConnect/SetupConnect.story.tsx b/web/packages/teleport/src/Discover/ConnectMyComputer/SetupConnect/SetupConnect.story.tsx
index 087f79042c2f8..8a9eab3dd228a 100644
--- a/web/packages/teleport/src/Discover/ConnectMyComputer/SetupConnect/SetupConnect.story.tsx
+++ b/web/packages/teleport/src/Discover/ConnectMyComputer/SetupConnect/SetupConnect.story.tsx
@@ -15,6 +15,9 @@
*/
import React from 'react';
+import { MemoryRouter } from 'react-router';
+import { initialize, mswLoader } from 'msw-storybook-addon';
+import { rest } from 'msw';
import {
OverrideUserAgent,
@@ -22,34 +25,108 @@ import {
} from 'shared/components/OverrideUserAgent';
import { ContextProvider } from 'teleport';
+import cfg from 'teleport/config';
import { UserContext } from 'teleport/User/UserContext';
import { createTeleportContext } from 'teleport/mocks/contexts';
import { makeDefaultUserPreferences } from 'teleport/services/userPreferences/userPreferences';
import { SetupConnect } from './SetupConnect';
+const oneDay = 1000 * 60 * 60 * 24;
+
+const setupConnectProps = {
+ prevStep: () => {},
+ nextStep: () => {},
+ // Set high default intervals and timeouts so that stories don't poll for no reason.
+ pingInterval: oneDay,
+ showHintTimeout: oneDay,
+};
+
+initialize();
+
export default {
title: 'Teleport/Discover/ConnectMyComputer/SetupConnect',
+ loaders: [mswLoader],
};
+const noNodesHandler = rest.get(cfg.api.nodesPath, (req, res, ctx) =>
+ res(ctx.json({ items: [] }))
+);
+
export const macOS = () => (
- {}} />
+
);
+macOS.parameters = {
+ msw: {
+ handlers: [noNodesHandler],
+ },
+};
+
export const Linux = () => (
- {}} />
+
);
+Linux.parameters = {
+ msw: {
+ handlers: [noNodesHandler],
+ },
+};
+
+export const Polling = () => (
+
+
+
+);
+
+Polling.parameters = {
+ msw: {
+ handlers: [noNodesHandler],
+ },
+};
+
+export const PollingSuccess = () => (
+
+
+
+);
+
+PollingSuccess.parameters = {
+ msw: {
+ handlers: [
+ rest.get(cfg.api.nodesPath, (req, res, ctx) => {
+ return res.once(ctx.json({ items: [] }));
+ }),
+ rest.get(cfg.api.nodesPath, (req, res, ctx) => {
+ return res(ctx.json({ items: [{ id: '1234', hostname: 'foo' }] }));
+ }),
+ ],
+ },
+};
+
+export const HintTimeout = () => (
+
+
+
+);
+
+HintTimeout.parameters = {
+ msw: {
+ handlers: [noNodesHandler],
+ },
+};
+
const Provider = ({ children }) => {
const ctx = createTeleportContext();
+ // The proxy version is set mostly so that the download links point to actual artifacts.
ctx.storeUser.state.cluster.proxyVersion = '14.1.0';
const preferences = makeDefaultUserPreferences();
@@ -58,15 +135,17 @@ const Provider = ({ children }) => {
const updateClusterPinnedResources = () => Promise.resolve();
return (
-
- {children}
-
+
+
+ {children}
+
+
);
};
diff --git a/web/packages/teleport/src/Discover/ConnectMyComputer/SetupConnect/SetupConnect.test.ts b/web/packages/teleport/src/Discover/ConnectMyComputer/SetupConnect/SetupConnect.test.ts
new file mode 100644
index 0000000000000..c7184774d2851
--- /dev/null
+++ b/web/packages/teleport/src/Discover/ConnectMyComputer/SetupConnect/SetupConnect.test.ts
@@ -0,0 +1,81 @@
+/**
+ * Copyright 2023 Gravitational, Inc
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { renderHook } from '@testing-library/react-hooks';
+
+import * as useTeleport from 'teleport/useTeleport';
+import NodeService from 'teleport/services/nodes/nodes';
+import TeleportContext from 'teleport/teleportContext';
+
+import { nodes } from 'teleport/Nodes/fixtures';
+
+import { Node } from 'teleport/services/nodes';
+
+import { usePollForConnectMyComputerNode } from './SetupConnect';
+
+beforeEach(() => {
+ jest.restoreAllMocks();
+});
+
+describe('usePollForConnectMyComputerNode', () => {
+ const tests: Array<{
+ name: string;
+ initialNodes: Node[];
+ }> = [
+ {
+ name: 'returns the correct node if the first polling request returns no nodes',
+ initialNodes: [],
+ },
+ {
+ name: 'returns the correct node if the first polling request returns some nodes',
+ initialNodes: [nodes[1], nodes[2]],
+ },
+ ];
+
+ test.each(tests)('$name', async ({ initialNodes }) => {
+ const expectedNode = nodes[0];
+
+ const nodeService = {
+ fetchNodes: jest.fn(),
+ } as Partial as NodeService;
+
+ jest
+ .mocked(nodeService)
+ .fetchNodes.mockResolvedValue({ agents: [...initialNodes, expectedNode] })
+ .mockResolvedValueOnce({ agents: initialNodes });
+
+ jest
+ .spyOn(useTeleport, 'default')
+ .mockReturnValue({ nodeService } as TeleportContext);
+
+ const { result, waitForValueToChange } = renderHook(() =>
+ usePollForConnectMyComputerNode({
+ username: 'alice',
+ clusterId: 'foo',
+ pingInterval: 1,
+ })
+ );
+
+ expect(result.error).toBeUndefined();
+ expect(result.current.node).toBeFalsy();
+ expect(result.current.isPolling).toBe(true);
+
+ await waitForValueToChange(() => result.current.node, { interval: 3 });
+
+ expect(result.current.node).toEqual(expectedNode);
+ expect(result.current.isPolling).toBe(false);
+ });
+});
diff --git a/web/packages/teleport/src/Discover/ConnectMyComputer/SetupConnect/SetupConnect.tsx b/web/packages/teleport/src/Discover/ConnectMyComputer/SetupConnect/SetupConnect.tsx
index fc6d6a1aa6fa9..01c7882c8053b 100644
--- a/web/packages/teleport/src/Discover/ConnectMyComputer/SetupConnect/SetupConnect.tsx
+++ b/web/packages/teleport/src/Discover/ConnectMyComputer/SetupConnect/SetupConnect.tsx
@@ -14,22 +14,47 @@
* limitations under the License.
*/
-import React from 'react';
+import React, { useEffect, useCallback, useState, useRef } from 'react';
+import { Link } from 'react-router-dom';
import { ButtonSecondary } from 'design/Button';
import { Platform, getPlatform } from 'design/platform';
import { Text, Flex } from 'design';
+import * as Icons from 'design/Icon';
import { MenuButton, MenuItem } from 'shared/components/MenuAction';
import { Path, makeDeepLinkWithSafeInput } from 'shared/deepLinks';
+import * as constants from 'shared/constants';
+import cfg from 'teleport/config';
import useTeleport from 'teleport/useTeleport';
+import { Node } from 'teleport/services/nodes';
-import { ActionButtons, Header, StyledBox } from 'teleport/Discover/Shared';
+import {
+ ActionButtons,
+ Header,
+ StyledBox,
+ TextIcon,
+} from 'teleport/Discover/Shared';
+import { usePoll } from 'teleport/Discover/Shared/usePoll';
+
+import {
+ HintBox,
+ SuccessBox,
+ WaitingInfo,
+} from 'teleport/Discover/Shared/HintBox';
import type { AgentStepProps } from '../../types';
-export function SetupConnect(props: AgentStepProps) {
+export function SetupConnect(
+ props: AgentStepProps & {
+ pingInterval?: number;
+ showHintTimeout?: number;
+ }
+) {
+ const pingInterval = props.pingInterval || 1000 * 3; // 3 seconds
+ const showHintTimeout = props.showHintTimeout || 1000 * 60 * 5; // 5 minutes
const ctx = useTeleport();
+ const clusterId = ctx.storeUser.getClusterId();
const { cluster, username } = ctx.storeUser.state;
const platform = getPlatform();
const downloadLinks = getConnectDownloadLinks(platform, cluster.proxyVersion);
@@ -39,6 +64,94 @@ export function SetupConnect(props: AgentStepProps) {
path: Path.ConnectMyComputer,
});
+ const { node, isPolling } = usePollForConnectMyComputerNode({
+ username,
+ clusterId,
+ pingInterval,
+ });
+
+ const [showHint, setShowHint] = useState(false);
+ useEffect(() => {
+ if (isPolling) {
+ const id = window.setTimeout(() => setShowHint(true), showHintTimeout);
+
+ return () => window.clearTimeout(id);
+ }
+ }, [isPolling, showHintTimeout]);
+
+ let pollingStatus: JSX.Element;
+ if (showHint && !node) {
+ pollingStatus = (
+ // Override max-width to match StyledBox's max-width.
+
+
+
+ There are a couple of possible reasons for why we haven't been able
+ to detect your computer.
+
+
+
p.theme.space[3]}px;
+ `}
+ >
+
+
+ You did not start Connect My Computer in Teleport Connect yet.
+
+
+
+
+ The Teleport agent started by Teleport Connect could not join
+ this Teleport cluster. Check if the Connect My Computer tab in
+ Teleport Connect shows any error messages.
+
+
+
+
+ The computer you are trying to add has already joined the
+ Teleport cluster before you entered this page. If that's the
+ case, you can go back to{' '}
+
+ the resources
+ {' '}
+ and connect to it.
+
+
+
+
+
+ We'll continue to look for the computer whilst you diagnose the
+ issue.
+
+
+
+ );
+ } else if (node) {
+ pollingStatus = (
+
+
+ Your computer, {node.hostname}, has been detected!
+
+
+ );
+ } else {
+ pollingStatus = (
+
+
+
+
+ After your computer is connected to the cluster, we’ll automatically
+ detect it.
+
+ );
+ }
+
return (
Set Up Teleport Connect
@@ -50,7 +163,7 @@ export function SetupConnect(props: AgentStepProps) {
Teleport Connect is a native desktop application for browsing and
accessing your resources. It can also connect your computer as an SSH
resource and scope access to a unique role so it is not automatically
- shared with anyone else in the cluster.
+ shared with all users in the cluster.
Once you’ve downloaded Teleport Connect, run the installer to add it
@@ -80,15 +193,90 @@ export function SetupConnect(props: AgentStepProps) {
+ {pollingStatus}
+
{}}
- disableProceed={true}
+ onProceed={props.nextStep}
+ disableProceed={!node}
onPrev={props.prevStep}
/>
);
}
+/**
+ * usePollForConnectMyComputerNode polls for a Connect My Computer node that joined the cluster
+ * after starting opening the SetupConnect step.
+ *
+ * The first polling request fills out a set of node IDs (initialNodeIdsRef). Subsequent requests
+ * check the returned nodes against this set. The hook stops polling as soon as a node that is not
+ * in the set was found.
+ *
+ * There can be multiple nodes matching the search criteria and we want the one that was added only
+ * after the user has started the guided flow, hence why we need to keep track of the IDs in a set.
+ *
+ * Unlike the DownloadScript step responsible for adding a server, we don't have a unique ID that
+ * identifies the node that the user added after following the steps from the guided flow. In
+ * theory, we could make the deep link button pass such ID to Connect, but the user would still be
+ * able to just launch the app directly and not use the link.
+ *
+ * Because of that, we must depend on comparing the list of nodes against the initial set of IDs.
+ */
+export const usePollForConnectMyComputerNode = (args: {
+ username: string;
+ clusterId: string;
+ pingInterval: number;
+}): {
+ node: Node | undefined;
+ isPolling: boolean;
+} => {
+ const ctx = useTeleport();
+ const [isPolling, setIsPolling] = useState(true);
+ const initialNodeIdsRef = useRef>(null);
+
+ const node = usePoll(
+ useCallback(
+ async signal => {
+ const request = {
+ query: `labels["${constants.ConnectMyComputerNodeOwnerLabel}"] == "${args.username}"`,
+ // An arbitrary limit where we bank on the fact that no one is going to have 50 Connect My
+ // Computer nodes assigned to them running at the same time.
+ limit: 50,
+ };
+
+ const response = await ctx.nodeService.fetchNodes(
+ args.clusterId,
+ request,
+ signal
+ );
+
+ // Fill out the set with node IDs if it's empty.
+ if (initialNodeIdsRef.current === null) {
+ initialNodeIdsRef.current = new Set(
+ response.agents.map(agent => agent.id)
+ );
+ return null;
+ }
+
+ // On subsequent requests, compare the nodes from the response against the set.
+ const node = response.agents.find(
+ agent => !initialNodeIdsRef.current.has(agent.id)
+ );
+
+ if (node) {
+ setIsPolling(false);
+ return node;
+ }
+ },
+ [ctx.nodeService, args.clusterId, args.username]
+ ),
+ isPolling,
+ args.pingInterval
+ );
+
+ return { node, isPolling };
+};
+
type DownloadLink = { text: string; url: string };
const DownloadConnect = (props: { downloadLinks: Array }) => {
diff --git a/web/packages/teleport/src/Discover/Shared/HintBox.tsx b/web/packages/teleport/src/Discover/Shared/HintBox.tsx
index 9732a18b81b2e..02b566cbfea49 100644
--- a/web/packages/teleport/src/Discover/Shared/HintBox.tsx
+++ b/web/packages/teleport/src/Discover/Shared/HintBox.tsx
@@ -23,8 +23,9 @@ import * as Icons from 'design/Icon';
import { TextIcon } from 'teleport/Discover/Shared/Text';
-const HintBoxContainer = styled(Box)`
- max-width: 1000px;
+const HintBoxContainer = styled(Box).attrs(props => ({
+ maxWidth: props.maxWidth,
+}))`
background-color: ${props => props.theme.colors.spotBackground[0]};
padding: ${props => `${props.theme.space[3]}px`};
border-radius: ${props => `${props.theme.space[2]}px`};
@@ -53,11 +54,12 @@ export const SuccessInfo = styled(Box)`
interface HintBoxProps {
header: string;
+ maxWidth?: string;
}
export function HintBox(props: React.PropsWithChildren) {
return (
-
+ keyAndValue.join('='))
.join(',');