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(',');