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 8a9eab3dd228a..928242fc741d8 100644 --- a/web/packages/teleport/src/Discover/ConnectMyComputer/SetupConnect/SetupConnect.story.tsx +++ b/web/packages/teleport/src/Discover/ConnectMyComputer/SetupConnect/SetupConnect.story.tsx @@ -37,6 +37,7 @@ const oneDay = 1000 * 60 * 60 * 24; const setupConnectProps = { prevStep: () => {}, nextStep: () => {}, + updateAgentMeta: () => {}, // Set high default intervals and timeouts so that stories don't poll for no reason. pingInterval: oneDay, showHintTimeout: oneDay, @@ -120,7 +121,12 @@ export const HintTimeout = () => ( HintTimeout.parameters = { msw: { - handlers: [noNodesHandler], + handlers: [ + noNodesHandler, + rest.post(cfg.api.webRenewTokenPath, (req, res, ctx) => + res(ctx.json({})) + ), + ], }, }; diff --git a/web/packages/teleport/src/Discover/ConnectMyComputer/SetupConnect/SetupConnect.test.ts b/web/packages/teleport/src/Discover/ConnectMyComputer/SetupConnect/SetupConnect.test.ts index c7184774d2851..18e9783624bdd 100644 --- a/web/packages/teleport/src/Discover/ConnectMyComputer/SetupConnect/SetupConnect.test.ts +++ b/web/packages/teleport/src/Discover/ConnectMyComputer/SetupConnect/SetupConnect.test.ts @@ -17,13 +17,12 @@ import { renderHook } from '@testing-library/react-hooks'; import * as useTeleport from 'teleport/useTeleport'; -import NodeService from 'teleport/services/nodes/nodes'; +import NodeService, { Node } from 'teleport/services/nodes'; +import UserService from 'teleport/services/user'; import TeleportContext from 'teleport/teleportContext'; import { nodes } from 'teleport/Nodes/fixtures'; -import { Node } from 'teleport/services/nodes'; - import { usePollForConnectMyComputerNode } from './SetupConnect'; beforeEach(() => { @@ -66,6 +65,7 @@ describe('usePollForConnectMyComputerNode', () => { username: 'alice', clusterId: 'foo', pingInterval: 1, + reloadUser: false, }) ); @@ -78,4 +78,64 @@ describe('usePollForConnectMyComputerNode', () => { expect(result.current.node).toEqual(expectedNode); expect(result.current.isPolling).toBe(false); }); + + it('reloads user before each poll if reloadUser is true', async () => { + const expectedNode = nodes[0]; + let hasReloadedUser = false; + + const nodeService = { + fetchNodes: jest.fn(), + } as Partial as NodeService; + + jest.mocked(nodeService).fetchNodes.mockImplementation(async () => { + if (hasReloadedUser) { + return { agents: [expectedNode] }; + } else { + return { agents: [] }; + } + }); + + const userService = { + reloadUser: jest.fn(), + } as Partial as typeof UserService; + + jest.mocked(userService).reloadUser.mockImplementation(async () => { + hasReloadedUser = true; + }); + + jest + .spyOn(useTeleport, 'default') + .mockReturnValue({ nodeService, userService } as TeleportContext); + + const { result, rerender, waitFor, waitForValueToChange } = renderHook( + usePollForConnectMyComputerNode, + { + initialProps: { + reloadUser: false, + username: 'alice', + clusterId: 'foo', + pingInterval: 1, + }, + } + ); + expect(result.error).toBeUndefined(); + await waitFor(() => { + expect(nodeService.fetchNodes).toHaveBeenCalled(); + }); + expect(userService.reloadUser).not.toHaveBeenCalled(); + + rerender({ + reloadUser: true, + username: 'alice', + clusterId: 'foo', + pingInterval: 1, + }); + expect(result.error).toBeUndefined(); + + await waitForValueToChange(() => result.current.node, { interval: 3 }); + expect(userService.reloadUser).toHaveBeenCalled(); + + 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 01c7882c8053b..83f45c9ecf770 100644 --- a/web/packages/teleport/src/Discover/ConnectMyComputer/SetupConnect/SetupConnect.tsx +++ b/web/packages/teleport/src/Discover/ConnectMyComputer/SetupConnect/SetupConnect.tsx @@ -53,6 +53,7 @@ export function SetupConnect( ) { 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; @@ -63,14 +64,46 @@ export function SetupConnect( username, path: Path.ConnectMyComputer, }); + const [showHint, setShowHint] = useState(false); const { node, isPolling } = usePollForConnectMyComputerNode({ username, clusterId, - pingInterval, + // If reloadUser is set to true, the polling callback takes longer to finish so let's increase + // the polling interval as well. + pingInterval: showHint ? pingInterval * 2 : pingInterval, + // Completing the Connect My Computer setup in Connect causes the user to gain a new role. That + // role grants access to nodes labeled with `teleport.dev/connect-my-computer/owner: + // `. + // + // In certain cases, that role might be the only role which grants the user the visibility of + // the Connect My Computer node. For example, if the user doesn't have a role like the built-in + // access which gives blanket access to all nodes, the user won't be able to see the node until + // they have the Connect My Computer role in their cert. + // + // As such, if we don't reload the cert during polling, it might never see the node. So let's + // flip it to true after a timeout. + reloadUser: showHint, }); - const [showHint, setShowHint] = useState(false); + // TODO(ravicious): Take these from the context rather than from props. + const { agentMeta, updateAgentMeta, nextStep } = props; + const handleNextStep = () => { + if (!node) { + return; + } + + updateAgentMeta({ + ...agentMeta, + // Node is an oddity in that the hostname is the more + // user identifiable resource name and what user expects + // as the resource name. + resourceName: node.hostname, + node, + }); + nextStep(); + }; + useEffect(() => { if (isPolling) { const id = window.setTimeout(() => setShowHint(true), showHintTimeout); @@ -196,7 +229,7 @@ export function SetupConnect( {pollingStatus} @@ -225,6 +258,7 @@ export function SetupConnect( export const usePollForConnectMyComputerNode = (args: { username: string; clusterId: string; + reloadUser: boolean; pingInterval: number; }): { node: Node | undefined; @@ -237,6 +271,10 @@ export const usePollForConnectMyComputerNode = (args: { const node = usePoll( useCallback( async signal => { + if (args.reloadUser) { + await ctx.userService.reloadUser(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 @@ -268,7 +306,13 @@ export const usePollForConnectMyComputerNode = (args: { return node; } }, - [ctx.nodeService, args.clusterId, args.username] + [ + ctx.nodeService, + ctx.userService, + args.clusterId, + args.username, + args.reloadUser, + ] ), isPolling, args.pingInterval diff --git a/web/packages/teleport/src/Discover/ConnectMyComputer/TestConnection/TestConnection.story.tsx b/web/packages/teleport/src/Discover/ConnectMyComputer/TestConnection/TestConnection.story.tsx new file mode 100644 index 0000000000000..c3d0adfe0fd7e --- /dev/null +++ b/web/packages/teleport/src/Discover/ConnectMyComputer/TestConnection/TestConnection.story.tsx @@ -0,0 +1,137 @@ +/** + * 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 React from 'react'; +import { MemoryRouter } from 'react-router'; +import { initialize, mswLoader } from 'msw-storybook-addon'; +import { rest } from 'msw'; + +import { nodes } from 'teleport/Nodes/fixtures'; +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 { TestConnection } from './TestConnection'; + +export default { + title: 'Teleport/Discover/ConnectMyComputer/TestConnection', + loaders: [mswLoader], +}; + +initialize(); + +const node = nodes[0]; + +const agentStepProps = { + prevStep: () => {}, + nextStep: () => {}, + agentMeta: { resourceName: node.hostname, node, agentMatcherLabels: [] }, +}; + +export const Story = () => { + return ( + + + + ); +}; + +Story.parameters = { + msw: { + handlers: [ + rest.post(cfg.api.webRenewTokenPath, (req, res, ctx) => + res(ctx.json({})) + ), + rest.get(cfg.api.nodesPath, (req, res, ctx) => + res(ctx.json({ items: [node] })) + ), + ], + }, +}; + +export const ReloadUserProcessing = () => { + return ( + + + + ); +}; + +ReloadUserProcessing.parameters = { + msw: { + handlers: [ + rest.post(cfg.api.webRenewTokenPath, (req, res, ctx) => + res(ctx.delay('infinite')) + ), + ], + }, +}; + +export const ReloadUserError = () => { + return ( + + + + ); +}; + +ReloadUserError.parameters = { + msw: { + handlers: [ + // The first handler returns an error immediately. Subsequent requests return after a delay so + // that we can show a spinner after clicking on "Retry". + rest.post(cfg.api.webRenewTokenPath, (req, res, ctx) => + res.once( + ctx.status(500), + ctx.json({ message: 'Could not renew session' }) + ) + ), + rest.post(cfg.api.webRenewTokenPath, (req, res, ctx) => + res( + ctx.delay(1000), + ctx.status(500), + ctx.json({ message: 'Could not renew session' }) + ) + ), + ], + }, +}; + +const Provider = ({ children }) => { + const ctx = createTeleportContext(); + + const preferences = makeDefaultUserPreferences(); + const updatePreferences = () => Promise.resolve(); + const getClusterPinnedResources = () => Promise.resolve([]); + const updateClusterPinnedResources = () => Promise.resolve(); + + return ( + + + {children} + + + ); +}; diff --git a/web/packages/teleport/src/Discover/ConnectMyComputer/TestConnection/TestConnection.tsx b/web/packages/teleport/src/Discover/ConnectMyComputer/TestConnection/TestConnection.tsx new file mode 100644 index 0000000000000..5be03c6df6f38 --- /dev/null +++ b/web/packages/teleport/src/Discover/ConnectMyComputer/TestConnection/TestConnection.tsx @@ -0,0 +1,134 @@ +/** + * 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 React, { useEffect, useCallback, useRef } from 'react'; + +import { ButtonPrimary, Flex, Indicator, Text } from 'design'; +import * as Icons from 'design/Icon'; +import { useAsync } from 'shared/hooks/useAsync'; + +import useTeleport from 'teleport/useTeleport'; +import { + ActionButtons, + StyledBox, + Header, + TextIcon, +} from 'teleport/Discover/Shared'; +import { NodeConnect } from 'teleport/UnifiedResources/ResourceActionButton'; + +import { NodeMeta } from '../../useDiscover'; + +import type { AgentStepProps } from '../../types'; + +export const TestConnection = (props: AgentStepProps) => { + const { userService, nodeService, storeUser } = useTeleport(); + const clusterId = storeUser.getClusterId(); + const meta = props.agentMeta as NodeMeta; + + const abortController = useRef(); + // When the user sets up Connect My Computer in Teleport Connect, a new role gets added to the + // user. Because of that, we need to reload the current session so that the user is able to + // connect to the new node, without having to log in to the cluster again. + // + // We also need to refetch the node so that it includes any new logins. + const [refetchNodeAttempt, refetchNode] = useAsync( + useCallback( + async (signal: AbortSignal) => { + await userService.reloadUser(signal); + + const response = await nodeService.fetchNodes( + clusterId, + { search: meta.node.id, limit: 1 }, + signal + ); + + if (response.agents.length === 0) { + throw new Error('Could not find the Connect My Computer node'); + } + + if (response.agents.length > 1) { + throw new Error( + 'Found multiple nodes matching the ID of the Connect My Computer node' + ); + } + + return response.agents[0]; + }, + [userService, nodeService, clusterId, meta.node.id] + ) + ); + + useEffect(() => { + abortController.current = new AbortController(); + + refetchNode(abortController.current.signal); + + return () => { + abortController.current.abort(); + }; + }, []); + + return ( + +
+
Start a Session
+
+ + + Step 1: Connect to Your Computer + + Optionally verify that you can connect to “ + {meta.resourceName} + ” by starting a session. + + {refetchNodeAttempt.status === '' || + (refetchNodeAttempt.status === 'processing' && )} + + {refetchNodeAttempt.status === 'error' && ( + <> + + + Encountered Error: {refetchNodeAttempt.statusText} + + + refetchNode(abortController.current.signal)} + > + Retry + + + )} + + {refetchNodeAttempt.status === 'success' && ( + + )} + + + +
+ ); +}; diff --git a/web/packages/teleport/src/Discover/ConnectMyComputer/TestConnection/index.ts b/web/packages/teleport/src/Discover/ConnectMyComputer/TestConnection/index.ts new file mode 100644 index 0000000000000..6852c35b30fc0 --- /dev/null +++ b/web/packages/teleport/src/Discover/ConnectMyComputer/TestConnection/index.ts @@ -0,0 +1,17 @@ +/** + * 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. + */ + +export { TestConnection } from './TestConnection'; diff --git a/web/packages/teleport/src/Discover/ConnectMyComputer/index.tsx b/web/packages/teleport/src/Discover/ConnectMyComputer/index.ts similarity index 73% rename from web/packages/teleport/src/Discover/ConnectMyComputer/index.tsx rename to web/packages/teleport/src/Discover/ConnectMyComputer/index.ts index 74c79ad382ff9..2c043f7bd6bd9 100644 --- a/web/packages/teleport/src/Discover/ConnectMyComputer/index.tsx +++ b/web/packages/teleport/src/Discover/ConnectMyComputer/index.ts @@ -14,15 +14,14 @@ * limitations under the License. */ -import React from 'react'; - import { ResourceViewConfig } from 'teleport/Discover/flow'; -import { ResourceKind } from 'teleport/Discover/Shared'; +import { Finished, ResourceKind } from 'teleport/Discover/Shared'; import { DiscoverEvent } from 'teleport/services/userEvent'; import { ResourceSpec } from '../SelectResource'; import { SetupConnect } from './SetupConnect'; +import { TestConnection } from './TestConnection'; export const ConnectMyComputerResource: ResourceViewConfig = { kind: ResourceKind.ConnectMyComputer, @@ -33,11 +32,18 @@ export const ConnectMyComputerResource: ResourceViewConfig = { eventName: DiscoverEvent.DeployService, }, { - title: 'Test Connection', - component: () =>
WIP
, + // TODO(ravicious): Rename this to "Test Connection" after implementing the connection test. + title: 'Start a Session', + component: TestConnection, eventName: DiscoverEvent.TestConnection, // TODO(ravicious): Manually emit success event on test connection success. - // manuallyEmitSuccessEvent: true, + manuallyEmitSuccessEvent: true, + }, + { + title: 'Finished', + component: Finished, + hide: true, + eventName: DiscoverEvent.Completed, }, ], }; diff --git a/web/packages/teleport/src/Discover/Shared/SetupAccess/useUserTraits.test.tsx b/web/packages/teleport/src/Discover/Shared/SetupAccess/useUserTraits.test.tsx index 6d17bf7900272..733d009c437f9 100644 --- a/web/packages/teleport/src/Discover/Shared/SetupAccess/useUserTraits.test.tsx +++ b/web/packages/teleport/src/Discover/Shared/SetupAccess/useUserTraits.test.tsx @@ -40,7 +40,7 @@ describe('onProceed correctly deduplicates, removes static traits, updates meta, const ctx = createTeleportContext(); jest.spyOn(ctx.userService, 'fetchUser').mockResolvedValue(getMockUser()); jest.spyOn(ctx.userService, 'updateUser').mockResolvedValue(null); - jest.spyOn(ctx.userService, 'applyUserTraits').mockResolvedValue(null); + jest.spyOn(ctx.userService, 'reloadUser').mockResolvedValue(null); jest .spyOn(userEventService, 'captureDiscoverEvent') .mockResolvedValue(null as never); // return value does not matter but required by ts @@ -120,7 +120,7 @@ describe('onProceed correctly deduplicates, removes static traits, updates meta, }); await waitFor(() => { - expect(ctx.userService.applyUserTraits).toHaveBeenCalledTimes(1); + expect(ctx.userService.reloadUser).toHaveBeenCalledTimes(1); }); // Test that we are updating the user with the correct traits. @@ -199,7 +199,7 @@ describe('onProceed correctly deduplicates, removes static traits, updates meta, }); await waitFor(() => { - expect(ctx.userService.applyUserTraits).toHaveBeenCalledTimes(1); + expect(ctx.userService.reloadUser).toHaveBeenCalledTimes(1); }); // Test that we are updating the user with the correct traits. @@ -270,7 +270,7 @@ describe('onProceed correctly deduplicates, removes static traits, updates meta, }); await waitFor(() => { - expect(ctx.userService.applyUserTraits).toHaveBeenCalledTimes(1); + expect(ctx.userService.reloadUser).toHaveBeenCalledTimes(1); }); // Test that we are updating the user with the correct traits. diff --git a/web/packages/teleport/src/Discover/Shared/SetupAccess/useUserTraits.ts b/web/packages/teleport/src/Discover/Shared/SetupAccess/useUserTraits.ts index 523f606a8936d..81702e8e03d15 100644 --- a/web/packages/teleport/src/Discover/Shared/SetupAccess/useUserTraits.ts +++ b/web/packages/teleport/src/Discover/Shared/SetupAccess/useUserTraits.ts @@ -248,7 +248,7 @@ export function useUserTraits(props: AgentStepProps) { throw error; }); - await ctx.userService.applyUserTraits().catch((error: Error) => { + await ctx.userService.reloadUser().catch((error: Error) => { emitErrorEvent(`error applying new user traits: ${error.message}`); throw error; }); diff --git a/web/packages/teleport/src/UnifiedResources/ResourceActionButton.tsx b/web/packages/teleport/src/UnifiedResources/ResourceActionButton.tsx index be2aac6c96e89..08473c3221b8a 100644 --- a/web/packages/teleport/src/UnifiedResources/ResourceActionButton.tsx +++ b/web/packages/teleport/src/UnifiedResources/ResourceActionButton.tsx @@ -54,7 +54,13 @@ export const ResourceActionButton = ({ resource }: Props) => { } }; -const NodeConnect = ({ node }: { node: Node }) => { +export const NodeConnect = ({ + node, + textTransform, +}: { + node: Node; + textTransform?: string; +}) => { const { clusterId } = useStickyClusterId(); const startSshSession = (login: string, serverId: string) => { const url = cfg.getSshConnectRoute({ @@ -78,7 +84,7 @@ const NodeConnect = ({ node }: { node: Node }) => { return ( { diff --git a/web/packages/teleport/src/services/websession/websession.ts b/web/packages/teleport/src/services/websession/websession.ts index 569033663ef7d..965276ce6603f 100644 --- a/web/packages/teleport/src/services/websession/websession.ts +++ b/web/packages/teleport/src/services/websession/websession.ts @@ -71,8 +71,8 @@ const session = { // renewSession renews session and returns the // absolute time the new session expires. - renewSession(req: RenewSessionRequest): Promise { - return this._renewToken(req).then(token => token.sessionExpires); + renewSession(req: RenewSessionRequest, signal?: AbortSignal): Promise { + return this._renewToken(req, signal).then(token => token.sessionExpires); }, /** @@ -133,10 +133,10 @@ const session = { return this._timeLeft() < RENEW_TOKEN_TIME; }, - _renewToken(req: RenewSessionRequest = {}) { + _renewToken(req: RenewSessionRequest = {}, signal?: AbortSignal) { this._setAndBroadcastIsRenewing(true); return api - .post(cfg.getRenewTokenUrl(), req) + .post(cfg.getRenewTokenUrl(), req, signal) .then(json => { const token = makeBearerToken(json); localStorage.setBearerToken(token);